Skip to content
Merged
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
10 changes: 10 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ codex --version
codex-switch account usage <alias>
```

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 <path>` 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
Expand Down
15 changes: 10 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type runtimeState struct {
paths appconfig.Paths
manager *vault.Manager
bin string
binErr error
}

type accountView struct {
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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, "")
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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,
},
}
Expand Down
42 changes: 39 additions & 3 deletions internal/codexlogin/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/SilkageNet/codex-switch/internal/atomicfile"
Expand All @@ -21,20 +22,55 @@ 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
}
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 {
Expand Down
73 changes: 73 additions & 0 deletions internal/codexlogin/login_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions internal/codexusage/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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")
}
Expand Down
9 changes: 9 additions & 0 deletions internal/codexusage/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
Expand Down Expand Up @@ -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{
Expand Down
6 changes: 4 additions & 2 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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}
Expand Down