返回 DeepSeek-Reasonix
bash.go
根目录 / internal / tool / builtin / bash.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "slices"
13 "strings"
14 "sync"
15 "time"
16
17 "mvdan.cc/sh/v3/syntax"
18
19 "reasonix/internal/persistentshell"
20 "reasonix/internal/proc"
21 "reasonix/internal/sandbox"
22 "reasonix/internal/secrets"
23 "reasonix/internal/sessiontemp"
24 "reasonix/internal/shellparse"
25 "reasonix/internal/shellrun"
26 "reasonix/internal/tool"
27 )
28
29 const (
30 bashWaitDelay = 5 * time.Second
31 )
32
33 func init() { tool.RegisterBuiltin(bash{}) }
34
35 var bashShellPATH = cachedBashShellPATH
36
37 var bashSandboxCommand = sandbox.Command
38
39 // cachedBashShellPATH memoizes the login-shell PATH lookup per login shell so a
40 // shell isn't spawned on every POSIX shell tool call (the lookup runs up to three
41 // interactive-login shells with a 2s timeout each). Empty results are cached too,
42 // so a host without a usable login shell doesn't re-probe each command.
43 var (
44 bashPathMu sync.Mutex
45 bashPathCache = map[string]string{}
46 )
47
48 func cachedBashShellPATH(ctx context.Context) string {
49 key := loginShell()
50 bashPathMu.Lock()
51 if v, ok := bashPathCache[key]; ok {
52 bashPathMu.Unlock()
53 return v
54 }
55 bashPathMu.Unlock()
56
57 v := defaultBashShellPATH(ctx)
58
59 bashPathMu.Lock()
60 bashPathCache[key] = v
61 bashPathMu.Unlock()
62 return v
63 }
64
65 // bash runs a shell command. sb, when it enforces, wraps the command in an OS
66 // sandbox; the zero value registered at init runs unconfined and is overridden
67 // per run by ConfineBash. shell is the resolved interpreter (real bash, or
68 // PowerShell on a Windows host without bash); the zero value resolves lazily.
69 // workDir, when non-empty, is the directory the command runs in (cmd.Dir);
70 // empty uses the process cwd. timeout optionally caps foreground commands;
71 // zero or negative means no tool-local cap, while parent context cancellation
72 // still kills the process tree. guard appends a warning to the output of
73 // commands that reference Reasonix's own session stores (see SessionDataGuard).
74 // sessionTemp, when non-nil, supplies the logical-session private temporary
75 // directory shared across shell calls (see package sessiontemp). A Manager on
76 // the execution context overrides this for sub-agent isolation.
77 type bash struct {
78 name string
79 sb sandbox.Spec
80 rootSet *sandbox.WritableRootSet
81 shell sandbox.Shell
82 guard SessionDataGuard
83 workDir string
84 timeout time.Duration
85 // terminal, when non-nil, runs foreground commands in a host-owned terminal
86 // (ACP terminal/*). Only consulted when the local OS sandbox is not
87 // enforcing — a host terminal cannot honor the confinement configuration —
88 // and never for background jobs, which need the local job manager.
89 terminal TerminalRunner
90 sessionTemp *sessiontemp.Manager
91 // persistent runs ordinary foreground commands in a session PTY. A
92 // context-attached manager isolates sub-agents. Nil keeps one-shot processes.
93 persistent *persistentshell.Manager
94 }
95
96 type bashParams struct {
97 Command string `json:"command"`
98 Description string `json:"description,omitempty"`
99 TimeoutMS int `json:"timeout_ms,omitempty"`
100 RunInBackground bool `json:"run_in_background"`
101 PreserveBackgroundProcesses bool `json:"preserve_background_processes"`
102 AdditionalWriteDirs []string `json:"additional_write_dirs,omitempty"`
103 Justification string `json:"justification,omitempty"`
104 SandboxPermissions string `json:"sandbox_permissions,omitempty"`
105 DenialID string `json:"denial_id,omitempty"`
106 }
107
108 func (b bash) Name() string {
109 if strings.TrimSpace(b.name) != "" {
110 return b.name
111 }
112 return "bash"
113 }
114
115 func (b bash) Description() string {
116 sh := b.resolved()
117 if sh.Kind == sandbox.ShellPowerShell {
118 return "Execute one PowerShell command in an isolated process and return combined stdout/stderr. " +
119 "The host prefers PowerShell 7 and can fall back to Windows PowerShell 5.1, so use syntax accepted by both:\n" +
120 " - chaining: ';' runs both commands; use 'if ($?) { ... }' for conditional execution.\n" +
121 " - redirect/vars: $null not /dev/null; $env:VAR not $VAR; '2>$null' drops stderr.\n" +
122 " - file ops: Get-ChildItem (ls), Get-Content (cat), Remove-Item -Recurse -Force (rm -rf), Copy-Item (cp), Select-String (grep).\n" +
123 " - no head/tail/which/touch: use Select-Object -First/-Last N, (Get-Command x).Source, New-Item.\n" +
124 " - services/watchers: set run_in_background=true and manage the returned job id with job_output/job_kill.\n" +
125 " - multi-line text to a native exe (e.g. git commit -m): use a single-quoted here-string @'...'@ (closing '@ at column 0)." +
126 bashToolSteer
127 }
128 return "Execute a command in the shell and return combined stdout/stderr. " +
129 "To write outside the workspace, pass additional_write_dirs with the smallest concrete directories (no globs; absolute, workspace-relative, ~, or ${HOME}) and a justification. " +
130 "The host will not infer write paths from the command text." + bashToolSteer
131 }
132
133 // bashToolSteer points the model at the cross-platform built-in tools instead of
134 // shell utilities, so it doesn't reach for grep/cat/ls/find (absent or different
135 // on native Windows) when a native tool already does the job everywhere.
136 const bashToolSteer = " Use for builds, tests, git, package managers, etc. To search/read/list/edit/move files, prefer the dedicated tools (grep, read_file, ls, glob, edit_file, move_file) over shell grep/cat/ls/find/sed/mv/Move-Item — they behave identically on every OS. For symbol search or architecture questions, prefer LSP/read tools and targeted grep before shell commands."
137
138 // resolved returns the bound shell, resolving lazily for the zero-value instance
139 // (e.g. a registry that never went through ConfineBash).
140 func (b bash) resolved() sandbox.Shell {
141 if b.shell.Path != "" {
142 return b.shell
143 }
144 if b.sb.Shell.Path != "" {
145 return b.sb.Shell
146 }
147 return sandbox.ResolveShell("", "", nil)
148 }
149
150 // ReadOnly is false: bash's effect cannot be inferred from args (rm, curl,
151 // git commit, etc. are all reachable). Conservative even when a particular
152 // command happens to be read-only — the agent batch decision can't tell.
153 func (bash) ReadOnly() bool { return false }
154
155 // SnipHint keeps both ends of command output equally: a build/test run's
156 // failure usually sits at the tail while the command and early context sit at
157 // the head, so neither end can be favored.
158 func (bash) SnipHint() tool.SnipHint {
159 return tool.SnipHint{Head: 40, Tail: 40, HeadChars: 8000, TailChars: 8000}
160 }
161
162 // Execute is the compatibility wrapper: all structured metadata is produced by
163 // ExecuteDetailed and discarded here so plugin/hook callers keep the old shape.
164 func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
165 res, err := b.ExecuteDetailed(ctx, args)
166 return res.Output, err
167 }
168
169 // ExecutionDescriptor returns shell identity for the bound interpreter without
170 // launching a process. Invalid args still yield a descriptor from the shell.
171 func (b bash) ExecutionDescriptor(args json.RawMessage) *tool.ShellExecution {
172 return shellrun.DescriptorFromShell(b.resolved())
173 }
174
175 // ExecuteDetailed runs the shell command and returns structured execution
176 // metadata for host UI / session persistence. Provider-visible output stays in
177 // DetailedResult.Output; metadata never enters tool schemas.
178 func (b bash) ExecuteDetailed(ctx context.Context, args json.RawMessage) (tool.DetailedResult, error) {
179 start := time.Now()
180 ex := shellrun.DescriptorFromShell(b.resolved())
181 ex.State = tool.ShellStateRunning
182 ex.MutationRisk = tool.ShellMutationUnknown
183 ex.Verification = tool.ShellVerificationNotVerification
184
185 var p bashParams
186 if err := json.Unmarshal(args, &p); err != nil {
187 return bashPreflightFailure(ex, start, fmt.Errorf("invalid args: %w", err))
188 }
189 if err := validateBashParams(p); err != nil {
190 return bashPreflightFailure(ex, start, err)
191 }
192
193 sh := b.resolved()
194 if err := sandbox.ValidateShellPolicy(b.specForCall(ctx), sh); err != nil {
195 return bashPreflightFailure(ex, start, err)
196 }
197 if res, err, reject := rejectPowerShellChaining(ex, start, sh, p.Command); reject {
198 return res, err
199 }
200
201 // Pin the session-private temporary generation before any launch path so
202 // foreground, background, and host-terminal runs share one directory, and
203 // so a failed start still releases the lease.
204 prepared, lease, err := b.prepareLaunch(ctx, sh, p.Command, args)
205 if err != nil {
206 return bashLaunchFailure(ex, start, err)
207 }
208 // Background jobs take ownership of the lease until the job goroutine ends.
209 // Foreground/terminal paths release after the process exits.
210 releaseLease := true
211 defer func() {
212 if releaseLease && lease != nil {
213 lease.Release()
214 }
215 }()
216
217 // A host-owned terminal runs the command where the user watches it live.
218 // Never when the OS sandbox is enforcing (the host cannot honor the local
219 // confinement config), never when [secrets].filter_subprocess_env is on
220 // (the host terminal spawns with its own unfiltered environment, which
221 // would leak the credentials the user asked to strip), and never for
222 // background jobs. ok=false falls back to local execution unchanged.
223 if b.terminal != nil && sh.Kind != sandbox.ShellPowerShell && !p.RunInBackground && !b.sb.Enforce() && !secrets.FilterSubprocessEnv() {
224 envMap := sandbox.SessionTempEnvMap(prepared.SessionTemp, prepared.LinuxSandboxed)
225 if out, ok, termErr := b.terminal.RunCommand(ctx, p.Command, b.workDir, b.timeout, envMap); ok {
226 out = appendSessionDataHint(out, b.guard.CommandHint(b.workDir, p.Command))
227 applyTerminalResult(ex, termErr)
228 ex.DurationMs = time.Since(start).Milliseconds()
229 return tool.DetailedResult{Output: out, Execution: ex}, termErr
230 }
231 }
232
233 argv, wrapped := prepared.Argv, prepared.Wrapped
234 cmdEnv := applyEnvOverrides(bashCommandEnv(ctx), prepared.EnvOverrides)
235 if res, err, used := b.tryPersistent(ctx, p, sh, prepared, persistEnv(cmdEnv), start, ex); used {
236 return res, err
237 }
238
239 if p.RunInBackground {
240 return b.startBackground(ctx, p, sh, argv, wrapped, cmdEnv, lease, &releaseLease, start, ex)
241 }
242
243 out, runEx, err := b.runForegroundDetailed(ctx, p, sh, argv, wrapped, cmdEnv)
244 mergeRunInto(ex, runEx)
245 ex.DurationMs = time.Since(start).Milliseconds()
246 out = b.appendWriteHints(ctx, out, err, p, wrapped)
247 return tool.DetailedResult{
248 Output: out,
249 Execution: ex,
250 }, err
251 }
252
253 func applyTerminalResult(ex *tool.ShellExecution, err error) {
254 if ex == nil {
255 return
256 }
257 if err == nil {
258 ex.State = tool.ShellStateCompleted
259 ex.ExitCode = tool.IntPtr(0)
260 ex.MutationRisk = tool.ShellMutationMayHaveCompleted
261 return
262 }
263 if errors.Is(err, context.Canceled) {
264 ex.State = tool.ShellStateCancelled
265 ex.FailurePhase = tool.ShellPhaseCancellation
266 ex.MutationRisk = tool.ShellMutationMayBePartial
267 return
268 }
269 var timeoutErr TerminalTimeoutError
270 if errors.As(err, &timeoutErr) || errors.Is(err, context.DeadlineExceeded) {
271 ex.State = tool.ShellStateTimedOut
272 ex.FailurePhase = tool.ShellPhaseTimeout
273 ex.MutationRisk = tool.ShellMutationMayBePartial
274 return
275 }
276 var exitErr TerminalExitError
277 if errors.As(err, &exitErr) {
278 code := exitErr.Code
279 ex.ExitCode = &code
280 ex.State = tool.ShellStateFailed
281 ex.FailurePhase = tool.ShellPhaseExecution
282 ex.MutationRisk = tool.ShellMutationMayBePartial
283 return
284 }
285 // Legacy plain errors from older host runners.
286 ex.State = tool.ShellStateFailed
287 ex.FailurePhase = tool.ShellPhaseExecution
288 ex.MutationRisk = tool.ShellMutationMayBePartial
289 }
290
291 func mergeRunInto(dst *tool.ShellExecution, src *tool.ShellExecution) {
292 if dst == nil || src == nil {
293 return
294 }
295 dst.State = src.State
296 dst.FailurePhase = src.FailurePhase
297 dst.ExitCode = src.ExitCode
298 dst.OutputTail = src.OutputTail
299 if src.MutationRisk != "" {
300 dst.MutationRisk = src.MutationRisk
301 }
302 }
303
304 // prepareLaunch acquires a session-temp lease (when a Manager is available),
305 // builds the sandboxed argv, and applies sandbox-escape approval. The caller
306 // owns the returned lease and must Release it after the process exits.
307 func (b bash) prepareLaunch(ctx context.Context, sh sandbox.Shell, command string, rawArgs json.RawMessage) (sandbox.Prepared, *sessiontemp.Lease, error) {
308 var lease *sessiontemp.Lease
309 sessionDir := ""
310 if m := b.sessionTempManager(ctx); m != nil {
311 l, err := m.Acquire()
312 if err != nil {
313 return sandbox.Prepared{}, nil, fmt.Errorf("session temporary directory: %w", err)
314 }
315 lease = l
316 sessionDir = l.Dir()
317 }
318
319 // bashSandboxCommand is injectable for tests; production points at
320 // sandbox.Command. Attach SessionTemp so Linux bwrap binds the private dir.
321 spec := b.specForCall(ctx)
322 effectiveSessionDir := sessionDir
323 spec.SessionTemp = effectiveSessionDir
324 argv, wrapped := bashSandboxCommand(spec, sh, command)
325 linuxSB := wrapped && effectiveSessionDir != "" && runtime.GOOS == "linux"
326 prepared := sandbox.Prepared{
327 Argv: argv,
328 Wrapped: wrapped,
329 SessionTemp: effectiveSessionDir,
330 EnvOverrides: sandbox.SessionTempEnv(effectiveSessionDir, linuxSB),
331 LinuxSandboxed: linuxSB,
332 }
333
334 if spec.Enforce() && !prepared.Wrapped {
335 if lease != nil {
336 lease.Release()
337 }
338 return sandbox.Prepared{}, nil, fmt.Errorf("%s", sandbox.UnavailableMessage())
339 }
340 return prepared, lease, nil
341 }
342
343 func (b bash) sessionTempManager(ctx context.Context) *sessiontemp.Manager {
344 if m := sessiontemp.FromContext(ctx); m != nil {
345 return m
346 }
347 return b.sessionTemp
348 }
349
350 func applyEnvOverrides(env, overrides []string) []string {
351 for _, kv := range overrides {
352 key, value, ok := strings.Cut(kv, "=")
353 if !ok || key == "" {
354 continue
355 }
356 env = setEnvValue(env, key, value)
357 }
358 return env
359 }
360
361 // appendSessionDataHint appends the session-data guard warning to command
362 // output; with no output the hint stands alone. An empty hint is a no-op.
363 func appendSessionDataHint(out, hint string) string {
364 if hint == "" {
365 return out
366 }
367 if strings.TrimSpace(out) == "" {
368 return hint
369 }
370 return out + "\n\n" + hint
371 }
372
373 func unconfinedShellArgv(sh sandbox.Shell, command string) []string {
374 argv, _ := sandbox.Command(sandbox.Spec{}, sh, command)
375 return argv
376 }
377
378 // runForegroundDetailed uses the shared shellrun collector so model bash and
379 // user !command share exit-code / phase / output-tail classification.
380 func (b bash) runForegroundDetailed(ctx context.Context, p bashParams, sh sandbox.Shell, argv []string, wrapped bool, cmdEnv []string) (string, *tool.ShellExecution, error) {
381 ex := shellrun.DescriptorFromShell(sh)
382 var progress func(string)
383 if emit, ok := tool.ProgressFrom(ctx); ok {
384 progress = emit
385 }
386 track := shouldTrackShellProcess(wrapped, sh, p.Command, p.PreserveBackgroundProcesses)
387 res := shellrun.RunForeground(ctx, shellrun.Request{
388 Argv: argv,
389 Dir: b.workDir,
390 Env: cmdEnv,
391 Timeout: b.foregroundTimeoutFor(p),
392 WaitDelay: bashWaitDelay,
393 CommandPreview: commandPreview(p.Command),
394 ShellKind: sh.Kind.String(),
395 ShellPath: sh.Path,
396 Source: b.Name() + "_tool",
397 Track: track,
398 PreserveWaitDelay: p.PreserveBackgroundProcesses,
399 Progress: progress,
400 })
401 // A foreground command that spawned a lingering child (e.g. `bazel run`'s
402 // server) leaves it in the process group; Wait only reaped the shell leader.
403 // Kill the group so those don't accumulate into an OOM (#3702). On cancel/
404 // timeout the command's Cancel path already did this; this covers normal exit.
405 // shellrun owns the tool-local timeout context, so treat timed_out/cancelled
406 // as ctx.Err()!=nil for the reap decision.
407 reapCtx := ctx
408 if res.State == tool.ShellStateTimedOut || res.State == tool.ShellStateCancelled || ctx.Err() != nil {
409 // Force reap on forced stops even when preserve_background_processes is set.
410 reapShellProcess(res.Cmd, res.Tracked)
411 } else if shouldReapAfterRun(reapCtx, sh, p.Command, p.PreserveBackgroundProcesses) {
412 reapShellProcess(res.Cmd, res.Tracked)
413 }
414
415 ex.State = res.State
416 ex.FailurePhase = res.FailurePhase
417 ex.ExitCode = res.ExitCode
418 ex.OutputTail = res.OutputTail
419 switch res.State {
420 case tool.ShellStateCompleted:
421 ex.MutationRisk = tool.ShellMutationMayHaveCompleted
422 case tool.ShellStateNotRun:
423 ex.MutationRisk = tool.ShellMutationNotStarted
424 case tool.ShellStateFailed:
425 if res.FailurePhase == tool.ShellPhaseLaunch || res.FailurePhase == tool.ShellPhasePreflight {
426 ex.MutationRisk = tool.ShellMutationNotStarted
427 } else {
428 ex.MutationRisk = tool.ShellMutationMayBePartial
429 }
430 case tool.ShellStateTimedOut, tool.ShellStateCancelled:
431 ex.MutationRisk = tool.ShellMutationMayBePartial
432 default:
433 ex.MutationRisk = tool.ShellMutationUnknown
434 }
435 return res.Combined, ex, res.Err
436 }
437
438 func normalizeBashRunError(ctx context.Context, err error, preserveBackgroundProcesses bool) error {
439 if preserveBackgroundProcesses && ctx.Err() == nil && errors.Is(err, exec.ErrWaitDelay) {
440 return nil
441 }
442 return err
443 }
444
445 func shouldReapAfterRun(ctx context.Context, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool {
446 if ctx.Err() != nil {
447 return true
448 }
449 if preserveBackgroundProcesses {
450 return false
451 }
452 return !sh.Kind.IsPOSIX() || !hasExplicitBackgroundKeepalive(command)
453 }
454
455 // hasExplicitBackgroundKeepalive detects common shell-level daemonization intent
456 // without letting a plain "cmd &" bypass #3702's stray process cleanup.
457 func hasExplicitBackgroundKeepalive(command string) bool {
458 file, err := shellparse.ParseBash(command)
459 if err != nil {
460 return false
461 }
462
463 hasBackground := false
464 hasKeepaliveCommand := false
465 syntax.Walk(file, func(node syntax.Node) bool {
466 switch n := node.(type) {
467 case *syntax.Stmt:
468 if n.Background {
469 hasBackground = true
470 }
471 case *syntax.CallExpr:
472 name, ok := staticShellCallName(n)
473 if !ok {
474 break
475 }
476 switch name {
477 case "disown", "nohup", "setsid":
478 hasKeepaliveCommand = true
479 }
480 }
481 return !(hasBackground && hasKeepaliveCommand)
482 })
483 return hasBackground && hasKeepaliveCommand
484 }
485
486 func (b bash) foregroundTimeout() time.Duration {
487 if b.timeout <= 0 {
488 return 0
489 }
490 return b.timeout
491 }
492
493 func (b bash) foregroundTimeoutFor(p bashParams) time.Duration {
494 configured := b.foregroundTimeout()
495 if p.TimeoutMS <= 0 {
496 return configured
497 }
498 return cappedMilliseconds(p.TimeoutMS, configured)
499 }
500
501 // Clamp in milliseconds before multiplication so integer overflow can never
502 // turn a positive timeout into an unlimited (negative) duration.
503 func cappedMilliseconds(ms int, cap time.Duration) time.Duration {
504 const maxDuration = time.Duration(1<<63 - 1)
505 if cap <= 0 {
506 cap = maxDuration
507 }
508 if int64(ms) > int64(cap/time.Millisecond) {
509 return cap
510 }
511 return time.Duration(ms) * time.Millisecond
512 }
513
514 func shouldTrackShellProcess(wrapped bool, sh sandbox.Shell, command string, preserveBackgroundProcesses bool) bool {
515 if preserveBackgroundProcesses {
516 return false
517 }
518 return !sh.Kind.IsPOSIX() || !hasExplicitBackgroundKeepalive(command)
519 }
520
521 func runShellProcess(ctx context.Context, cmd *exec.Cmd, sh sandbox.Shell, command string, track bool) (*proc.TrackedCommand, error) {
522 source := "bash_tool"
523 if sh.Kind == sandbox.ShellPowerShell {
524 source = "pwsh_tool"
525 }
526 tracked, err := proc.RunCommand(ctx, cmd, proc.RunOptions{
527 Track: track,
528 CancelWaitGrace: bashWaitDelay + time.Second,
529 Source: source,
530 ShellKind: sh.Kind.String(),
531 ShellPath: sh.Path,
532 CommandPreview: commandPreview(command),
533 })
534 return tracked, err
535 }
536
537 func reapShellProcess(cmd *exec.Cmd, tracked *proc.TrackedCommand) {
538 if tracked != nil {
539 tracked.Kill()
540 return
541 }
542 proc.KillTree(cmd)
543 }
544
545 // hasUnquotedSeq reports whether seq appears in s outside any single- or
546 // double-quoted span, so a literal "a && b" string argument doesn't trip the
547 // PowerShell chaining guard.
548 func hasUnquotedSeq(s, seq string) bool {
549 var quote byte
550 for i := range len(s) {
551 c := s[i]
552 if quote != 0 {
553 if c == quote {
554 quote = 0
555 }
556 continue
557 }
558 if c == '\'' || c == '"' {
559 quote = c
560 continue
561 }
562 if strings.HasPrefix(s[i:], seq) {
563 return true
564 }
565 }
566 return false
567 }
568
569 func staticShellCallName(call *syntax.CallExpr) (string, bool) {
570 for _, arg := range call.Args {
571 word, ok := shellparse.StaticWord(arg)
572 if !ok {
573 return "", false
574 }
575 if shellparse.IsAssignment(word) {
576 continue
577 }
578 base := shellparse.WordBase(word)
579 if base == "command" || base == "env" {
580 continue
581 }
582 return base, true
583 }
584 return "", false
585 }
586
587 // commandPreview is a short single-line label for a background bash job, surfaced
588 // in the status bar and completion notices.
589 func commandPreview(cmd string) string {
590 cmd = strings.TrimSpace(strings.ReplaceAll(cmd, "\n", " "))
591 const max = 48
592 r := []rune(cmd)
593 if len(r) > max {
594 return string(r[:max]) + "…"
595 }
596 return cmd
597 }
598
599 func bashCommandEnv(ctx context.Context) []string {
600 env := secrets.ProcessEnv()
601 if runtime.GOOS == "windows" {
602 return env
603 }
604 currentPath, _ := envValue(env, "PATH")
605 if shellPath := strings.TrimSpace(bashShellPATH(ctx)); shellPath != "" {
606 if merged := mergePathLists(shellPath, currentPath); merged != currentPath {
607 env = setEnvValue(env, "PATH", merged)
608 }
609 }
610 return env
611 }
612
613 func defaultBashShellPATH(ctx context.Context) string {
614 if runtime.GOOS == "windows" {
615 return ""
616 }
617 shell := loginShell()
618 if shell == "" {
619 return ""
620 }
621 const marker = "__REASONIX_BASH_PATH__="
622 script := "printf '\\n" + marker + "%s\\n' \"$PATH\""
623 for _, args := range [][]string{
624 {"-l", "-i", "-c", script},
625 {"-l", "-c", script},
626 {"-c", script},
627 } {
628 out := runShellPATHCommand(ctx, shell, args)
629 if path := parseShellPATH(out, marker); path != "" {
630 return path
631 }
632 }
633 return ""
634 }
635
636 func loginShell() string {
637 if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" {
638 if hasPathSeparator(shell) {
639 if isExecutableFile(shell) {
640 return shell
641 }
642 } else if p, err := exec.LookPath(shell); err == nil {
643 return p
644 }
645 }
646 for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
647 if isExecutableFile(shell) {
648 return shell
649 }
650 }
651 return ""
652 }
653
654 func runShellPATHCommand(parent context.Context, shell string, args []string) []byte {
655 ctx, cancel := context.WithTimeout(parent, 2*time.Second)
656 defer cancel()
657 cmd := proc.CommandContext(ctx, shell, args...)
658 // Explicit env so the login-shell probe honors [secrets]
659 // filter_subprocess_env instead of inheriting the full environment.
660 cmd.Env = secrets.ProcessEnv()
661 proc.PrepareShellPATHProbe(cmd)
662 cmd.Stdin = strings.NewReader("")
663 out, _ := cmd.CombinedOutput()
664 return out
665 }
666
667 func parseShellPATH(out []byte, marker string) string {
668 lines := strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n")
669 for _, line := range slices.Backward(lines) {
670 if rest, ok := strings.CutPrefix(line, marker); ok {
671 return strings.TrimSpace(rest)
672 }
673 }
674 return ""
675 }
676
677 func hasPathSeparator(s string) bool {
678 return strings.ContainsAny(s, `/\`)
679 }
680
681 func isExecutableFile(path string) bool {
682 info, err := os.Stat(path)
683 if err != nil || info.IsDir() {
684 return false
685 }
686 return info.Mode().Perm()&0o111 != 0
687 }
688
689 func setEnvValue(env []string, key, value string) []string {
690 out := make([]string, 0, len(env)+1)
691 replaced := false
692 for _, kv := range env {
693 k, _, ok := strings.Cut(kv, "=")
694 if ok && envKeyEqual(k, key) {
695 if !replaced {
696 out = append(out, key+"="+value)
697 replaced = true
698 }
699 continue
700 }
701 out = append(out, kv)
702 }
703 if !replaced {
704 out = append(out, key+"="+value)
705 }
706 return out
707 }
708
709 func envValue(env []string, key string) (string, bool) {
710 for _, entry := range slices.Backward(env) {
711 k, v, ok := strings.Cut(entry, "=")
712 if ok && envKeyEqual(k, key) {
713 return v, true
714 }
715 }
716 return "", false
717 }
718
719 func envKeyEqual(a, b string) bool {
720 if runtime.GOOS == "windows" {
721 return strings.EqualFold(a, b)
722 }
723 return a == b
724 }
725
726 func mergePathLists(primary, secondary string) string {
727 var out []string
728 seen := map[string]bool{}
729 add := func(path string) {
730 for _, part := range filepath.SplitList(path) {
731 part = strings.TrimSpace(part)
732 if part == "" || seen[part] {
733 continue
734 }
735 seen[part] = true
736 out = append(out, part)
737 }
738 }
739 add(primary)
740 add(secondary)
741 return strings.Join(out, string(os.PathListSeparator))
742 }
743
743 lines GO