From c7002e3b308306a00b8431db9167ebef82681aaa Mon Sep 17 00:00:00 2001 From: Kyou <1129057944@qq.com> Date: Sun, 12 Jul 2026 13:50:54 +0800 Subject: [PATCH] fix(daemon): detect Codex CLI under ChatGPT.app on macOS (#5250) Detect the bundled Codex CLI under the relocated ChatGPT.app on macOS, while keeping the legacy Codex.app path so older installs still resolve. Closes #5205 --- server/internal/daemon/config.go | 12 ++++- server/internal/daemon/config_test.go | 74 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 7ac96c9eba..f58c27f1eb 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -756,12 +756,22 @@ var defaultAgentCommandNames = []string{ "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy", "traecli", } +// codexDesktopAppBundlePaths returns candidate macOS app-bundle locations for +// the bundled Codex CLI. OpenAI relocated the Desktop app from Codex.app to +// ChatGPT.app (#5205). Candidates are ordered by install location first +// (system /Applications before user ~/Applications); within each location the +// new ChatGPT.app path is tried before the legacy Codex.app path, so updated +// installs win while older installs still resolve. var codexDesktopAppBundlePaths = func() []string { paths := []string{ + "/Applications/ChatGPT.app/Contents/Resources/codex", "/Applications/Codex.app/Contents/Resources/codex", } if home, err := os.UserHomeDir(); err == nil { - paths = append(paths, filepath.Join(home, "Applications", "Codex.app", "Contents", "Resources", "codex")) + paths = append(paths, + filepath.Join(home, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"), + filepath.Join(home, "Applications", "Codex.app", "Contents", "Resources", "codex"), + ) } return paths } diff --git a/server/internal/daemon/config_test.go b/server/internal/daemon/config_test.go index 9be8967bf6..4a41559941 100644 --- a/server/internal/daemon/config_test.go +++ b/server/internal/daemon/config_test.go @@ -660,6 +660,80 @@ func TestLoadConfig_UsesCodexDesktopAppBundleFallback(t *testing.T) { } } +// Regression for #5205: after OpenAI moved the Desktop app to ChatGPT.app, +// Multica must resolve the bundled CLI under ChatGPT.app (and prefer it over +// the legacy Codex.app path when both exist). +func TestLoadConfig_UsesChatGPTAppBundleCodexPath(t *testing.T) { + pathDir := t.TempDir() + fakeChatGPT := filepath.Join(pathDir, "ChatGPT.app", "Contents", "Resources", "codex") + fakeLegacy := filepath.Join(pathDir, "Codex.app", "Contents", "Resources", "codex") + for _, p := range []string{fakeChatGPT, fakeLegacy} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake CLI: %v", err) + } + } + + oldBundlePaths := codexDesktopAppBundlePaths + // Prefer ChatGPT first, matching production ordering. + codexDesktopAppBundlePaths = func() []string { return []string{fakeChatGPT, fakeLegacy} } + t.Cleanup(func() { codexDesktopAppBundlePaths = oldBundlePaths }) + + t.Setenv("PATH", t.TempDir()) + t.Setenv("SHELL", filepath.Join(t.TempDir(), "fish")) + t.Setenv("MULTICA_DAEMON_ID", "11111111-1111-1111-1111-111111111111") + pinNonCodexAgentsToMissingPaths(t) + + cfg, err := LoadConfig(Overrides{ + ServerURL: "http://localhost:0", + WorkspacesRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + got, ok := cfg.Agents["codex"] + if !ok { + t.Fatalf("expected codex agent from ChatGPT.app bundle, got %#v", cfg.Agents) + } + if got.Path != fakeChatGPT { + t.Fatalf("codex path = %q, want ChatGPT.app path %q", got.Path, fakeChatGPT) + } +} + +func TestCodexDesktopAppBundlePaths_IncludesChatGPTAndLegacy(t *testing.T) { + paths := codexDesktopAppBundlePaths() + var hasChatGPT, hasLegacy bool + for _, p := range paths { + if strings.Contains(p, "ChatGPT.app") && strings.HasSuffix(filepath.ToSlash(p), "Contents/Resources/codex") { + hasChatGPT = true + } + if strings.Contains(p, "Codex.app") && strings.HasSuffix(filepath.ToSlash(p), "Contents/Resources/codex") { + hasLegacy = true + } + } + if !hasChatGPT { + t.Fatalf("codexDesktopAppBundlePaths missing ChatGPT.app entry: %#v", paths) + } + if !hasLegacy { + t.Fatalf("codexDesktopAppBundlePaths missing legacy Codex.app entry: %#v", paths) + } + // New path must be preferred (listed before legacy). + chatgptIdx, legacyIdx := -1, -1 + for i, p := range paths { + if chatgptIdx < 0 && strings.Contains(p, "ChatGPT.app") { + chatgptIdx = i + } + if legacyIdx < 0 && strings.Contains(p, "Codex.app") { + legacyIdx = i + } + } + if chatgptIdx < 0 || legacyIdx < 0 || chatgptIdx > legacyIdx { + t.Fatalf("expected ChatGPT.app before Codex.app, got indices chat=%d legacy=%d paths=%#v", chatgptIdx, legacyIdx, paths) + } +} + func TestLoadConfig_CodexDesktopFallbackDoesNotOverrideExplicitPath(t *testing.T) { pathDir := t.TempDir() fakeCodex := filepath.Join(pathDir, "Codex.app", "Contents", "Resources", "codex")