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