diff --git a/README.md b/README.md index 529e2d2..7f036a9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ go install github.com/SilkageNet/codex-switch/cmd/codex-switch@latest Release archives for macOS, Linux, and Windows are published on GitHub. +Adding accounts and querying live usage require an official Codex CLI that this +process can launch. On Windows, install the +[standalone Codex CLI](https://learn.chatgpt.com/docs/codex/cli); the executable +bundled inside the Codex desktop app's WindowsApps package cannot be launched by +external processes. Verify the installation with `codex --version` before using +those commands. + WSL2 is supported by the Linux archive. It uses the Windows user's DPAPI protection through the built-in `powershell.exe`; a Linux desktop Secret Service session is not required. diff --git a/docs/compatibility.md b/docs/compatibility.md index 8dd2b88..31a1365 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -51,6 +51,13 @@ queries were validated with `0.148.0-alpha.21`. The project does not use private OAuth or usage endpoints. Login is delegated to the installed official CLI, and usage is read through the documented stable Codex App Server protocol. +On Windows, these operations require the standalone Codex CLI. The executable +inside the Microsoft Store/MSIX desktop package is private to that package and +cannot be spawned by an external process. `codex-switch` skips that entry while +searching `PATH` and returns an actionable error when no standalone CLI is +available. A standalone CLI can also be selected with `CODEX_BINARY` or +`--codex-binary`. + Usage querying initializes `codex app-server` and calls: - `account/read` @@ -71,5 +78,6 @@ accepted shape. - OpenAI authentication documentation: https://developers.openai.com/codex/auth - OpenAI Codex App Server documentation: https://learn.chatgpt.com/docs/app-server +- OpenAI Codex CLI documentation: https://learn.chatgpt.com/docs/codex/cli - CC Switch managed Codex OAuth implementation: https://github.com/farion1231/cc-switch/tree/v3.20.0 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 54b5e17..afc3773 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -81,6 +81,16 @@ codex --version codex-switch account usage ``` +On Windows, a path under +`C:\Program Files\WindowsApps\OpenAI.Codex_*\app\resources\codex.exe` belongs to +the desktop app package. Windows does not allow `codex-switch` to launch that +bundled executable directly. Install the +[standalone Codex CLI](https://learn.chatgpt.com/docs/codex/cli), open a new +terminal, and verify that `codex --version` resolves to the standalone CLI. If +needed, select it explicitly with `--codex-binary ` or the `CODEX_BINARY` +environment variable. `codex-switch doctor` reports this case separately from a +completely missing CLI. + Usage queries require network access and a saved ChatGPT login. They do not work for API-key-only or Amazon Bedrock authentication. A missing method on an older Codex build is reported as partial when the other method still works; update diff --git a/internal/app/app.go b/internal/app/app.go index 9131310..3bf4c2a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,6 +48,7 @@ type runtimeState struct { paths appconfig.Paths manager *vault.Manager bin string + binErr error } type accountView struct { @@ -204,6 +205,9 @@ func newAccountAddCommand(options *Options, reauth bool) *cobra.Command { if err != nil { return err } + if runtime.binErr != nil { + return runtime.binErr + } if runtime.bin == "" { return errors.New("official Codex executable not found; install Codex or pass --codex-binary") } @@ -595,7 +599,7 @@ func newDoctorCommand(options *Options) *cobra.Command { if err != nil { return err } - report := doctor.Run(runtime.home, runtime.paths, runtime.manager, runtime.bin) + report := doctor.Run(runtime.home, runtime.paths, runtime.manager, runtime.bin, runtime.binErr) if options.JSON { return options.render(report, "") } @@ -831,8 +835,8 @@ func (options *Options) loadRuntime(create bool) (runtimeState, error) { return runtimeState{}, err } } - bin, _ := codexlogin.FindBinary(options.CodexBin) - return runtimeState{home: home, paths: paths, manager: manager, bin: bin}, nil + bin, binErr := codexlogin.FindBinary(options.CodexBin) + return runtimeState{home: home, paths: paths, manager: manager, bin: bin, binErr: binErr}, nil } func (options *Options) loadRuntimeForDoctor() (runtimeState, error) { @@ -848,8 +852,8 @@ func (options *Options) loadRuntimeForDoctor() (runtimeState, error) { if storeErr != nil { return runtimeState{}, storeErr } - bin, _ := codexlogin.FindBinary(options.CodexBin) - return runtimeState{home: home, paths: paths, manager: vault.New(paths.Vault, store), bin: bin}, nil + bin, binErr := codexlogin.FindBinary(options.CodexBin) + return runtimeState{home: home, paths: paths, manager: vault.New(paths.Vault, store), bin: bin, binErr: binErr}, nil } func (runtime runtimeState) switcher() switcher.Service { @@ -863,6 +867,7 @@ func (runtime runtimeState) usageService(version string) accountusage.Service { Vault: runtime.manager, Runner: codexusage.Runner{ Binary: runtime.bin, + BinaryError: runtime.binErr, ClientVersion: version, }, } diff --git a/internal/codexlogin/login.go b/internal/codexlogin/login.go index f73cc13..6e027b8 100644 --- a/internal/codexlogin/login.go +++ b/internal/codexlogin/login.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/SilkageNet/codex-switch/internal/atomicfile" @@ -21,13 +22,18 @@ type Runner struct { func FindBinary(override string) (string, error) { if override != "" { - return override, nil + return validateBinary(override) } if configured := os.Getenv("CODEX_BINARY"); configured != "" { - return configured, nil + return validateBinary(configured) } if path, err := exec.LookPath("codex"); err == nil { - return path, nil + if isWindowsDesktopBundle(path) { + if standalone := findStandaloneBinaryOnPath(); standalone != "" { + return standalone, nil + } + } + return validateBinary(path) } if path := "/Applications/ChatGPT.app/Contents/Resources/codex"; fileExists(path) { return path, nil @@ -35,6 +41,36 @@ func FindBinary(override string) (string, error) { return "", fmt.Errorf("codex executable not found; install Codex CLI or set CODEX_BINARY") } +func validateBinary(path string) (string, error) { + if isWindowsDesktopBundle(path) { + return "", fmt.Errorf("the Codex desktop bundled executable at %s cannot be launched externally on Windows; install the standalone Codex CLI or pass --codex-binary", path) + } + return path, nil +} + +func findStandaloneBinaryOnPath() string { + for _, directory := range filepath.SplitList(os.Getenv("PATH")) { + directory = strings.Trim(strings.TrimSpace(directory), `"`) + if directory == "" { + continue + } + path, err := exec.LookPath(filepath.Join(directory, "codex")) + if err == nil && !isWindowsDesktopBundle(path) { + return path + } + } + return "" +} + +func isWindowsDesktopBundle(path string) bool { + if runtime.GOOS != "windows" { + return false + } + normalized := strings.ToLower(strings.ReplaceAll(filepath.Clean(path), "/", `\`)) + return strings.Contains(normalized, `\windowsapps\openai.codex_`) && + strings.HasSuffix(normalized, `\app\resources\codex.exe`) +} + func (runner Runner) Login(deviceAuth bool) (authschema.Document, error) { temporaryHome, err := os.MkdirTemp("", "codex-switch-login-*") if err != nil { diff --git a/internal/codexlogin/login_test.go b/internal/codexlogin/login_test.go new file mode 100644 index 0000000..c398af5 --- /dev/null +++ b/internal/codexlogin/login_test.go @@ -0,0 +1,73 @@ +package codexlogin + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestFindBinaryRejectsWindowsDesktopBundle(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows package paths are only rejected on Windows") + } + t.Setenv("CODEX_BINARY", "") + + path := `C:\Program Files\WindowsApps\OpenAI.Codex_26.818.3698.0_x64__2p2nqsd0c76g0\app\resources\codex.exe` + found, err := FindBinary(path) + if err == nil { + t.Fatalf("FindBinary(%q) succeeded with %q", path, found) + } + if found != "" { + t.Fatalf("FindBinary(%q) returned unusable path %q", path, found) + } + for _, expected := range []string{"cannot be launched externally on Windows", "standalone Codex CLI", "--codex-binary"} { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("error %q does not contain %q", err, expected) + } + } +} + +func TestFindBinaryAcceptsExplicitStandaloneBinary(t *testing.T) { + t.Setenv("CODEX_BINARY", "") + + path := `C:\Users\person\.local\bin\codex.exe` + found, err := FindBinary(path) + if err != nil { + t.Fatal(err) + } + if found != path { + t.Fatalf("FindBinary(%q) = %q", path, found) + } +} + +func TestFindBinarySkipsWindowsDesktopBundleOnPath(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows package paths are only rejected on Windows") + } + t.Setenv("CODEX_BINARY", "") + t.Setenv("PATHEXT", ".COM;.EXE;.BAT;.CMD") + + root := t.TempDir() + desktopDir := filepath.Join(root, "WindowsApps", "OpenAI.Codex_test_x64__package", "app", "resources") + standaloneDir := filepath.Join(root, "standalone") + for _, dir := range []string{desktopDir, standaloneDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "codex.exe"), nil, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", strings.Join([]string{desktopDir, standaloneDir}, string(os.PathListSeparator))) + + found, err := FindBinary("") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(standaloneDir, "codex.exe") + if !strings.EqualFold(found, want) { + t.Fatalf("FindBinary() = %q, want %q", found, want) + } +} diff --git a/internal/codexusage/runner.go b/internal/codexusage/runner.go index d0e0f6a..2ceb638 100644 --- a/internal/codexusage/runner.go +++ b/internal/codexusage/runner.go @@ -23,6 +23,7 @@ const maxProtocolMessage = 8 << 20 type Runner struct { Binary string + BinaryError error ClientVersion string command func(context.Context, string, ...string) *exec.Cmd } @@ -45,6 +46,9 @@ type rpcError struct { } func (runner Runner) Query(ctx context.Context, auth json.RawMessage) (Snapshot, json.RawMessage, error) { + if runner.BinaryError != nil { + return Snapshot{}, nil, runner.BinaryError + } if runner.Binary == "" { return Snapshot{}, nil, errors.New("official Codex executable not found; install Codex or pass --codex-binary") } diff --git a/internal/codexusage/runner_test.go b/internal/codexusage/runner_test.go index 25914fb..46167bf 100644 --- a/internal/codexusage/runner_test.go +++ b/internal/codexusage/runner_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -58,6 +59,14 @@ func TestProtocolErrorRedactsChildDiagnostics(t *testing.T) { } } +func TestRunnerReturnsBinaryDiscoveryError(t *testing.T) { + discoveryErr := errors.New("standalone Codex CLI required") + _, _, err := (Runner{BinaryError: discoveryErr}).Query(context.Background(), testAuth("account-a", "refresh-old", "2026-08-20T00:00:00Z")) + if !errors.Is(err, discoveryErr) { + t.Fatalf("Query() error = %v, want %v", err, discoveryErr) + } +} + func helperRunner(t *testing.T, partial bool) Runner { t.Helper() return Runner{ diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 9d2cf38..f0cdcd8 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -25,7 +25,7 @@ type Report struct { Checks []Check `json:"checks"` } -func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, codexBinary string) Report { +func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, codexBinary string, codexBinaryErr error) Report { report := Report{OK: true} add := func(name, status, message string) { report.Checks = append(report.Checks, Check{Name: name, Status: status, Message: message}) @@ -72,7 +72,9 @@ func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, cod add("vault", "ok", fmt.Sprintf("%d account profile(s)", len(data.Profiles))) } - if codexBinary == "" { + if codexBinaryErr != nil { + add("codex_cli", "error", codexBinaryErr.Error()) + } else if codexBinary == "" { add("codex_cli", "error", "codex executable not found") } else { runner := codexlogin.Runner{Binary: codexBinary}