Codex's install.ps1 puts %LOCALAPPDATA%\Programs\OpenAI\Codex\bin on PATH. That folder is a junction to ~\.codex\packages\standalone\current\bin, and current is a junction to releases\<version>-<target>, retargeted by each update. filepath.EvalSymlinks leaves junctions alone since Go 1.23, so magpie saw the binary only as ...\Programs\OpenAI\Codex\bin\codex.exe: - howInstalled didn't find .codex/packages/standalone in its path, so the Agents page showed the version with no newest version and no update. - installedVersion's stamp was that unchanged path plus size and mtime. npm's archive, which install.ps1's legacy layout copies from, gives every version's codex.exe the same 1985 mtime. So a new release was asked again only if its size happened to differ, else after the 24h memo. proc.RealPath is now the path Windows opens (GetFinalPathNameByHandle), junctions followed, and filepath.EvalSymlinks elsewhere. cliupdate's howInstalled and installedVersion and proc.Version use it. TestCodexInstallPs1Junctions (Windows) builds install.ps1's layout with mklink /J, two releases whose codex.exe have the same bytes and mtime. It checks that codex is recognized as self-updated (codex update) and that retargeting current makes the version asked again. On the Windows box (10.0.0.132, sandboxed USERPROFILE/APPDATA/LOCALAPPDATA) it fails on the old code (howInstalled nil; "0.161.0" after the update) and passes on the new. The rest of the agent package fails on Windows with the same 17 tests before and after (TestFolderVarsAreListed, TestAside*, TestVSCodium, ...), which CI never runs there. `codex update` on Windows is in codex.exe 0.162.0 (win32-x64): it runs install.ps1. The layout was read from https://chatgpt.com/codex/install.ps1. Not tried with a real install.ps1 install (it rewrites the user's PATH in the registry). go test -tags nogui ./internal/agent ./internal/proc ./internal/provider ./internal/library (macOS), gofmt, and go vet for GOOS=linux and windows pass.
98 lines
2.9 KiB
Go
98 lines
2.9 KiB
Go
package proc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Version is what the CLI at bin says its version is: the version in the
|
|
// package.json of the npm package it is a file of, read rather than run,
|
|
// else what bin --version prints (stdout and stderr, ANSI and all), "" when
|
|
// it says nothing. A run that fails other than by taking too long isn't
|
|
// made again until the binary changes (#864): macOS stops a program XProtect flags with
|
|
// a "contains malware" alert on each start, and a CLI asked its version
|
|
// every few minutes put one up every time magpie's window opened. An npm
|
|
// CLI isn't run at all: its package says the version its --version would.
|
|
func Version(bin string) string {
|
|
real, err := RealPath(bin)
|
|
if err != nil {
|
|
real = bin
|
|
}
|
|
st, err := os.Stat(real)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if v := npmVersion(real); v != "" {
|
|
return v
|
|
}
|
|
stamp := fmt.Sprint(real, st.Size(), st.ModTime().UnixNano())
|
|
versionsFailed.Lock()
|
|
failed := versionsFailed.m[bin] == stamp
|
|
versionsFailed.Unlock()
|
|
if failed {
|
|
return ""
|
|
}
|
|
out, err := runVersion(bin)
|
|
// a run cut off by the timeout (a busy machine, a slow first start) is
|
|
// made again; one that ran and said nothing, or couldn't start, is not.
|
|
// One that answered is asked again as its caller likes: what it says
|
|
// may come from a file beside it that an update changes.
|
|
if out == "" && !errors.Is(err, context.DeadlineExceeded) {
|
|
versionsFailed.Lock()
|
|
versionsFailed.m[bin] = stamp
|
|
versionsFailed.Unlock()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// versionsFailed is, for each CLI whose run failed, the binary it was.
|
|
var versionsFailed = struct {
|
|
sync.Mutex
|
|
m map[string]string
|
|
}{m: map[string]string{}}
|
|
|
|
// runVersion runs bin --version with nothing on its stdin; a var so tests
|
|
// can count the runs.
|
|
var runVersion = func(bin string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
cmd := ProbeContext(ctx, bin, "--version")
|
|
cmd.Stdin = nil // /dev/null: one that would ask something gets nothing
|
|
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
|
out, err := cmd.CombinedOutput()
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
// one that says its version and exits non-zero has still said it
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
// npmVersion is the version of the npm package the file at real is in
|
|
// (…/node_modules/@openai/codex/bin/codex.js is in @openai/codex), "" when
|
|
// it is in none.
|
|
func npmVersion(real string) string {
|
|
for d := filepath.Dir(real); ; d = filepath.Dir(d) {
|
|
up := filepath.Dir(d)
|
|
if up == d {
|
|
return ""
|
|
}
|
|
if filepath.Base(up) == "node_modules" || strings.HasPrefix(filepath.Base(up), "@") && filepath.Base(filepath.Dir(up)) == "node_modules" {
|
|
var p struct {
|
|
Version string `json:"version"`
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(d, "package.json"))
|
|
if err != nil || json.Unmarshal(b, &p) != nil {
|
|
return ""
|
|
}
|
|
return p.Version
|
|
}
|
|
}
|
|
}
|