返回 DeepSeek-Reasonix
bash_persistent.go
根目录 / internal / tool / builtin / bash_persistent.go
1 package builtin
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "mvdan.cc/sh/v3/syntax"
11
12 "reasonix/internal/persistentshell"
13 "reasonix/internal/sandbox"
14 "reasonix/internal/shellparse"
15 "reasonix/internal/shellrun"
16 "reasonix/internal/tool"
17 )
18
19 // shellResetNotice tells the model that session shell state is gone. Without it
20 // the model keeps issuing relative paths against a working directory that the
21 // reset already discarded.
22 const shellResetNotice = "The persistent shell was reset; the next bash call starts from the workspace with a fresh working directory and environment."
23
24 func persistEnv(env []string) []string {
25 return applyEnvOverrides(env, []string{
26 "TERM=dumb",
27 "NO_COLOR=1",
28 "PAGER=cat",
29 "GIT_PAGER=cat",
30 "BASH_SILENCE_DEPRECATION_WARNING=1",
31 })
32 }
33
34 func (b bash) persistentManager(ctx context.Context) *persistentshell.Manager {
35 if m := persistentshell.FromContext(ctx); m != nil {
36 return m
37 }
38 return b.persistent
39 }
40
41 // hasBackgroundStatement reports an explicit `&` background operator. A session
42 // shell outlives the call, so its background children are neither reaped by
43 // #3702's process-group cleanup nor kept out of the next command's output: both
44 // contracts only hold in a one-shot process, so such commands stay there.
45 func hasBackgroundStatement(command string) bool {
46 file, err := shellparse.ParseBash(command)
47 if err != nil {
48 return false
49 }
50 background := false
51 syntax.Walk(file, func(node syntax.Node) bool {
52 if stmt, ok := node.(*syntax.Stmt); ok && stmt.Background {
53 background = true
54 }
55 return !background
56 })
57 return background
58 }
59
60 func (b bash) shouldUsePersistent(ctx context.Context, p bashParams, sh sandbox.Shell) bool {
61 if sh.Kind == sandbox.ShellPowerShell {
62 return false
63 }
64 if !persistentshell.Supports(sh) {
65 return false
66 }
67 if p.RunInBackground || p.PreserveBackgroundProcesses {
68 return false
69 }
70 if len(p.AdditionalWriteDirs) > 0 || strings.TrimSpace(p.SandboxPermissions) != "" {
71 return false
72 }
73 if sh.Kind.IsPOSIX() && hasBackgroundStatement(p.Command) {
74 return false
75 }
76 if sh.Kind == sandbox.ShellPowerShell && powerShellIsolated(p.Command) {
77 return false
78 }
79 m := b.persistentManager(ctx)
80 return m != nil && !m.Sealed()
81 }
82
83 func rejectPowerShellChaining(ex *tool.ShellExecution, start time.Time, sh sandbox.Shell, command string) (tool.DetailedResult, error, bool) {
84 if sh.SupportsChaining() || (!hasUnquotedSeq(command, "&&") && !hasUnquotedSeq(command, "||")) {
85 return tool.DetailedResult{}, nil, false
86 }
87 ex.State = tool.ShellStateNotRun
88 ex.FailurePhase = tool.ShellPhasePreflight
89 ex.MutationRisk = tool.ShellMutationNotStarted
90 ex.DurationMs = time.Since(start).Milliseconds()
91 return tool.DetailedResult{Execution: ex}, fmt.Errorf("this shell is Windows PowerShell, which does not parse '&&' or '||'. " +
92 "Sequence with ';' (both run regardless of the first's result), use 'if ($?) { ... }' for " +
93 "conditional chaining, or issue the commands as separate calls"), true
94 }
95
96 func (b bash) tryPersistent(ctx context.Context, p bashParams, sh sandbox.Shell, prepared sandbox.Prepared, cmdEnv []string, start time.Time, ex *tool.ShellExecution) (tool.DetailedResult, error, bool) {
97 out, runEx, err, used := b.runPersistent(ctx, p, sh, prepared, cmdEnv)
98 if !used {
99 return tool.DetailedResult{}, nil, false
100 }
101 mergeRunInto(ex, runEx)
102 ex.DurationMs = time.Since(start).Milliseconds()
103 return tool.DetailedResult{
104 Output: b.appendWriteHints(ctx, out, err, p, prepared.Wrapped),
105 Execution: ex,
106 }, err, true
107 }
108
109 func (b bash) runPersistent(ctx context.Context, p bashParams, sh sandbox.Shell, prepared sandbox.Prepared, cmdEnv []string) (string, *tool.ShellExecution, error, bool) {
110 if !b.shouldUsePersistent(ctx, p, sh) {
111 return "", nil, nil, false
112 }
113 m := b.persistentManager(ctx)
114 // The session-private temporary directory must reach the sandbox profile,
115 // not just the child environment: the spec that sets TMPDIR/GOCACHE must
116 // also bind (Linux) or allow (Seatbelt) that directory.
117 spec := b.specForCall(ctx)
118 launch := sandbox.PrepareShellArgs(spec, persistentshell.InteractiveArgv(sh), prepared.SessionTemp)
119 if spec.Enforce() && !launch.Wrapped {
120 ex := shellrun.DescriptorFromShell(sh)
121 ex.State = tool.ShellStateNotRun
122 ex.FailurePhase = tool.ShellPhaseLaunch
123 ex.MutationRisk = tool.ShellMutationNotStarted
124 return "", ex, fmt.Errorf("%s", sandbox.UnavailableMessage()), true
125 }
126 var progress = shellrun.NewProgressWriter(nil)
127 if emit, ok := tool.ProgressFrom(ctx); ok {
128 progress = shellrun.NewProgressWriter(emit)
129 }
130 defer progress.Flush()
131 res := m.Run(ctx, persistentshell.Request{
132 Argv: launch.Argv,
133 Dir: b.workDir,
134 Env: applyEnvOverrides(cmdEnv, launch.EnvOverrides),
135 Command: p.Command,
136 Timeout: b.foregroundTimeoutFor(p),
137 Shell: sh,
138 Progress: progress,
139 })
140 if !res.Started && res.Err != nil && !res.Reset {
141 var startup *persistentshell.StartupError
142 if !errors.As(res.Err, &startup) {
143 if sh.Kind == sandbox.ShellPowerShell {
144 out, ex, err := b.runForegroundDetailed(ctx, p, sh, prepared.Argv, prepared.Wrapped, cmdEnv)
145 return appendSessionDataHint(out, "Persistent PowerShell was unavailable before this command started. This call ran once in an isolated process; its directory and variable changes are not retained."), ex, err, true
146 }
147 return "", nil, nil, false
148 }
149 }
150 ex := shellrun.DescriptorFromShell(sh)
151 ex.State = res.State
152 ex.FailurePhase = res.FailurePhase
153 code := res.ExitCode
154 if res.ExitCodeKnown {
155 ex.ExitCode = &code
156 }
157 if res.State != tool.ShellStateCompleted && res.Output != "" {
158 ex.OutputTail = res.Output
159 if len(ex.OutputTail) > tool.OutputTailMaxBytes {
160 ex.OutputTail = ex.OutputTail[len(ex.OutputTail)-tool.OutputTailMaxBytes:]
161 }
162 }
163 switch res.State {
164 case tool.ShellStateCompleted:
165 ex.MutationRisk = tool.ShellMutationMayHaveCompleted
166 case tool.ShellStateNotRun:
167 ex.MutationRisk = tool.ShellMutationNotStarted
168 case tool.ShellStateFailed:
169 if res.FailurePhase == tool.ShellPhaseLaunch || res.FailurePhase == tool.ShellPhasePreflight {
170 ex.MutationRisk = tool.ShellMutationNotStarted
171 } else {
172 ex.MutationRisk = tool.ShellMutationMayBePartial
173 }
174 case tool.ShellStateTimedOut, tool.ShellStateCancelled:
175 ex.MutationRisk = tool.ShellMutationMayBePartial
176 default:
177 ex.MutationRisk = tool.ShellMutationUnknown
178 }
179 out := res.Output
180 if res.Reset {
181 out = appendSessionDataHint(out, shellResetNotice)
182 }
183 return out, ex, res.Err, true
184 }
185
186 // Conservative isolation is safe for quoted mentions too; do not parse
187 // PowerShell background syntax with the Bash parser.
188 func powerShellIsolated(command string) bool {
189 lower := strings.ToLower(command)
190 for _, token := range []string{"start-job", "start-threadjob", "start-process", "-asjob"} {
191 if strings.Contains(lower, token) {
192 return true
193 }
194 }
195 var quote rune
196 var previous rune
197 runes := []rune(command)
198 for i, ch := range runes {
199 if quote != 0 {
200 if ch == quote {
201 quote = 0
202 }
203 continue
204 }
205 if ch == '\'' || ch == '"' {
206 quote = ch
207 previous = ch
208 continue
209 }
210 if ch == '&' {
211 if i+1 < len(runes) && runes[i+1] == '&' || i > 0 && runes[i-1] == '&' {
212 continue
213 }
214 if previous != 0 && !strings.ContainsRune(";\n|({=", previous) {
215 return true
216 }
217 }
218 if ch != ' ' && ch != '\t' && ch != '\r' {
219 previous = ch
220 }
221 }
222 return false
223 }
224
224 lines GO