源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package runtimecmd
|
||||
|
||||
import "os"
|
||||
|
||||
func executableNameCandidates(command string, _ []string) []string {
|
||||
return []string{command}
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
stat, err := os.Stat(path)
|
||||
return err == nil && !stat.IsDir() && stat.Mode().Perm()&0o111 != 0
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultWindowsPathExt = ".COM;.EXE;.BAT;.CMD;.PS1"
|
||||
|
||||
func executableNameCandidates(command string, env []string) []string {
|
||||
if filepath.Ext(command) != "" {
|
||||
return []string{command}
|
||||
}
|
||||
pathExt := strings.TrimSpace(envValue(env, "PATHEXT"))
|
||||
if pathExt == "" {
|
||||
pathExt = defaultWindowsPathExt
|
||||
}
|
||||
// Prefer Windows PATHEXT launchers before an extensionless file. npm writes
|
||||
// both a POSIX `opencode` shim and a Windows `opencode.cmd` shim into the
|
||||
// same prefix; choosing the POSIX shim makes ACP probes hang or fail on
|
||||
// Windows. Keep the extensionless candidate as a final fallback for managed
|
||||
// PE launchers such as the Tutti agent binary.
|
||||
result := make([]string, 0, 6)
|
||||
seen := map[string]struct{}{}
|
||||
extensions := strings.Split(pathExt, ";")
|
||||
hasPowerShell := false
|
||||
for _, extension := range extensions {
|
||||
if strings.EqualFold(strings.TrimSpace(extension), ".PS1") {
|
||||
hasPowerShell = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPowerShell {
|
||||
extensions = append(extensions, ".PS1")
|
||||
}
|
||||
for _, extension := range extensions {
|
||||
extension = strings.TrimSpace(extension)
|
||||
if extension == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(extension, ".") {
|
||||
extension = "." + extension
|
||||
}
|
||||
candidate := command + extension
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
if _, ok := seen[strings.ToLower(command)]; !ok {
|
||||
result = append(result, command)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isExecutableFile(path string) bool {
|
||||
stat, err := os.Stat(path)
|
||||
return err == nil && !stat.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolverPrefersWindowsNPMShimOverPOSIXShim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
posixShim := filepath.Join(dir, "opencode")
|
||||
windowsShim := filepath.Join(dir, "opencode.cmd")
|
||||
if err := os.WriteFile(posixShim, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(windowsShim, []byte("@echo off\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=" + dir} },
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
if got := resolver.Resolve("opencode", resolver.Env(nil)); !strings.EqualFold(got, windowsShim) {
|
||||
t.Fatalf("Resolve() = %q, want Windows shim %q", got, windowsShim)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// NPMGlobalLayout describes npm's platform-specific global prefix layout.
|
||||
type NPMGlobalLayout struct {
|
||||
PrefixDir string
|
||||
BinDir string
|
||||
}
|
||||
|
||||
// ResolveNPMGlobalLayout keeps npm install and executable discovery on the
|
||||
// same platform contract. installBinDir is the directory that must contain
|
||||
// the user-facing npm launcher.
|
||||
func ResolveNPMGlobalLayout(installBinDir string) NPMGlobalLayout {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows npm writes global command shims directly into its prefix (not
|
||||
// <prefix>/bin). The requested directory is therefore both the prefix
|
||||
// and the executable directory so a managed install produces, for
|
||||
// example, %USERPROFILE%\\.local\\bin\\codex.cmd.
|
||||
return NPMGlobalLayout{PrefixDir: installBinDir, BinDir: installBinDir}
|
||||
}
|
||||
prefixDir := filepath.Dir(installBinDir)
|
||||
return NPMGlobalLayout{PrefixDir: prefixDir, BinDir: installBinDir}
|
||||
}
|
||||
|
||||
// UserManagedNPMExecutableDirs returns Tutti's current user-level npm launcher
|
||||
// directory followed by any backward-compatible directory that older Tutti
|
||||
// releases used. Callers use this list for discovery only; fresh installs still
|
||||
// target the first entry.
|
||||
func UserManagedNPMExecutableDirs(home string) []string {
|
||||
installBinDir := filepath.Join(home, ".local", "bin")
|
||||
dirs := []string{ResolveNPMGlobalLayout(installBinDir).BinDir}
|
||||
if runtime.GOOS == "windows" {
|
||||
// Older Windows releases passed %USERPROFILE%\.local as npm's prefix,
|
||||
// so npm placed codex.cmd/tutti-agent.cmd directly in that directory.
|
||||
dirs = append(dirs, filepath.Join(home, ".local"))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build windows
|
||||
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveNPMGlobalLayoutUsesRequestedBinAsWindowsPrefix(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
requestedBin := filepath.Join(home, ".local", "bin")
|
||||
layout := ResolveNPMGlobalLayout(requestedBin)
|
||||
want := requestedBin
|
||||
if layout.PrefixDir != want || layout.BinDir != want {
|
||||
t.Fatalf("ResolveNPMGlobalLayout() = %#v, want prefix and bin %q", layout, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserManagedNPMExecutableDirsIncludesLegacyWindowsPrefix(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dirs := UserManagedNPMExecutableDirs(home)
|
||||
want := []string{
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".local"),
|
||||
}
|
||||
if len(dirs) != len(want) {
|
||||
t.Fatalf("UserManagedNPMExecutableDirs() = %#v, want %#v", dirs, want)
|
||||
}
|
||||
for index := range want {
|
||||
if dirs[index] != want[index] {
|
||||
t.Fatalf("UserManagedNPMExecutableDirs()[%d] = %q, want %q", index, dirs[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFindsWindowsNPMGlobalShim(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
shimDir := filepath.Join(home, ".local", "bin")
|
||||
if err := os.MkdirAll(shimDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shim := filepath.Join(shimDir, "codex.cmd")
|
||||
if err := os.WriteFile(shim, []byte("@echo off\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=C:\\Windows\\System32"} },
|
||||
HomeDir: func() (string, error) { return home, nil },
|
||||
LookPath: func(string) (string, error) { return "", os.ErrNotExist },
|
||||
IsExecutableFile: func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
},
|
||||
}
|
||||
if got := resolver.ResolveBinary([]string{"codex.exe", "codex.cmd"}, nil); got != shim {
|
||||
t.Fatalf("ResolveBinary() = %q, want %q", got, shim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFindsLegacyWindowsNPMGlobalShimOutsidePath(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
legacyPrefix := filepath.Join(home, ".local")
|
||||
if err := os.MkdirAll(legacyPrefix, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shim := filepath.Join(legacyPrefix, "codex.cmd")
|
||||
if err := os.WriteFile(shim, []byte("@echo off\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=C:\\Windows\\System32"} },
|
||||
HomeDir: func() (string, error) { return home, nil },
|
||||
LookPath: func(string) (string, error) { return "", os.ErrNotExist },
|
||||
IsExecutableFile: func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
},
|
||||
}
|
||||
if got := resolver.ResolveBinary([]string{"codex.exe", "codex.cmd"}, nil); got != shim {
|
||||
t.Fatalf("ResolveBinary() = %q, want legacy shim %q", got, shim)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
// System proxy injection.
|
||||
//
|
||||
// Some standalone agent processes only honor the
|
||||
// HTTP(S)_PROXY environment variables — they do NOT read the OS system proxy.
|
||||
// Desktop runtimes commonly work around this by resolving the OS proxy via
|
||||
// Electron's session.resolveProxy() (Chromium/SystemConfiguration) and injecting
|
||||
// HTTPS_PROXY/HTTP_PROXY into the spawned process. Without it, a child agent
|
||||
// connects directly and, from a restricted region, gets `403 Request not
|
||||
// allowed` from the upstream API while the app keeps working.
|
||||
//
|
||||
// We mirror that behavior here through a narrow platform adapter and inject
|
||||
// equivalent env vars. To stay faithful to the
|
||||
// app we: skip SOCKS entries (downstream HTTP agents don't speak SOCKS), use the
|
||||
// same default NO_PROXY, and never override a proxy the user/session already set.
|
||||
//
|
||||
// Effective precedence, everywhere (spawned agents and in-process clients):
|
||||
//
|
||||
// session-explicit env > process env (incl. the user's shell env, when the
|
||||
// desktop forwards it) > OS system proxy > direct
|
||||
//
|
||||
// Out of scope, deliberately: SOCKS proxies and PAC resolution (scutil only
|
||||
// exposes only a PAC URL; the desktop's Chromium stack handles PAC natively for
|
||||
// its own requests). Windows support intentionally covers the static per-user
|
||||
// proxy only. Setting TUTTI_DISABLE_PROXY_AUTODETECT=1 disables system-proxy
|
||||
// detection and injection entirely; explicit env proxies keep working.
|
||||
|
||||
// noProxyDefault matches the value the Claude desktop app injects.
|
||||
const noProxyDefault = "localhost,127.0.0.1,::1,.local"
|
||||
|
||||
// disableProxyAutodetectEnvKey is the escape hatch for machines where the
|
||||
// detected system proxy is broken but direct connections work.
|
||||
const disableProxyAutodetectEnvKey = "TUTTI_DISABLE_PROXY_AUTODETECT"
|
||||
|
||||
func proxyAutodetectDisabled() bool {
|
||||
value := strings.TrimSpace(os.Getenv(disableProxyAutodetectEnvKey))
|
||||
return value == "1" || strings.EqualFold(value, "true")
|
||||
}
|
||||
|
||||
// injectSystemProxyEnv appends HTTPS_PROXY/HTTP_PROXY/NO_PROXY derived from the
|
||||
// OS system proxy, but only for keys not already present (case-insensitive)
|
||||
// in env, so explicit user/session settings always win.
|
||||
func (r Resolver) injectSystemProxyEnv(env []string) []string {
|
||||
if proxyAutodetectDisabled() {
|
||||
return env
|
||||
}
|
||||
proxyEnv := r.systemProxyEnv()
|
||||
if len(proxyEnv) == 0 {
|
||||
return env
|
||||
}
|
||||
for _, key := range []string{"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"} {
|
||||
value, ok := proxyEnv[key]
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := envValueFrom(env, key); exists {
|
||||
continue
|
||||
}
|
||||
env = append(env, key+"="+value)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// InjectSystemProxyEnv appends the OS system proxy variables
|
||||
// (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) to env for any key not already present,
|
||||
// reading the same platform system-proxy source as Resolver.Env(). It exists so
|
||||
// the spawn paths that build their own environment instead of going through
|
||||
// Resolver.Env() — managed-runtime installs, agent logins, workspace terminals —
|
||||
// get identical proxy treatment. No-op on unsupported platforms or when no system proxy is
|
||||
// configured; never overrides a user/session-set value.
|
||||
func InjectSystemProxyEnv(env []string) []string {
|
||||
return Resolver{}.injectSystemProxyEnv(env)
|
||||
}
|
||||
|
||||
// DynamicProxyFunc returns an http.Transport.Proxy function that resolves the
|
||||
// proxy per request: explicit proxies from the process environment win, the
|
||||
// OS system proxy fills in the blanks, and NO_PROXY (from env, the system
|
||||
// exceptions, or noProxyDefault) plus loopback targets always bypass. It is the
|
||||
// in-process counterpart of InjectSystemProxyEnv, so daemon HTTP clients and
|
||||
// spawned agents make the same routing decision. The system proxy is re-read
|
||||
// (through a short-lived cache) on every call, so toggling the proxy panel
|
||||
// takes effect without restarting the daemon.
|
||||
func DynamicProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||
return func(req *http.Request) (*url.URL, error) {
|
||||
cfg := httpproxy.FromEnvironment()
|
||||
if !proxyAutodetectDisabled() {
|
||||
mergeSystemProxy(cfg, Resolver{}.systemProxyEnv())
|
||||
}
|
||||
return cfg.ProxyFunc()(req.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveProxySummary reports which source currently supplies the outbound
|
||||
// proxy — "env" (explicit environment variables), "system" (OS system
|
||||
// proxy), or "none" — plus the proxy address as host:port with any credentials
|
||||
// stripped. Startup/diagnostic logging only; it answers "did requests on this
|
||||
// machine go through a proxy" without another capture round-trip.
|
||||
func EffectiveProxySummary() (source string, host string) {
|
||||
fromEnv := httpproxy.FromEnvironment()
|
||||
if raw := firstNonEmptyString(fromEnv.HTTPSProxy, fromEnv.HTTPProxy); raw != "" {
|
||||
return "env", proxyHostForLog(raw)
|
||||
}
|
||||
if proxyAutodetectDisabled() {
|
||||
return "none", ""
|
||||
}
|
||||
sys := Resolver{}.systemProxyEnv()
|
||||
if raw := firstNonEmptyString(sys["HTTPS_PROXY"], sys["HTTP_PROXY"]); raw != "" {
|
||||
return "system", proxyHostForLog(raw)
|
||||
}
|
||||
return "none", ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// proxyHostForLog reduces a proxy URL to host:port, never exposing userinfo.
|
||||
func proxyHostForLog(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed == nil || parsed.Host == "" {
|
||||
// Scheme-less values ("host:port") parse without a Host; retry with an
|
||||
// explicit scheme rather than logging the raw (possibly credentialed)
|
||||
// string.
|
||||
parsed, err = url.Parse("http://" + raw)
|
||||
if err != nil || parsed == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return parsed.Host
|
||||
}
|
||||
|
||||
// SystemProxyEnv exposes the cached OS system proxy as env-style keys
|
||||
// (HTTPS_PROXY/HTTP_PROXY/NO_PROXY) for observability call sites that want to
|
||||
// log the effective proxy source. Returns nil when autodetect is disabled or no
|
||||
// system proxy is configured.
|
||||
func SystemProxyEnv() map[string]string {
|
||||
if proxyAutodetectDisabled() {
|
||||
return nil
|
||||
}
|
||||
return Resolver{}.systemProxyEnv()
|
||||
}
|
||||
|
||||
// mergeSystemProxy fills empty fields of an env-derived proxy config with the
|
||||
// system proxy values, preserving env-first precedence. When any proxy ends up
|
||||
// configured without a NO_PROXY, noProxyDefault applies so local services are
|
||||
// never routed through the proxy (httpproxy additionally hard-bypasses
|
||||
// loopback hosts).
|
||||
func mergeSystemProxy(cfg *httpproxy.Config, systemEnv map[string]string) {
|
||||
if cfg.HTTPSProxy == "" {
|
||||
cfg.HTTPSProxy = systemEnv["HTTPS_PROXY"]
|
||||
}
|
||||
if cfg.HTTPProxy == "" {
|
||||
cfg.HTTPProxy = systemEnv["HTTP_PROXY"]
|
||||
}
|
||||
if cfg.NoProxy == "" {
|
||||
cfg.NoProxy = systemEnv["NO_PROXY"]
|
||||
}
|
||||
if cfg.NoProxy == "" && (cfg.HTTPSProxy != "" || cfg.HTTPProxy != "") {
|
||||
cfg.NoProxy = noProxyDefault
|
||||
}
|
||||
}
|
||||
|
||||
// systemProxyCache memoizes platform proxy reads so hot paths (per-spawn env
|
||||
// injection, per-request proxy resolution) avoid native I/O each time, while
|
||||
// still noticing proxy-panel toggles within the TTL.
|
||||
var systemProxyCache = struct {
|
||||
sync.Mutex
|
||||
env map[string]string
|
||||
expiresAt time.Time
|
||||
}{}
|
||||
|
||||
const systemProxyCacheTTL = 60 * time.Second
|
||||
|
||||
func (r Resolver) systemProxyEnv() map[string]string {
|
||||
if r.ScutilProxy != nil {
|
||||
// Injected (test) source: bypass the cache so cases stay independent.
|
||||
output, ok := r.ScutilProxy()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return parseScutilProxy(output)
|
||||
}
|
||||
systemProxyCache.Lock()
|
||||
defer systemProxyCache.Unlock()
|
||||
if time.Now().Before(systemProxyCache.expiresAt) {
|
||||
return systemProxyCache.env
|
||||
}
|
||||
env := readSystemProxyEnv()
|
||||
systemProxyCache.env = env
|
||||
systemProxyCache.expiresAt = time.Now().Add(systemProxyCacheTTL)
|
||||
return env
|
||||
}
|
||||
|
||||
// parseScutilProxy turns `scutil --proxy` output into proxy env vars.
|
||||
//
|
||||
// Example input:
|
||||
//
|
||||
// <dictionary> {
|
||||
// HTTPSEnable : 1
|
||||
// HTTPSProxy : 127.0.0.1
|
||||
// HTTPSPort : 7890
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
// SOCKS entries are intentionally ignored, and PAC (ProxyAutoConfig) is not
|
||||
// resolved — scutil only exposes the PAC URL, not the per-URL result, so we
|
||||
// leave those to the user's explicit env. Returns nil when no usable proxy is
|
||||
// configured (direct connection).
|
||||
func parseScutilProxy(output string) map[string]string {
|
||||
fields := map[string]string{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
proxyURL := func(enableKey, hostKey, portKey string) string {
|
||||
if fields[enableKey] != "1" {
|
||||
return ""
|
||||
}
|
||||
host := fields[hostKey]
|
||||
port := fields[portKey]
|
||||
if host == "" || port == "" {
|
||||
return ""
|
||||
}
|
||||
return "http://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// Prefer the HTTPS entry (the upstream API is HTTPS); fall back to HTTP.
|
||||
// Both env vars point at the same proxy, mirroring the Claude app.
|
||||
url := proxyURL("HTTPSEnable", "HTTPSProxy", "HTTPSPort")
|
||||
if url == "" {
|
||||
url = proxyURL("HTTPEnable", "HTTPProxy", "HTTPPort")
|
||||
}
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"HTTPS_PROXY": url,
|
||||
"HTTP_PROXY": url,
|
||||
"NO_PROXY": noProxyDefault,
|
||||
}
|
||||
}
|
||||
|
||||
// parseWindowsProxyServer converts the WinINet ProxyServer registry value into
|
||||
// env-style HTTP proxy settings. Windows accepts either one proxy for all
|
||||
// protocols ("host:port") or a protocol map ("http=...;https=...;socks=...").
|
||||
// SOCKS entries are deliberately ignored because downstream HTTP agents do not
|
||||
// consistently support SOCKS proxy URLs.
|
||||
func parseWindowsProxyServer(proxyServer, proxyOverride string) map[string]string {
|
||||
proxyServer = strings.TrimSpace(proxyServer)
|
||||
if proxyServer == "" {
|
||||
return nil
|
||||
}
|
||||
values := map[string]string{}
|
||||
if strings.Contains(proxyServer, "=") {
|
||||
for _, part := range strings.Split(proxyServer, ";") {
|
||||
key, value, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = normalizeHTTPProxyURL(value)
|
||||
if value != "" && (key == "http" || key == "https") {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
} else if value := normalizeHTTPProxyURL(proxyServer); value != "" {
|
||||
values["http"] = value
|
||||
values["https"] = value
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
if values["https"] == "" {
|
||||
values["https"] = values["http"]
|
||||
}
|
||||
if values["http"] == "" {
|
||||
values["http"] = values["https"]
|
||||
}
|
||||
result := map[string]string{
|
||||
"HTTPS_PROXY": values["https"],
|
||||
"HTTP_PROXY": values["http"],
|
||||
"NO_PROXY": windowsNoProxy(proxyOverride),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeHTTPProxyURL(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "http://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func windowsNoProxy(proxyOverride string) string {
|
||||
items := strings.Split(noProxyDefault, ",")
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range items {
|
||||
seen[strings.ToLower(value)] = struct{}{}
|
||||
}
|
||||
for _, raw := range strings.Split(proxyOverride, ";") {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" || strings.EqualFold(value, "<local>") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(value, "*.") {
|
||||
value = value[1:]
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
items = append(items, value)
|
||||
}
|
||||
return strings.Join(items, ",")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build darwin
|
||||
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readSystemProxyEnv() map[string]string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "scutil", "--proxy").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseScutilProxy(string(out))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package runtimecmd
|
||||
|
||||
func readSystemProxyEnv() map[string]string { return nil }
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build windows
|
||||
|
||||
package runtimecmd
|
||||
|
||||
import "golang.org/x/sys/windows/registry"
|
||||
|
||||
const windowsInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
|
||||
|
||||
func readSystemProxyEnv() map[string]string {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, windowsInternetSettingsKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer key.Close()
|
||||
enabled, _, err := key.GetIntegerValue("ProxyEnable")
|
||||
if err != nil || enabled == 0 {
|
||||
return nil
|
||||
}
|
||||
server, _, err := key.GetStringValue("ProxyServer")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
override, _, _ := key.GetStringValue("ProxyOverride")
|
||||
return parseWindowsProxyServer(server, override)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
// Real-world `scutil --proxy` output with Clash Verge system proxy enabled
|
||||
// (HTTP/HTTPS/SOCKS all pointing at 127.0.0.1:7890).
|
||||
const scutilProxyEnabled = `<dictionary> {
|
||||
ExceptionsList : <array> {
|
||||
0 : 127.0.0.1
|
||||
1 : localhost
|
||||
2 : *.local
|
||||
}
|
||||
HTTPEnable : 1
|
||||
HTTPPort : 7890
|
||||
HTTPProxy : 127.0.0.1
|
||||
HTTPSEnable : 1
|
||||
HTTPSPort : 7890
|
||||
HTTPSProxy : 127.0.0.1
|
||||
ProxyAutoConfigEnable : 0
|
||||
SOCKSEnable : 1
|
||||
SOCKSPort : 7890
|
||||
SOCKSProxy : 127.0.0.1
|
||||
}`
|
||||
|
||||
const scutilProxyDisabled = `<dictionary> {
|
||||
ExceptionsList : <array> {
|
||||
0 : 127.0.0.1
|
||||
1 : localhost
|
||||
}
|
||||
HTTPEnable : 0
|
||||
HTTPSEnable : 0
|
||||
ProxyAutoConfigEnable : 0
|
||||
SOCKSEnable : 0
|
||||
}`
|
||||
|
||||
func TestParseScutilProxyEnabled(t *testing.T) {
|
||||
got := parseScutilProxy(scutilProxyEnabled)
|
||||
want := map[string]string{
|
||||
"HTTPS_PROXY": "http://127.0.0.1:7890",
|
||||
"HTTP_PROXY": "http://127.0.0.1:7890",
|
||||
"NO_PROXY": noProxyDefault,
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Fatalf("parseScutilProxy()[%q] = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScutilProxyDisabledReturnsNil(t *testing.T) {
|
||||
if got := parseScutilProxy(scutilProxyDisabled); got != nil {
|
||||
t.Fatalf("parseScutilProxy() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScutilProxyHTTPOnly(t *testing.T) {
|
||||
out := `<dictionary> {
|
||||
HTTPEnable : 1
|
||||
HTTPProxy : 10.0.0.2
|
||||
HTTPPort : 3128
|
||||
HTTPSEnable : 0
|
||||
SOCKSEnable : 0
|
||||
}`
|
||||
got := parseScutilProxy(out)
|
||||
if got["HTTP_PROXY"] != "http://10.0.0.2:3128" || got["HTTPS_PROXY"] != "http://10.0.0.2:3128" {
|
||||
t.Fatalf("parseScutilProxy() = %v, want HTTP(S)_PROXY=http://10.0.0.2:3128", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScutilProxySOCKSOnlyIgnored(t *testing.T) {
|
||||
out := `<dictionary> {
|
||||
HTTPEnable : 0
|
||||
HTTPSEnable : 0
|
||||
SOCKSEnable : 1
|
||||
SOCKSProxy : 127.0.0.1
|
||||
SOCKSPort : 7890
|
||||
}`
|
||||
if got := parseScutilProxy(out); got != nil {
|
||||
t.Fatalf("parseScutilProxy() with SOCKS only = %v, want nil (SOCKS skipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsProxyServerSingleProxy(t *testing.T) {
|
||||
got := parseWindowsProxyServer("127.0.0.1:7890", "<local>;*.corp.example;localhost")
|
||||
if got["HTTPS_PROXY"] != "http://127.0.0.1:7890" || got["HTTP_PROXY"] != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("parseWindowsProxyServer() proxy = %v", got)
|
||||
}
|
||||
if got["NO_PROXY"] != "localhost,127.0.0.1,::1,.local,.corp.example" {
|
||||
t.Fatalf("parseWindowsProxyServer() NO_PROXY = %q", got["NO_PROXY"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsProxyServerProtocolMap(t *testing.T) {
|
||||
got := parseWindowsProxyServer("http=proxy.internal:8080;https=secure.internal:8443;socks=127.0.0.1:1080", "")
|
||||
if got["HTTP_PROXY"] != "http://proxy.internal:8080" || got["HTTPS_PROXY"] != "http://secure.internal:8443" {
|
||||
t.Fatalf("parseWindowsProxyServer() = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsProxyServerIgnoresSocksOnly(t *testing.T) {
|
||||
if got := parseWindowsProxyServer("socks=127.0.0.1:1080", ""); got != nil {
|
||||
t.Fatalf("parseWindowsProxyServer() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvInjectsSystemProxy(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=/usr/bin:/bin"} },
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
ScutilProxy: func() (string, bool) { return scutilProxyEnabled, true },
|
||||
}
|
||||
env := resolver.Env(nil)
|
||||
if got := envValue(env, "HTTPS_PROXY"); got != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("HTTPS_PROXY = %q, want http://127.0.0.1:7890", got)
|
||||
}
|
||||
if got := envValue(env, "HTTP_PROXY"); got != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("HTTP_PROXY = %q, want http://127.0.0.1:7890", got)
|
||||
}
|
||||
if got := envValue(env, "NO_PROXY"); got != noProxyDefault {
|
||||
t.Fatalf("NO_PROXY = %q, want %q", got, noProxyDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvDoesNotOverrideExplicitProxy(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
// User already exported a (lowercase) proxy — must be preserved.
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/usr/bin:/bin", "https_proxy=http://user-set:1080"}
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
ScutilProxy: func() (string, bool) { return scutilProxyEnabled, true },
|
||||
}
|
||||
env := resolver.Env(nil)
|
||||
if got := envValue(env, "https_proxy"); got != "http://user-set:1080" {
|
||||
t.Fatalf("https_proxy = %q, want it preserved as http://user-set:1080", got)
|
||||
}
|
||||
// And we must not have appended a conflicting upper-case HTTPS_PROXY.
|
||||
count := 0
|
||||
for _, item := range env {
|
||||
if k, _, _ := strings.Cut(item, "="); strings.EqualFold(k, "HTTPS_PROXY") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("found %d HTTPS_PROXY entries, want exactly 1 (no override)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvNoProxyWhenScutilUnavailable(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=/usr/bin:/bin"} },
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
ScutilProxy: func() (string, bool) { return "", false },
|
||||
}
|
||||
env := resolver.Env(nil)
|
||||
if got := envValue(env, "HTTPS_PROXY"); got != "" {
|
||||
t.Fatalf("HTTPS_PROXY = %q, want empty when scutil unavailable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyFor(t *testing.T, cfg *httpproxy.Config, rawURL string) *url.URL {
|
||||
t.Helper()
|
||||
target, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", rawURL, err)
|
||||
}
|
||||
proxy, err := cfg.ProxyFunc()(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ProxyFunc(%q): %v", rawURL, err)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func TestMergeSystemProxyEnvWins(t *testing.T) {
|
||||
cfg := &httpproxy.Config{HTTPSProxy: "http://env-proxy:8080"}
|
||||
mergeSystemProxy(cfg, map[string]string{
|
||||
"HTTPS_PROXY": "http://system-proxy:7890",
|
||||
"HTTP_PROXY": "http://system-proxy:7890",
|
||||
"NO_PROXY": noProxyDefault,
|
||||
})
|
||||
if cfg.HTTPSProxy != "http://env-proxy:8080" {
|
||||
t.Fatalf("HTTPSProxy = %q, want env value preserved", cfg.HTTPSProxy)
|
||||
}
|
||||
if cfg.HTTPProxy != "http://system-proxy:7890" {
|
||||
t.Fatalf("HTTPProxy = %q, want system value filling the blank", cfg.HTTPProxy)
|
||||
}
|
||||
if got := proxyFor(t, cfg, "https://api.anthropic.com/v1"); got == nil || got.Host != "env-proxy:8080" {
|
||||
t.Fatalf("proxy = %v, want env proxy env-proxy:8080", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSystemProxyFallsBackToSystem(t *testing.T) {
|
||||
cfg := &httpproxy.Config{}
|
||||
mergeSystemProxy(cfg, map[string]string{
|
||||
"HTTPS_PROXY": "http://system-proxy:7890",
|
||||
"HTTP_PROXY": "http://system-proxy:7890",
|
||||
"NO_PROXY": noProxyDefault,
|
||||
})
|
||||
if got := proxyFor(t, cfg, "https://api.anthropic.com/v1"); got == nil || got.Host != "system-proxy:7890" {
|
||||
t.Fatalf("proxy = %v, want system proxy when env has none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSystemProxyDirectWhenNoneConfigured(t *testing.T) {
|
||||
cfg := &httpproxy.Config{}
|
||||
mergeSystemProxy(cfg, nil)
|
||||
if got := proxyFor(t, cfg, "https://api.anthropic.com/v1"); got != nil {
|
||||
t.Fatalf("proxy = %v, want nil (direct) when nothing configured", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSystemProxyBypassesLoopbackAndNoProxy(t *testing.T) {
|
||||
cfg := &httpproxy.Config{}
|
||||
mergeSystemProxy(cfg, map[string]string{
|
||||
"HTTPS_PROXY": "http://system-proxy:7890",
|
||||
"HTTP_PROXY": "http://system-proxy:7890",
|
||||
"NO_PROXY": noProxyDefault,
|
||||
})
|
||||
for _, target := range []string{
|
||||
"http://127.0.0.1:4545/v1/health",
|
||||
"http://localhost:4545/v1/health",
|
||||
"https://printer.local/status",
|
||||
} {
|
||||
if got := proxyFor(t, cfg, target); got != nil {
|
||||
t.Fatalf("proxy for %q = %v, want nil (local bypass)", target, got)
|
||||
}
|
||||
}
|
||||
if got := proxyFor(t, cfg, "https://api.anthropic.com/v1"); got == nil {
|
||||
t.Fatalf("proxy for external target = nil, want system proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSystemProxyDefaultsNoProxyForEnvOnlyProxy(t *testing.T) {
|
||||
// Env sets a proxy but no NO_PROXY, and no system proxy exists: the
|
||||
// default exclusions still apply so .local names never hit the proxy.
|
||||
cfg := &httpproxy.Config{HTTPSProxy: "http://env-proxy:8080"}
|
||||
mergeSystemProxy(cfg, nil)
|
||||
if cfg.NoProxy != noProxyDefault {
|
||||
t.Fatalf("NoProxy = %q, want default %q", cfg.NoProxy, noProxyDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyAutodetectDisabledSkipsInjection(t *testing.T) {
|
||||
t.Setenv(disableProxyAutodetectEnvKey, "1")
|
||||
resolver := Resolver{
|
||||
Environ: func() []string { return []string{"PATH=/usr/bin:/bin"} },
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
ScutilProxy: func() (string, bool) { return scutilProxyEnabled, true },
|
||||
}
|
||||
env := resolver.Env(nil)
|
||||
if got := envValue(env, "HTTPS_PROXY"); got != "" {
|
||||
t.Fatalf("HTTPS_PROXY = %q, want empty when autodetect disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyAutodetectDisabledKeepsEnvProxy(t *testing.T) {
|
||||
t.Setenv(disableProxyAutodetectEnvKey, "true")
|
||||
t.Setenv("HTTPS_PROXY", "http://env-proxy:8080")
|
||||
fn := DynamicProxyFunc()
|
||||
request, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
got, err := fn(request)
|
||||
if err != nil {
|
||||
t.Fatalf("proxy func err = %v", err)
|
||||
}
|
||||
if got == nil || got.Host != "env-proxy:8080" {
|
||||
t.Fatalf("proxy = %v, want explicit env proxy to keep working", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemProxyEnvCachesScutil(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("scutil cache path is darwin-only")
|
||||
}
|
||||
systemProxyCache.Lock()
|
||||
systemProxyCache.env = map[string]string{"HTTPS_PROXY": "http://cached-proxy:7890"}
|
||||
systemProxyCache.expiresAt = time.Now().Add(time.Minute)
|
||||
systemProxyCache.Unlock()
|
||||
t.Cleanup(func() {
|
||||
systemProxyCache.Lock()
|
||||
systemProxyCache.env = nil
|
||||
systemProxyCache.expiresAt = time.Time{}
|
||||
systemProxyCache.Unlock()
|
||||
})
|
||||
got := Resolver{}.systemProxyEnv()
|
||||
if got["HTTPS_PROXY"] != "http://cached-proxy:7890" {
|
||||
t.Fatalf("systemProxyEnv() = %v, want cached value within TTL", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
Environ func() []string
|
||||
HomeDir func() (string, error)
|
||||
IsExecutableFile func(string) bool
|
||||
LookPath func(string) (string, error)
|
||||
// ScutilProxy returns the raw output of `scutil --proxy` (and whether it is
|
||||
// available). It is injectable for legacy macOS parser tests; production
|
||||
// reads the platform system proxy through the build-tagged adapter.
|
||||
ScutilProxy func() (string, bool)
|
||||
}
|
||||
|
||||
// nestingGuardEnvKeys are the environment variables a parent Claude Code
|
||||
// session exports to detect (and refuse) nested launches. When tuttid itself
|
||||
// runs inside a Claude Code session these leak into spawned agent subprocesses, causing
|
||||
// a child `claude` to abort with "cannot be launched inside another Claude Code
|
||||
// session". They are stripped from the base environment so each spawned agent
|
||||
// starts as a fresh session; they are CLAUDE-specific and harmless to other runtimes.
|
||||
var nestingGuardEnvKeys = []string{
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION_ID",
|
||||
"CLAUDE_CODE_CHILD_SESSION",
|
||||
}
|
||||
|
||||
func (r Resolver) Env(overrides []string) []string {
|
||||
baseEnv := stripEnvKeys(r.environ(), nestingGuardEnvKeys)
|
||||
env := append(baseEnv, overrides...)
|
||||
pathKey := pathEnvKey(env)
|
||||
pathGroups := [][]string{}
|
||||
if overridePathGroups := pathGroupsFromEnv(overrides, pathKey, envValue(baseEnv, pathKey)); len(overridePathGroups) > 0 {
|
||||
pathGroups = append(pathGroups, overridePathGroups...)
|
||||
pathGroups = append(pathGroups, preferredExecutableDirs(env))
|
||||
pathGroups = append(pathGroups, r.fallbackExecutableDirs())
|
||||
} else {
|
||||
pathGroups = append(pathGroups, preferredExecutableDirs(env))
|
||||
pathGroups = append(pathGroups, splitPathList(envValue(baseEnv, pathKey)))
|
||||
pathGroups = append(pathGroups, r.fallbackExecutableDirs())
|
||||
}
|
||||
pathDirs := mergePathDirs(pathGroups...)
|
||||
if len(pathDirs) > 0 {
|
||||
env = setEnvValue(env, pathKey, strings.Join(pathDirs, string(os.PathListSeparator)))
|
||||
}
|
||||
return r.injectSystemProxyEnv(env)
|
||||
}
|
||||
|
||||
func (r Resolver) Resolve(command string, env []string) string {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" || strings.ContainsAny(command, `/\`) {
|
||||
return command
|
||||
}
|
||||
if candidates := r.ResolveAll(command, env); len(candidates) > 0 {
|
||||
return candidates[0]
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
// ResolveAll returns every executable match for command in the effective PATH,
|
||||
// preserving directory order and removing duplicate paths. It deliberately
|
||||
// does not call LookPath: callers that need an OS-specific fallback can append
|
||||
// that result explicitly, while discovery callers get a complete, stable view
|
||||
// of the resolver's own search plan.
|
||||
func (r Resolver) ResolveAll(command string, env []string) []string {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" || strings.ContainsAny(command, "/\\") {
|
||||
return nil
|
||||
}
|
||||
return r.ResolveAllNames([]string{command}, env)
|
||||
}
|
||||
|
||||
// ResolveAllNames returns every executable match for a set of command names.
|
||||
// Directory order is primary and name order is secondary, which lets callers
|
||||
// enumerate platform launcher variants without changing PATH precedence.
|
||||
func (r Resolver) ResolveAllNames(commandNames []string, env []string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
for _, dir := range splitPathList(envValue(env, pathEnvKey(env))) {
|
||||
for _, command := range commandNames {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" || strings.ContainsAny(command, `/\\`) {
|
||||
continue
|
||||
}
|
||||
for _, executableName := range executableNameCandidates(command, env) {
|
||||
candidate := filepath.Join(dir, executableName)
|
||||
if !r.isExecutableFile(candidate) {
|
||||
continue
|
||||
}
|
||||
key := executablePathKey(candidate)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r Resolver) ResolveBinary(binaryNames []string, overrides []string) string {
|
||||
env := r.Env(overrides)
|
||||
for _, binaryName := range binaryNames {
|
||||
binaryName = strings.TrimSpace(binaryName)
|
||||
if binaryName == "" {
|
||||
continue
|
||||
}
|
||||
path := r.Resolve(binaryName, env)
|
||||
if path != binaryName {
|
||||
return path
|
||||
}
|
||||
if path := r.lookPath(binaryName); path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r Resolver) UserBinInstallDirs(overrides []string) []string {
|
||||
baseEnv := r.environ()
|
||||
env := append([]string{}, baseEnv...)
|
||||
env = append(env, overrides...)
|
||||
pathValue := envValue(baseEnv, pathEnvKey(baseEnv))
|
||||
if overridePath, ok := envValueFrom(overrides, pathEnvKey(env)); ok {
|
||||
pathValue = overridePath
|
||||
}
|
||||
candidates := [][]string{
|
||||
splitPathList(pathValue),
|
||||
}
|
||||
home, err := r.homeDir()
|
||||
if err == nil && strings.TrimSpace(home) != "" {
|
||||
managedNPMDirs := UserManagedNPMExecutableDirs(home)
|
||||
candidates = append(candidates, managedNPMDirs, []string{filepath.Join(home, "bin")})
|
||||
}
|
||||
return mergePathDirs(candidates...)
|
||||
}
|
||||
|
||||
func preferredExecutableDirs(env []string) []string {
|
||||
dirs := []string{}
|
||||
if nPrefix := strings.TrimSpace(envValue(env, "N_PREFIX")); nPrefix != "" {
|
||||
dirs = append([]string{filepath.Join(nPrefix, "bin")}, dirs...)
|
||||
}
|
||||
if pnpmHome := strings.TrimSpace(envValue(env, "PNPM_HOME")); pnpmHome != "" {
|
||||
dirs = append([]string{pnpmHome}, dirs...)
|
||||
}
|
||||
if voltaHome := strings.TrimSpace(envValue(env, "VOLTA_HOME")); voltaHome != "" {
|
||||
dirs = append([]string{filepath.Join(voltaHome, "bin")}, dirs...)
|
||||
}
|
||||
if asdfDataDir := strings.TrimSpace(envValue(env, "ASDF_DATA_DIR")); asdfDataDir != "" {
|
||||
dirs = append([]string{filepath.Join(asdfDataDir, "shims")}, dirs...)
|
||||
}
|
||||
if miseDataDir := strings.TrimSpace(envValue(env, "MISE_DATA_DIR")); miseDataDir != "" {
|
||||
dirs = append([]string{filepath.Join(miseDataDir, "shims")}, dirs...)
|
||||
}
|
||||
if fnmDir := strings.TrimSpace(envValue(env, "FNM_DIR")); fnmDir != "" {
|
||||
dirs = append(fnmNodeBinDirs(fnmDir), dirs...)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (r Resolver) fallbackExecutableDirs() []string {
|
||||
dirs := []string{
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
}
|
||||
homeDirs := []string{}
|
||||
home, err := r.homeDir()
|
||||
if err == nil && strings.TrimSpace(home) != "" {
|
||||
homeDirs = []string{
|
||||
filepath.Join(home, ".tutti", "bin"),
|
||||
filepath.Join(home, ".opencode", "bin"),
|
||||
}
|
||||
homeDirs = append(homeDirs, UserManagedNPMExecutableDirs(home)...)
|
||||
homeDirs = append(homeDirs,
|
||||
filepath.Join(home, "bin"),
|
||||
filepath.Join(home, ".npm-global", "bin"),
|
||||
filepath.Join(home, ".n", "bin"),
|
||||
filepath.Join(home, "n", "bin"),
|
||||
filepath.Join(home, ".volta", "bin"),
|
||||
filepath.Join(home, ".asdf", "shims"),
|
||||
filepath.Join(home, ".mise", "shims"),
|
||||
filepath.Join(home, ".bun", "bin"),
|
||||
filepath.Join(home, "Library", "pnpm"),
|
||||
)
|
||||
homeDirs = append(homeDirs, nvmNodeBinDirs(home)...)
|
||||
homeDirs = append(homeDirs, fnmNodeBinDirs(filepath.Join(home, ".fnm"))...)
|
||||
// Cursor's native Windows installer places agent.ps1 and its versioned
|
||||
// payload under %LOCALAPPDATA%\\cursor-agent rather than on PATH.
|
||||
homeDirs = append(homeDirs, filepath.Join(home, "AppData", "Local", "cursor-agent"))
|
||||
}
|
||||
return append(homeDirs, dirs...)
|
||||
}
|
||||
|
||||
func (r Resolver) environ() []string {
|
||||
if r.Environ != nil {
|
||||
return r.Environ()
|
||||
}
|
||||
return os.Environ()
|
||||
}
|
||||
|
||||
func (r Resolver) homeDir() (string, error) {
|
||||
if r.HomeDir != nil {
|
||||
return r.HomeDir()
|
||||
}
|
||||
return os.UserHomeDir()
|
||||
}
|
||||
|
||||
func (r Resolver) isExecutableFile(path string) bool {
|
||||
if r.IsExecutableFile != nil {
|
||||
return r.IsExecutableFile(path)
|
||||
}
|
||||
return isExecutableFile(path)
|
||||
}
|
||||
|
||||
func (r Resolver) lookPath(binaryName string) string {
|
||||
lookPath := r.LookPath
|
||||
if lookPath == nil {
|
||||
lookPath = exec.LookPath
|
||||
}
|
||||
path, err := lookPath(binaryName)
|
||||
if err == nil && strings.TrimSpace(path) != "" {
|
||||
return strings.TrimSpace(path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nvmNodeBinDirs(home string) []string {
|
||||
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
return compareNVMNodeBinDirs(matches[i], matches[j]) > 0
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func compareNVMNodeBinDirs(left string, right string) int {
|
||||
leftVersion := nvmNodeVersion(left)
|
||||
rightVersion := nvmNodeVersion(right)
|
||||
for i := 0; i < max(len(leftVersion), len(rightVersion)); i++ {
|
||||
leftPart := versionPart(leftVersion, i)
|
||||
rightPart := versionPart(rightVersion, i)
|
||||
if leftPart > rightPart {
|
||||
return 1
|
||||
}
|
||||
if leftPart < rightPart {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return strings.Compare(left, right)
|
||||
}
|
||||
|
||||
func nvmNodeVersion(binDir string) []int {
|
||||
versionDir := filepath.Base(filepath.Dir(binDir))
|
||||
version := strings.TrimPrefix(versionDir, "v")
|
||||
parts := strings.Split(version, ".")
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func versionPart(version []int, index int) int {
|
||||
if index < 0 || index >= len(version) {
|
||||
return 0
|
||||
}
|
||||
return version[index]
|
||||
}
|
||||
|
||||
func fnmNodeBinDirs(fnmDir string) []string {
|
||||
matches, err := filepath.Glob(filepath.Join(fnmDir, "node-versions", "*", "installation", "bin"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func mergePathDirs(groups ...[]string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, group := range groups {
|
||||
for _, dir := range group {
|
||||
normalized := strings.TrimSpace(dir)
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
key := pathDirKey(normalized)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, normalized)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pathDirKey(dir string) string {
|
||||
key := filepath.Clean(strings.TrimSpace(dir))
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func executablePathKey(path string) string {
|
||||
return pathDirKey(path)
|
||||
}
|
||||
|
||||
func pathEnvKey(env []string) string {
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
key, _, ok := strings.Cut(env[i], "=")
|
||||
if ok && strings.EqualFold(key, "PATH") {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
value, _ := envValueFrom(env, key)
|
||||
return value
|
||||
}
|
||||
|
||||
func envValueFrom(env []string, key string) (string, bool) {
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
candidateKey, value, ok := strings.Cut(env[i], "=")
|
||||
if ok && strings.EqualFold(candidateKey, key) {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func pathGroupsFromEnv(env []string, key string, basePath string) [][]string {
|
||||
groups := [][]string{}
|
||||
baseDirs := splitPathList(basePath)
|
||||
inheritedBaseDirs := []string{}
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
candidateKey, value, ok := strings.Cut(env[i], "=")
|
||||
if ok && strings.EqualFold(candidateKey, key) {
|
||||
dirs := splitPathList(value)
|
||||
if prefix, inherited, ok := splitInheritedPath(dirs, baseDirs); ok {
|
||||
groups = append(groups, prefix)
|
||||
if len(inheritedBaseDirs) == 0 {
|
||||
inheritedBaseDirs = inherited
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups = append(groups, dirs)
|
||||
}
|
||||
}
|
||||
if len(inheritedBaseDirs) > 0 {
|
||||
groups = append(groups, inheritedBaseDirs)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// splitPathList follows the host separator but also accepts the colon form
|
||||
// emitted by MSYS/Unix-like test and launcher environments on Windows. A
|
||||
// native Windows path containing a drive letter or backslashes remains on the
|
||||
// normal semicolon path-list path.
|
||||
func splitPathList(value string) []string {
|
||||
if runtime.GOOS == "windows" && !strings.Contains(value, ";") &&
|
||||
strings.Contains(value, ":") && !strings.Contains(value, `\`) {
|
||||
return strings.Split(value, ":")
|
||||
}
|
||||
return filepath.SplitList(value)
|
||||
}
|
||||
|
||||
func splitInheritedPath(dirs []string, baseDirs []string) ([]string, []string, bool) {
|
||||
if len(baseDirs) == 0 || len(dirs) < len(baseDirs) {
|
||||
return nil, nil, false
|
||||
}
|
||||
for i := 0; i+len(baseDirs) <= len(dirs); i++ {
|
||||
if pathDirSlicesEqual(dirs[i:i+len(baseDirs)], baseDirs) {
|
||||
return dirs[:i], dirs[i:], true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func pathDirSlicesEqual(a []string, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if pathDirKey(a[i]) != pathDirKey(b[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripEnvKeys(env []string, keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return env
|
||||
}
|
||||
next := make([]string, 0, len(env))
|
||||
for _, item := range env {
|
||||
candidateKey, _, ok := strings.Cut(item, "=")
|
||||
if ok {
|
||||
drop := false
|
||||
for _, key := range keys {
|
||||
if strings.EqualFold(candidateKey, key) {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if drop {
|
||||
continue
|
||||
}
|
||||
}
|
||||
next = append(next, item)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func setEnvValue(env []string, key string, value string) []string {
|
||||
next := make([]string, 0, len(env)+1)
|
||||
for _, item := range env {
|
||||
candidateKey, _, ok := strings.Cut(item, "=")
|
||||
if ok && strings.EqualFold(candidateKey, key) {
|
||||
continue
|
||||
}
|
||||
next = append(next, item)
|
||||
}
|
||||
return append(next, key+"="+value)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package runtimecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolverFindsKnownNodeGlobalBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
binDir := filepath.Join(home, ".nvm", "versions", "node", "v24.12.0", "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
binaryPath := filepath.Join(binDir, "codex-acp")
|
||||
writeExecutable(t, binaryPath)
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=" + strings.Join([]string{filepath.FromSlash("/usr/bin"), filepath.FromSlash("/bin")}, string(os.PathListSeparator))}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("codex-acp", env); got != binaryPath {
|
||||
t.Fatalf("Resolve() = %q, want %q", got, binaryPath)
|
||||
}
|
||||
if got := resolver.ResolveBinary([]string{"codex-acp"}, nil); got != binaryPath {
|
||||
t.Fatalf("ResolveBinary() = %q, want %q", got, binaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverPrefersNewestNVMNodeBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
oldBinDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin")
|
||||
newBinDir := filepath.Join(home, ".nvm", "versions", "node", "v24.16.0", "bin")
|
||||
for _, dir := range []string{oldBinDir, newBinDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
writeExecutable(t, filepath.Join(dir, "codex"))
|
||||
}
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/usr/bin:/bin"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
want := filepath.Join(newBinDir, "codex")
|
||||
if got := resolver.Resolve("codex", env); got != want {
|
||||
t.Fatalf("Resolve() = %q, want newest NVM codex %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverPrefersExistingPathOverScannedNVMFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
activeBinDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin")
|
||||
newerBinDir := filepath.Join(home, ".nvm", "versions", "node", "v24.16.0", "bin")
|
||||
for _, dir := range []string{activeBinDir, newerBinDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
writeExecutable(t, filepath.Join(dir, "codex"))
|
||||
}
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{
|
||||
"PATH=" + activeBinDir + string(os.PathListSeparator) + "/usr/bin:/bin",
|
||||
}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
want := filepath.Join(activeBinDir, "codex")
|
||||
if got := resolver.Resolve("codex", env); got != want {
|
||||
t.Fatalf("Resolve() = %q, want active PATH codex %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverResolveAllReturnsEveryExecutableMatchInPathOrder(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
firstDir := filepath.Join(home, "first")
|
||||
secondDir := filepath.Join(home, "second")
|
||||
for _, dir := range []string{firstDir, secondDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
writeExecutable(t, filepath.Join(dir, "codex"))
|
||||
}
|
||||
|
||||
resolver := Resolver{IsExecutableFile: func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}}
|
||||
env := []string{"PATH=" + strings.Join([]string{firstDir, secondDir, firstDir}, string(os.PathListSeparator))}
|
||||
|
||||
got := resolver.ResolveAll("codex", env)
|
||||
want := []string{filepath.Join(firstDir, "codex"), filepath.Join(secondDir, "codex")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveAll() = %#v, want %#v", got, want)
|
||||
}
|
||||
if selected := resolver.Resolve("codex", env); selected != want[0] {
|
||||
t.Fatalf("Resolve() = %q, want first candidate %q", selected, want[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverResolveAllNamesKeepsDirectoryOrderBeforeLauncherVariant(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
firstDir := filepath.Join(home, "first")
|
||||
secondDir := filepath.Join(home, "second")
|
||||
for _, path := range []string{
|
||||
filepath.Join(firstDir, "codex.cmd"),
|
||||
filepath.Join(secondDir, "codex.exe"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
writeExecutable(t, path)
|
||||
}
|
||||
resolver := Resolver{IsExecutableFile: func(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}}
|
||||
env := []string{"PATH=" + strings.Join([]string{firstDir, secondDir}, string(os.PathListSeparator))}
|
||||
|
||||
got := resolver.ResolveAllNames([]string{"codex.exe", "codex.cmd"}, env)
|
||||
want := []string{filepath.Join(firstDir, "codex.cmd"), filepath.Join(secondDir, "codex.exe")}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ResolveAllNames() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFindsTuttiBinFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
tuttiBinDir := filepath.Join(home, ".tutti", "bin")
|
||||
if err := os.MkdirAll(tuttiBinDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir tutti bin dir: %v", err)
|
||||
}
|
||||
tuttiPath := filepath.Join(tuttiBinDir, "tutti")
|
||||
writeExecutable(t, tuttiPath)
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/usr/bin:/bin"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("tutti", env); got != tuttiPath {
|
||||
t.Fatalf("Resolve() = %q, want %q", got, tuttiPath)
|
||||
}
|
||||
if got := resolver.ResolveBinary([]string{"tutti"}, nil); got != tuttiPath {
|
||||
t.Fatalf("ResolveBinary() = %q, want %q", got, tuttiPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFindsOpenCodeBinFallback(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
opencodeBinDir := filepath.Join(home, ".opencode", "bin")
|
||||
if err := os.MkdirAll(opencodeBinDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir opencode bin dir: %v", err)
|
||||
}
|
||||
opencodePath := filepath.Join(opencodeBinDir, "opencode")
|
||||
writeExecutable(t, opencodePath)
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/usr/bin:/bin"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("opencode", env); got != opencodePath {
|
||||
t.Fatalf("Resolve() = %q, want %q", got, opencodePath)
|
||||
}
|
||||
if got := resolver.ResolveBinary([]string{"opencode"}, nil); got != opencodePath {
|
||||
t.Fatalf("ResolveBinary() = %q, want %q", got, opencodePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverFindsFnmNodeBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
fnmDir := filepath.Join(home, "custom-fnm")
|
||||
binDir := filepath.Join(fnmDir, "node-versions", "v24.12.0", "installation", "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
nodePath := filepath.Join(binDir, "node")
|
||||
writeExecutable(t, nodePath)
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{
|
||||
"PATH=/usr/bin:/bin",
|
||||
"FNM_DIR=" + fnmDir,
|
||||
}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("node", env); got != nodePath {
|
||||
t.Fatalf("Resolve() = %q, want %q", got, nodePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverPrefersFnmNodeBinOverExistingPathNode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
fnmDir := filepath.Join(home, "custom-fnm")
|
||||
fnmBinDir := filepath.Join(fnmDir, "node-versions", "v24.12.0", "installation", "bin")
|
||||
existingBinDir := filepath.Join(home, "existing", "bin")
|
||||
for _, dir := range []string{fnmBinDir, existingBinDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
}
|
||||
fnmNodePath := filepath.Join(fnmBinDir, "node")
|
||||
writeExecutable(t, fnmNodePath)
|
||||
writeExecutable(t, filepath.Join(existingBinDir, "node"))
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{
|
||||
"PATH=" + existingBinDir + string(os.PathListSeparator) + "/usr/bin:/bin",
|
||||
"FNM_DIR=" + fnmDir,
|
||||
}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("node", env); got != fnmNodePath {
|
||||
t.Fatalf("Resolve() = %q, want %q", got, fnmNodePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverPrefersInheritedPathOverBunInstallBin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
bunInstall := filepath.Join(home, "custom-bun")
|
||||
bunBinDir := filepath.Join(bunInstall, "bin")
|
||||
pathBinDir := filepath.Join(home, "path-bin")
|
||||
for _, dir := range []string{bunBinDir, pathBinDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin dir: %v", err)
|
||||
}
|
||||
}
|
||||
writeExecutable(t, filepath.Join(bunBinDir, "claude"))
|
||||
pathClaude := filepath.Join(pathBinDir, "claude")
|
||||
writeExecutable(t, pathClaude)
|
||||
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{
|
||||
"PATH=" + pathBinDir + string(os.PathListSeparator) + "/usr/bin:/bin",
|
||||
"BUN_INSTALL=" + bunInstall,
|
||||
}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
LookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
if got := resolver.Resolve("claude", env); got != pathClaude {
|
||||
t.Fatalf("Resolve() = %q, want inherited PATH claude %q", got, pathClaude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverReplacesPathEnv(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/first", "OTHER=value"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env([]string{"PATH=/override:/first"})
|
||||
pathCount := 0
|
||||
pathValue := ""
|
||||
for _, item := range env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if ok && key == "PATH" {
|
||||
pathCount++
|
||||
pathValue = value
|
||||
}
|
||||
}
|
||||
if pathCount != 1 {
|
||||
t.Fatalf("PATH entry count = %d, want 1 in %#v", pathCount, env)
|
||||
}
|
||||
if !strings.HasPrefix(pathValue, "/override") {
|
||||
t.Fatalf("PATH = %q, want override prefix", pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverMergesMultiplePathOverrides(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=/usr/bin:/bin"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
pathValueForTest := func(paths ...string) string {
|
||||
converted := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
converted = append(converted, filepath.FromSlash(path))
|
||||
}
|
||||
return strings.Join(converted, string(os.PathListSeparator))
|
||||
}
|
||||
env := resolver.Env([]string{
|
||||
"PATH=" + pathValueForTest("/state/bin", "/usr/bin", "/bin"),
|
||||
"PATH=" + pathValueForTest("/managed/node/bin", "/usr/bin", "/bin"),
|
||||
})
|
||||
pathCount := 0
|
||||
pathValue := ""
|
||||
for _, item := range env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if ok && key == "PATH" {
|
||||
pathCount++
|
||||
pathValue = value
|
||||
}
|
||||
}
|
||||
if pathCount != 1 {
|
||||
t.Fatalf("PATH entry count = %d, want 1 in %#v", pathCount, env)
|
||||
}
|
||||
pathDirs := filepath.SplitList(pathValue)
|
||||
if len(pathDirs) < 4 ||
|
||||
pathDirs[0] != filepath.FromSlash("/managed/node/bin") ||
|
||||
pathDirs[1] != filepath.FromSlash("/state/bin") ||
|
||||
pathDirs[2] != filepath.FromSlash("/usr/bin") ||
|
||||
pathDirs[3] != filepath.FromSlash("/bin") {
|
||||
t.Fatalf("PATH = %q, want override prefixes before inherited base path", pathValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverEnvStripsClaudeCodeNestingGuards(t *testing.T) {
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{
|
||||
"PATH=/usr/bin",
|
||||
"CLAUDECODE=1",
|
||||
"CLAUDE_CODE_ENTRYPOINT=claude-desktop",
|
||||
"CLAUDE_CODE_SESSION_ID=abc",
|
||||
"CLAUDE_CODE_CHILD_SESSION=1",
|
||||
"CLAUDE_CODE_OAUTH_SCOPES=keep-me",
|
||||
"OTHER=value",
|
||||
}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
env := resolver.Env(nil)
|
||||
for _, item := range env {
|
||||
key, _, _ := strings.Cut(item, "=")
|
||||
switch key {
|
||||
case "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION":
|
||||
t.Fatalf("nesting guard %q should be stripped, got %#v", key, env)
|
||||
}
|
||||
}
|
||||
if value, ok := envValueFrom(env, "CLAUDE_CODE_OAUTH_SCOPES"); !ok || value != "keep-me" {
|
||||
t.Fatalf("unrelated CLAUDE_CODE_* var was dropped: %#v", env)
|
||||
}
|
||||
if value, ok := envValueFrom(env, "OTHER"); !ok || value != "value" {
|
||||
t.Fatalf("unrelated var was dropped: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverUserBinInstallDirsPrefersPathEntriesThenFallbacks(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
pathDir := filepath.Join(home, "custom-bin")
|
||||
resolver := Resolver{
|
||||
Environ: func() []string {
|
||||
return []string{"PATH=" + pathDir + string(os.PathListSeparator) + "/usr/bin"}
|
||||
},
|
||||
HomeDir: func() (string, error) {
|
||||
return home, nil
|
||||
},
|
||||
}
|
||||
|
||||
dirs := resolver.UserBinInstallDirs(nil)
|
||||
if len(dirs) < 4 {
|
||||
t.Fatalf("len(dirs) = %d, want at least 4; dirs=%#v", len(dirs), dirs)
|
||||
}
|
||||
if dirs[0] != pathDir {
|
||||
t.Fatalf("dirs[0] = %q, want %q", dirs[0], pathDir)
|
||||
}
|
||||
if dirs[1] != "/usr/bin" {
|
||||
t.Fatalf("dirs[1] = %q, want /usr/bin", dirs[1])
|
||||
}
|
||||
if dirs[2] != filepath.Join(home, ".local", "bin") {
|
||||
t.Fatalf("dirs[2] = %q, want fallback ~/.local/bin", dirs[2])
|
||||
}
|
||||
if dirs[3] != filepath.Join(home, "bin") {
|
||||
t.Fatalf("dirs[3] = %q, want fallback ~/bin", dirs[3])
|
||||
}
|
||||
}
|
||||
|
||||
func writeExecutable(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("write executable %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user