-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexec_path.go
More file actions
345 lines (314 loc) · 11.5 KB
/
Copy pathexec_path.go
File metadata and controls
345 lines (314 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
package acpruntime
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// extraCandidateDirs is overridden in tests to inject deterministic lookup
// locations. In production it stays empty and resolveAgentCommand relies on
// nodeCandidateDirs() instead, which enumerates the well-known node/npm install
// directories on the host.
var extraCandidateDirs []string
// resolveAgentCommand makes sure input.Agent.Command can be found on PATH
// before the stdio factory tries to exec it.
//
// Background: GUI apps launched from Finder/Dock/Spotlight on macOS, the Start
// Menu on Windows, or a .desktop file on Linux inherit a minimal PATH that does
// not include the directory where node-based CLIs (npm, npx, the ACP agent
// shims) are installed (Homebrew, nvm, volta, fnm, nvm-windows, scoop, ...).
// The stdio factory passes os.Environ() through unchanged, so exec.LookPath
// fails and the spawn aborts with a confusing "executable file not found in
// $PATH" error even though the binary exists on the machine.
//
// If the command is already resolvable on the current PATH this is a no-op.
// Otherwise we scan a small set of well-known node install locations; when the
// binary is found there we rewrite Agent.Command to the absolute path (so
// exec.Command skips LookPath, which would consult the parent PATH again) and
// prepend the discovered directory to Agent.Env["PATH"] so child processes
// (npm spawns node, which shells out to git, etc.) can still find their
// dependencies. We never mutate the global process environment.
func resolveAgentCommand(agent *Agent) error {
if agent == nil || agent.Command == "" {
return nil
}
// Fast path: the command resolves against the parent PATH already. Nothing
// to do; leave Env untouched so callers that intentionally pass a minimal
// Env are not surprised.
if _, err := exec.LookPath(agent.Command); err == nil {
return nil
}
// If the command is an absolute or relative path, exec.LookPath already
// gave the definitive answer; do not second-guess it.
if isExplicitPath(agent.Command) {
return fmt.Errorf("agent command %q not found: %w", agent.Command, exec.ErrNotFound)
}
// Test-injected candidates take precedence so unit tests stay deterministic
// even on hosts that have a real node install under Homebrew/nvm.
candidates := append([]string(nil), extraCandidateDirs...)
candidates = append(candidates, nodeCandidateDirs()...)
foundPath := ""
for _, dir := range candidates {
if dir == "" {
continue
}
if candidate := resolveInDir(dir, agent.Command); candidate != "" {
foundPath = candidate
break
}
}
if foundPath == "" {
return fmt.Errorf("agent command %q not found on PATH or in known node install directories (%s): %w",
agent.Command, strings.Join(candidates, string(os.PathListSeparator)), exec.ErrNotFound)
}
// Rewrite Command to the absolute path so exec.Command skips LookPath (it
// would otherwise consult the parent PATH again and fail). Inject the
// directory at the front of Env PATH so child processes still resolve their
// own dependencies (npm -> node -> git, etc.).
parentPath := os.Getenv("PATH")
newPath := filepath.Dir(foundPath) + string(os.PathListSeparator) + parentPath
if agent.Env == nil {
agent.Env = map[string]string{}
}
if _, ok := agent.Env["PATH"]; !ok {
agent.Env["PATH"] = newPath
}
agent.Command = foundPath
return nil
}
// isExplicitPath reports whether cmd looks like a path rather than a bare
// command name (i.e. it contains a path separator, or on Windows a drive
// letter). On Windows both `\` and `/` count as separators.
func isExplicitPath(cmd string) bool {
if cmd == "" {
return false
}
if strings.ContainsAny(cmd, `/\`) {
return true
}
// Windows drive-relative like "C:foo".
if runtime.GOOS == "windows" && len(cmd) >= 2 && cmd[1] == ':' {
return true
}
return false
}
// resolveInDir looks for command inside dir, returning the absolute path to the
// executable if a match exists, or "" otherwise. On Windows it appends .exe
// when command has no extension and also honors PATHEXT for `cmd`-style lookup.
func resolveInDir(dir, command string) string {
if command == "" {
return ""
}
// If the command already carries an extension, check it verbatim.
if filepath.Ext(command) != "" {
candidate := filepath.Join(dir, command)
if isExecutableFile(candidate) {
return candidate
}
return ""
}
if runtime.GOOS == "windows" {
// Try the common PATHEXT extensions, .exe first.
exts := pathExtList()
for _, ext := range exts {
candidate := filepath.Join(dir, command+ext)
if isExecutableFile(candidate) {
return candidate
}
}
return ""
}
// POSIX: bare command, no extension manipulation.
candidate := filepath.Join(dir, command)
if isExecutableFile(candidate) {
return candidate
}
return ""
}
// pathExtList returns the ordered list of extensions to probe on Windows
// (derived from PATHEXT, with a sane default fallback).
func pathExtList() []string {
pe := os.Getenv("PATHEXT")
if pe == "" {
return []string{".exe", ".cmd", ".bat"}
}
parts := strings.Split(pe, string(os.PathListSeparator))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
if len(out) == 0 {
return []string{".exe", ".cmd", ".bat"}
}
return out
}
// isExecutableFile reports whether path is a regular file. On POSIX it also
// requires the executable bit, mirroring exec.LookPath semantics so a stale
// data file named "npm" does not get picked up.
func isExecutableFile(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
if runtime.GOOS == "windows" {
// Windows has no executable bit; any regular file with an executable
// extension (already enforced by the caller) is runnable.
return true
}
return info.Mode().Perm()&0o111 != 0
}
// nodeCandidateDirs returns the host's well-known directories that may hold
// node-based CLIs (npm/npx/node) when the parent process has a minimal PATH.
// The list is platform-specific; see nodeCandidateDirsUnix and
// nodeCandidateDirsWindows for the per-OS details.
func nodeCandidateDirs() []string {
if runtime.GOOS == "windows" {
return nodeCandidateDirsWindows()
}
return nodeCandidateDirsUnix()
}
// nodeCandidateDirsUnix covers macOS and Linux. The list is derived from
// environment variables when present (NVM_DIR, VOLTA_HOME, FNM_DIR, N_PREFIX)
// and falls back to common default locations for Homebrew, MacPorts, nvm,
// volta, fnm, .n, bun, yarn, snap, and the user's ~/.local/bin.
func nodeCandidateDirsUnix() []string {
home, _ := os.UserHomeDir()
var dirs []string
// Package-manager system locations.
dirs = append(dirs,
"/usr/local/bin", // Homebrew Intel, source installs, Linux user-local
"/usr/bin", // distro packages (Linux), system (macOS)
"/opt/homebrew/bin", // Homebrew Apple Silicon
"/opt/local/bin", // MacPorts
)
// nvm: $NVM_DIR/versions/node/<version>/bin plus the default install path.
nvmDir := os.Getenv("NVM_DIR")
if nvmDir == "" && home != "" {
nvmDir = filepath.Join(home, ".nvm")
}
if nvmDir != "" {
if entries, err := os.ReadDir(filepath.Join(nvmDir, "versions", "node")); err == nil {
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, filepath.Join(nvmDir, "versions", "node", e.Name(), "bin"))
}
}
}
}
// volta: $VOLTA_HOME/bin (default ~/.volta/bin).
voltaHome := os.Getenv("VOLTA_HOME")
if voltaHome == "" && home != "" {
voltaHome = filepath.Join(home, ".volta")
}
if voltaHome != "" {
dirs = append(dirs, filepath.Join(voltaHome, "bin"))
}
// fnm: $FNM_DIR/node-versions/<ver>/installation/bin, plus
// ~/.local/share/fnm/aliases/default/bin.
fnmDir := os.Getenv("FNM_DIR")
if fnmDir == "" && home != "" {
fnmDir = filepath.Join(home, ".fnm")
}
if fnmDir != "" {
if entries, err := os.ReadDir(filepath.Join(fnmDir, "node-versions")); err == nil {
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, filepath.Join(fnmDir, "node-versions", e.Name(), "installation", "bin"))
}
}
}
}
if home != "" {
dirs = append(dirs, filepath.Join(home, ".local", "share", "fnm", "aliases", "default", "bin"))
}
// .n (https://github.com/tj/n): N_PREFIX/bin (default /usr/local/n or
// ~/.n), and the per-user ~/.n/bin.
nPrefix := os.Getenv("N_PREFIX")
if nPrefix == "" && home != "" {
nPrefix = filepath.Join(home, ".n")
}
if nPrefix != "" {
dirs = append(dirs, filepath.Join(nPrefix, "bin"))
}
if home != "" {
dirs = append(dirs, filepath.Join(home, ".n", "bin"))
}
// Yarn, Bun, snap, and the generic user-level bin dir.
if home != "" {
dirs = append(dirs,
filepath.Join(home, ".yarn", "bin"),
filepath.Join(home, ".bun", "bin"),
filepath.Join(home, ".local", "bin"),
)
}
dirs = append(dirs, "/snap/bin") // snap packages (Linux)
return dirs
}
// nodeCandidateDirsWindows enumerates the directories where node-based CLIs
// land on Windows. GUI/service processes on Windows inherit the system PATH
// (which the official .msi installer augments), but user-scoped installs
// (nvm-windows, scoop, pnpm, fnm, volta) and per-user npm globals
// (%AppData%\npm) are frequently missing from a stripped-down PATH.
func nodeCandidateDirsWindows() []string {
home, _ := os.UserHomeDir()
var dirs []string
// Official .msi installer (system-wide and 32-bit fallback).
if pf := os.Getenv("ProgramFiles"); pf != "" {
dirs = append(dirs, filepath.Join(pf, "nodejs"))
}
if pf86 := os.Getenv("ProgramFiles(x86)"); pf86 != "" {
dirs = append(dirs, filepath.Join(pf86, "nodejs"))
}
// npm global bin: `npm install -g` puts shims here on Windows, and the
// official installer adds this to PATH only for the installing user.
if appData := os.Getenv("AppData"); appData != "" {
dirs = append(dirs, filepath.Join(appData, "npm"))
}
// pnpm global install location.
if localAppData := os.Getenv("LocalAppData"); localAppData != "" {
dirs = append(dirs, filepath.Join(localAppData, "pnpm"))
}
// nvm-windows: %NVM_HOME% (defaults to %LocalAppData%\nvm) holds
// nvm.exe; the active node version is symlinked under its root, so add the
// root and let the PATH-style lookup find node.exe/npm.cmd there.
if nvmHome := os.Getenv("NVM_HOME"); nvmHome != "" {
dirs = append(dirs, nvmHome)
} else if localAppData := os.Getenv("LocalAppData"); localAppData != "" {
dirs = append(dirs, filepath.Join(localAppData, "nvm"))
}
if nvmSymlink := os.Getenv("NVM_SYMLINK"); nvmSymlink != "" {
dirs = append(dirs, nvmSymlink)
}
// fnm-windows: %LocalAppData%\fnm_multishells is runtime-only; the stable
// binary lives under %LocalAppData%\fnm and node under its default version.
if localAppData := os.Getenv("LocalAppData"); localAppData != "" {
dirs = append(dirs,
filepath.Join(localAppData, "fnm"),
filepath.Join(localAppData, "fnm_multishells"),
)
}
// Volta for Windows.
if voltaHome := os.Getenv("VOLTA_HOME"); voltaHome != "" {
dirs = append(dirs, filepath.Join(voltaHome, "bin"))
} else if localAppData := os.Getenv("LocalAppData"); localAppData != "" {
dirs = append(dirs, filepath.Join(localAppData, "Volta", "bin"))
}
// Scoop: per-user shim dir + the nodejs app current symlink.
if home != "" {
dirs = append(dirs,
filepath.Join(home, "scoop", "shims"),
filepath.Join(home, "scoop", "apps", "nodejs", "current"),
)
}
return dirs
}
// errAgentCommandNotFound is returned (wrapped) when no candidate resolves; it
// exists so callers can use errors.Is if needed.
var errAgentCommandNotFound = errors.New("agent command not found")