Files
yetone 98236edd77 agent: Codex installed by install.ps1 on Windows shows its newest version and offers codex update, and its version is read again after each update (xiaoxiaofeixz on Discord: 不会自动检测最新的版本)
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.
2026-10-09 13:44:50 +08:00

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
}
}
}