Files
magpie/internal/proc/path_other.go
T
yetone dd12c75bff codex: a turn relayed through Codex accounts says it is the Codex client it came from, or newer, so a model Codex CLI reaches on its own is no longer refused through magpie (Discord, lantern2077 / TuskerManshu: gpt-6.1-sol through a routing group answered 400 "The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account" while Codex CLI alone could use it) — the ChatGPT backend serves a model only to a client new enough for it. Re-signing a pool turn, codexSign set User-Agent codex_cli_rs/<codexVersion()>, the newest of a constant 0.154.0, ~/.codex/models_cache.json's client_version (written by the Codex before the last update) and codex --version when codex was on PATH, ~/.local/bin or Homebrew's — nvm, mise, fnm, npm prefixes, pnpm and Volta were out of reach, above all in magpie serve — while the client's own version header was relayed: one turn saying two versions, the older in the User-Agent. Now codexSign writes one version in both, never older than the relayed one; the gateway notes the newest version a Codex client (originator or codex… User-Agent) has come with (SawCodexClient) and codexVersion, also asked by the accounts' model lists, takes it; the fallback is 0.159.0; the models cache is read from CODEX_HOME when set; codexExecutable also looks in proc.UserBinDirs() (every Node of nvm, mise and fnm, ~/.npmrc's prefix, pnpm, bun, Volta; %APPDATA%\npm and the like on Windows; codex.cmd/.exe there). Tested with TestCodexPoolSaysTheClientsVersion against a fake backend refusing gpt-6.1-sol below 0.161.0: Codex CLI 0.161.0 through the pool failed on main with codex_cli_rs/0.154.0 and passes; TestCodexVersionSeen (Claude Code's User-Agent ignored, CLI and Desktop versions read, never lowered, the model list asked with it), TestCodexVersion with CODEX_HOME, TestCodexExecutableOffPath (nvm, mise, an .npmrc prefix with PATH /usr/bin:/bin). go vet ./internal/..., go build -tags nogui (and GOOS=linux, GOOS=windows) and go test -tags nogui ./... pass. Not tried against the real backend: the version gating is read from the error and the models cache, not seen on the wire.
2026-09-30 10:06:15 +08:00

141 lines
4.0 KiB
Go

//go:build !windows
package proc
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// UserPath gives the desktop app the PATH a terminal of the user's has. One
// opened from the Finder, the Dock or a login item gets launchd's
// /usr/bin:/bin:/usr/sbin:/sbin and nothing a shell profile adds, so a claude
// or codex installed with a custom npm prefix (~/.npm-global/bin), nvm, bun,
// volta or asdf wasn't found: the agent wasn't seen and its subscription
// couldn't be used. The folders those tools use are added at once, when they
// exist; the login shell's own PATH is asked for in the background and
// added after, since a slow profile mustn't hold the window up.
func UserPath() {
addPath(userDirs(false))
go func() {
if p := shellPath(); p != "" {
addPath(filepath.SplitList(p))
}
}()
}
// UserBinDirs are the folders a user's command-line tools are installed in
// that exist here — npm's prefixes, each Node version of nvm, fnm and mise,
// bun, volta, pnpm, asdf, the standalone installers' ~/.local/bin,
// Homebrew — for finding one PATH doesn't reach.
func UserBinDirs() []string { return userDirs(true) }
func userDirs(everyNode bool) []string {
home, _ := os.UserHomeDir()
var known []string
for _, d := range []string{".local/bin", ".npm-global/bin", ".npm/bin", ".bun/bin", ".volta/bin", ".asdf/shims", ".local/share/mise/shims", ".cargo/bin", ".deno/bin", "Library/pnpm"} {
known = append(known, filepath.Join(home, d))
}
nodes := func(pattern string) {
vs, _ := filepath.Glob(filepath.Join(home, pattern))
if len(vs) == 0 {
return
}
if !everyNode {
known = append(known, vs[len(vs)-1])
return
}
slices.Reverse(vs)
known = append(known, vs...)
}
nodes(".nvm/versions/node/*/bin")
if everyNode {
known = append(known, filepath.Join(home, ".local/share/pnpm"), npmPrefix(home))
nodes(".local/share/mise/installs/node/*/bin")
nodes(".local/share/fnm/node-versions/*/installation/bin")
nodes("Library/Application Support/fnm/node-versions/*/installation/bin")
}
known = append(known, "/opt/homebrew/bin", "/usr/local/bin")
var have []string
for _, d := range known {
if st, err := os.Stat(d); d != "" && err == nil && st.IsDir() {
have = append(have, d)
}
}
return have
}
// npmPrefix is the bin folder of the npm prefix ~/.npmrc names, "" when it
// names none.
func npmPrefix(home string) string {
b, err := os.ReadFile(filepath.Join(home, ".npmrc"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok || strings.TrimSpace(k) != "prefix" {
continue
}
v = strings.Trim(strings.TrimSpace(v), `"'`)
if strings.HasPrefix(v, "~/") {
v = filepath.Join(home, v[2:])
}
if filepath.IsAbs(v) {
return filepath.Join(v, "bin")
}
}
return ""
}
// shellPath asks the user's login shell for its PATH; "" when it can't say
// within a few seconds.
func shellPath() string {
sh := os.Getenv("SHELL")
if sh == "" || !filepath.IsAbs(sh) {
sh = "/bin/zsh"
if _, err := os.Stat(sh); err != nil {
sh = "/bin/sh"
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// interactive too, since many put PATH in .zshrc/.bashrc; the marker
// tells PATH apart from whatever the profile prints
const mark = "__magpie_path__"
cmd := CommandContext(ctx, sh, "-ilc", "printf '"+mark+"%s"+mark+"' \"$PATH\"")
cmd.Stdin = nil
out, _ := cmd.Output()
s := string(out)
i := strings.Index(s, mark)
if i < 0 {
return ""
}
s = s[i+len(mark):]
j := strings.Index(s, mark)
if j < 0 {
return ""
}
return s[:j]
}
// addPath adds the folders PATH lacks, after the ones it has.
func addPath(dirs []string) {
cur := filepath.SplitList(os.Getenv("PATH"))
seen := map[string]bool{}
for _, d := range cur {
seen[d] = true
}
for _, d := range dirs {
if d != "" && filepath.IsAbs(d) && !seen[d] {
seen[d] = true
cur = append(cur, d)
}
}
os.Setenv("PATH", strings.Join(cur, string(os.PathListSeparator)))
}