返回 DeepSeek-Reasonix
transport_stdio.go
根目录 / internal / plugin / transport_stdio.go
1 package plugin
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "slices"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/proc"
17 "reasonix/internal/sandbox"
18 "reasonix/internal/secrets"
19 )
20
21 const (
22 closeWaitBudget = 5 * time.Second
23 gracefulCloseWaitBudget = 750 * time.Millisecond
24 )
25
26 // stdioTransport owns the Reasonix-specific subprocess lifecycle. MCP framing,
27 // concurrent request correlation, cancellation, and server requests are owned
28 // by the official SDK's IOTransport.
29 type stdioTransport struct {
30 name string
31 cmd *exec.Cmd
32 job uintptr // Windows Job Object handle (0 elsewhere); reaps detached grandchildren on close
33 stdin io.WriteCloser
34 stdout io.ReadCloser
35 stderr *tailBuffer
36 waitOnce sync.Once
37 closeOnce sync.Once
38 releaseSlot func() // returns a bounded instance slot (e.g. CodeGraph) on close; nil when unbounded
39 }
40
41 func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) {
42 if strings.TrimSpace(s.Command) == "" {
43 return nil, fmt.Errorf("stdio plugin %q: command is required", s.Name)
44 }
45 var releaseSlot func()
46 if isCodeGraphSpecName(s.Name) {
47 release, err := acquireCodeGraphSlot()
48 if err != nil {
49 return nil, err
50 }
51 releaseSlot = release
52 }
53 defer func() {
54 // Release the reserved slot if construction fails before the transport
55 // takes ownership of it (set to nil on the success path below).
56 if releaseSlot != nil {
57 releaseSlot()
58 }
59 }()
60 env := mergeEnv(secrets.ProcessEnv(), s.Env)
61 exe, env, err := resolveStdioExecutable(ctx, s, env)
62 if err != nil {
63 return nil, err
64 }
65 // Private state/cache/temp always apply so MCP processes do not pollute the
66 // user's home caches. Command-sandbox wrapping is separate and only used for
67 // confined mode; authorized user installs run as trusted host processes so
68 // Chrome, Keychain, and local app services keep working.
69 processSandbox := s.Sandbox
70 processSandbox, env, err = prepareMCPPrivateState(s, processSandbox, env)
71 if err != nil {
72 return nil, err
73 }
74 launchArgs := append([]string{exe}, effectiveLaunchArgs(s)...)
75 var argv []string
76 if s.ResolvedProcessMode() == MCPProcessConfined {
77 processSandbox.MinimalWrites = true
78 var err error
79 argv, err = wrapConfinedCommand(s.Name, processSandbox, launchArgs, sandbox.CommandArgs)
80 if err != nil {
81 return nil, err
82 }
83 } else {
84 argv = launchArgs
85 }
86 cmd := proc.CommandContext(ctx, argv[0], argv[1:]...)
87 proc.HideWindow(cmd)
88 if s.LowPriority {
89 proc.LowPriority(cmd)
90 }
91 cmd.Env = env
92 cmd.Dir = stdioWorkingDir(s)
93 stderr := &tailBuffer{limit: 16 * 1024}
94 cmd.Stderr = stderr
95 if s.Stderr != nil {
96 cmd.Stderr = io.MultiWriter(stderr, s.Stderr)
97 }
98
99 stdin, err := cmd.StdinPipe()
100 if err != nil {
101 return nil, err
102 }
103 stdout, err := cmd.StdoutPipe()
104 if err != nil {
105 return nil, err
106 }
107 job, err := proc.StartTracked(cmd)
108 if err != nil {
109 return nil, err
110 }
111 if s.LowPriority {
112 proc.LowPriorityStarted(cmd)
113 }
114 t := &stdioTransport{
115 name: s.Name,
116 cmd: cmd,
117 job: job,
118 stdin: stdin,
119 stdout: stdout,
120 stderr: stderr,
121 releaseSlot: releaseSlot,
122 }
123 releaseSlot = nil // ownership transferred to t; close() releases it
124 return t, nil
125 }
126
127 func prepareMCPPrivateState(s Spec, processSandbox sandbox.Spec, env []string) (sandbox.Spec, []string, error) {
128 return prepareMCPPrivateStateForOS(s, processSandbox, env, runtime.GOOS)
129 }
130
131 func prepareMCPPrivateStateForOS(s Spec, processSandbox sandbox.Spec, env []string, goos string) (sandbox.Spec, []string, error) {
132 root := strings.TrimSpace(s.StateDir)
133 if root == "" {
134 return processSandbox, env, nil
135 }
136 if err := os.MkdirAll(root, 0o700); err != nil {
137 return processSandbox, env, err
138 }
139 privateRoot := root
140 cacheDir := filepath.Join(privateRoot, "cache")
141 stateDir := filepath.Join(privateRoot, "state")
142 dirs := []string{cacheDir, stateDir}
143 privateEnv := map[string]string{
144 "XDG_CACHE_HOME": cacheDir, "XDG_STATE_HOME": stateDir,
145 "npm_config_cache": filepath.Join(cacheDir, "npm"),
146 "UV_CACHE_DIR": filepath.Join(cacheDir, "uv"),
147 "BUN_INSTALL_CACHE_DIR": filepath.Join(cacheDir, "bun"),
148 }
149 if goos != "windows" {
150 tmpDir := filepath.Join(privateRoot, "tmp")
151 dirs = append(dirs, tmpDir)
152 privateEnv["TMP"] = tmpDir
153 privateEnv["TEMP"] = tmpDir
154 privateEnv["TMPDIR"] = tmpDir
155 }
156 for _, dir := range dirs {
157 if err := os.MkdirAll(dir, 0o700); err != nil {
158 return processSandbox, env, err
159 }
160 }
161 // Windows stdio processes are currently unsandboxed and must keep the host's
162 // short temporary directory. Nesting TEMP below Reasonix's workspace-scoped
163 // state path can exceed the 108-byte Unix-domain-socket limit used by MCP
164 // servers such as MATLAB before their initialize response is written.
165 for key, value := range privateEnv {
166 env = setEnvValue(env, key, value)
167 }
168 processSandbox.WriteRoots = append(processSandbox.WriteRoots, root, privateRoot)
169 return processSandbox, env, nil
170 }
171
172 var stdioShellPATH = cachedShellPATH(defaultStdioShellPATH)
173
174 // cachedShellPATH memoizes the first completed shell-PATH probe: the user's
175 // interactive PATH is stable for the process, and resolveStdioExecutable now
176 // probes for every stdio plugin, so caching avoids a login shell per server.
177 // The probe runs up to three login shells with a 2s timeout each, so it must
178 // not run under the lock; concurrent spawns share the in-flight probe instead
179 // of each running (or queueing behind) their own. Empty results are cached too
180 // — a host without a usable login shell must not re-probe on every spawn —
181 // except when the probe's context was cancelled, since that empty reflects the
182 // aborted caller rather than the host, and caching it would pin "" for the
183 // rest of the process.
184 func cachedShellPATH(probe func(context.Context) string) func(context.Context) string {
185 var (
186 mu sync.Mutex
187 cached string
188 done bool
189 inflight chan struct{} // non-nil while a probe runs; closed when it settles
190 )
191 return func(ctx context.Context) string {
192 for {
193 mu.Lock()
194 if done {
195 p := cached
196 mu.Unlock()
197 return p
198 }
199 if inflight != nil {
200 wait := inflight
201 mu.Unlock()
202 select {
203 case <-wait:
204 continue // re-check: the probe may not have cached (cancelled)
205 case <-ctx.Done():
206 return ""
207 }
208 }
209 ch := make(chan struct{})
210 inflight = ch
211 mu.Unlock()
212
213 p := probe(ctx)
214
215 mu.Lock()
216 inflight = nil
217 if p != "" || ctx.Err() == nil {
218 cached, done = p, true
219 }
220 mu.Unlock()
221 close(ch)
222 return p
223 }
224 }
225 }
226
227 func resolveStdioExecutable(ctx context.Context, s Spec, env []string) (string, []string, error) {
228 // Unconditionally enrich PATH with the user's shell PATH so every
229 // subprocess—including wrapper scripts that invoke npx, uvx, etc.—
230 // inherits the expected tool locations even under a GUI launch.
231 env = enrichStdioShellPATH(ctx, env)
232
233 if hasPathSeparator(s.Command) {
234 exe := s.Command
235 if !filepath.IsAbs(exe) {
236 if dir := stdioWorkingDir(s); dir != "" {
237 exe = filepath.Join(dir, exe)
238 }
239 abs, err := filepath.Abs(exe)
240 if err != nil {
241 return "", env, fmt.Errorf("stdio plugin %q: resolve command %q: %w", s.Name, s.Command, err)
242 }
243 exe = abs
244 }
245 return exe, env, nil
246 }
247 if exe, ok := lookPathInEnv(s.Command, env); ok {
248 return exe, env, nil
249 }
250
251 currentPath, _ := envValue(env, "PATH")
252 if runtime.GOOS == "windows" {
253 fallbackPath := mergePathLists(windowsStdioFallbackPATH(env), currentPath)
254 if fallbackPath != currentPath {
255 fallbackEnv := setEnvValue(env, "PATH", fallbackPath)
256 if exe, ok := lookPathInEnv(s.Command, fallbackEnv); ok {
257 return exe, fallbackEnv, nil
258 }
259 env = fallbackEnv
260 currentPath = fallbackPath
261 }
262 }
263
264 return "", env, fmt.Errorf("stdio plugin %q: command %q not found on PATH; GUI launches and non-interactive sessions may not inherit your shell PATH. Use an absolute command path or set PATH in the MCP server env. PATH=%q",
265 s.Name, s.Command, currentPath)
266 }
267
268 // stdioWorkingDir keeps WorkspaceRoot's roots/list role separate from process
269 // execution for user-installed servers. Only repository-declared servers need
270 // relative arguments to resolve against the project that supplied the config.
271 func stdioWorkingDir(s Spec) string {
272 if s.Dir != "" {
273 return s.Dir
274 }
275 if s.RequireLaunchApproval {
276 return s.WorkspaceRoot
277 }
278 return ""
279 }
280
281 // enrichStdioShellPATH probes the user's interactive login shell for its PATH
282 // and prepends those directories to the current environment. The result is the
283 // subprocess environment with a PATH that matches what the user sees in their
284 // terminal, even when Reasonix was launched from the Finder / Dock / open(1).
285 func enrichStdioShellPATH(ctx context.Context, env []string) []string {
286 currentPath, _ := envValue(env, "PATH")
287 if shellPath := strings.TrimSpace(stdioShellPATH(ctx)); shellPath != "" {
288 if fallbackPath := mergePathLists(shellPath, currentPath); fallbackPath != currentPath {
289 env = setEnvValue(env, "PATH", fallbackPath)
290 }
291 }
292 return env
293 }
294
295 func hasPathSeparator(s string) bool {
296 return strings.ContainsAny(s, `/\`)
297 }
298
299 func lookPathInEnv(command string, env []string) (string, bool) {
300 path, _ := envValue(env, "PATH")
301 pathext, _ := envValue(env, "PATHEXT")
302 for _, dir := range filepath.SplitList(path) {
303 if dir == "" || !filepath.IsAbs(dir) {
304 continue
305 }
306 for _, name := range executableNames(command, pathext) {
307 candidate := filepath.Join(dir, name)
308 if isExecutableFile(candidate) {
309 return candidate, true
310 }
311 }
312 }
313 return "", false
314 }
315
316 func executableNames(command, pathext string) []string {
317 if runtime.GOOS != "windows" || filepath.Ext(command) != "" {
318 return []string{command}
319 }
320 if strings.TrimSpace(pathext) == "" {
321 pathext = ".COM;.EXE;.BAT;.CMD"
322 }
323 names := []string{command}
324 seen := map[string]bool{strings.ToLower(command): true}
325 for ext := range strings.SplitSeq(pathext, ";") {
326 ext = strings.TrimSpace(ext)
327 if ext == "" {
328 continue
329 }
330 if !strings.HasPrefix(ext, ".") {
331 ext = "." + ext
332 }
333 name := command + ext
334 key := strings.ToLower(name)
335 if !seen[key] {
336 seen[key] = true
337 names = append(names, name)
338 }
339 }
340 return names
341 }
342
343 func isExecutableFile(path string) bool {
344 info, err := os.Stat(path)
345 if err != nil || info.IsDir() {
346 return false
347 }
348 if runtime.GOOS == "windows" {
349 return true
350 }
351 return info.Mode().Perm()&0o111 != 0
352 }
353
354 func windowsStdioFallbackPATH(env []string) string {
355 if runtime.GOOS != "windows" {
356 return ""
357 }
358 programFiles, _ := envValue(env, "ProgramFiles")
359 programFilesX86, _ := envValue(env, "ProgramFiles(x86)")
360 localAppData, _ := envValue(env, "LOCALAPPDATA")
361 appData, _ := envValue(env, "APPDATA")
362 userProfile, _ := envValue(env, "USERPROFILE")
363 chocolatey, _ := envValue(env, "ChocolateyInstall")
364 if localAppData == "" && userProfile != "" {
365 localAppData = filepath.Join(userProfile, "AppData", "Local")
366 }
367 if appData == "" && userProfile != "" {
368 appData = filepath.Join(userProfile, "AppData", "Roaming")
369 }
370 candidates := []string{
371 filepath.Join(programFiles, "nodejs"),
372 filepath.Join(programFilesX86, "nodejs"),
373 filepath.Join(localAppData, "Programs", "nodejs"),
374 filepath.Join(appData, "npm"),
375 filepath.Join(localAppData, "Microsoft", "WindowsApps"),
376 filepath.Join(userProfile, "scoop", "shims"),
377 filepath.Join(userProfile, ".bun", "bin"),
378 filepath.Join(userProfile, ".cargo", "bin"),
379 filepath.Join(chocolatey, "bin"),
380 }
381 var existing []string
382 for _, dir := range candidates {
383 if isDir(dir) {
384 existing = append(existing, dir)
385 }
386 }
387 return strings.Join(existing, string(os.PathListSeparator))
388 }
389
390 func isDir(path string) bool {
391 if path == "" {
392 return false
393 }
394 if !filepath.IsAbs(path) {
395 return false
396 }
397 info, err := os.Stat(path)
398 return err == nil && info.IsDir()
399 }
400
401 func defaultStdioShellPATH(ctx context.Context) string {
402 if runtime.GOOS == "windows" {
403 return ""
404 }
405 shell := stdioShell()
406 if shell == "" {
407 return ""
408 }
409 const marker = "__REASONIX_PATH__="
410 script := "printf '\\n" + marker + "%s\\n' \"$PATH\""
411 for _, args := range [][]string{
412 {"-l", "-i", "-c", script},
413 {"-l", "-c", script},
414 {"-c", script},
415 } {
416 out := runShellPATHCommand(ctx, shell, args)
417 if path := parseShellPATH(out, marker); path != "" {
418 return path
419 }
420 }
421 return ""
422 }
423
424 func stdioShell() string {
425 if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" {
426 if hasPathSeparator(shell) {
427 if isExecutableFile(shell) {
428 return shell
429 }
430 } else if exe, ok := lookPathInEnv(shell, secrets.ProcessEnv()); ok {
431 return exe
432 }
433 }
434 for _, shell := range []string{"/bin/zsh", "/bin/bash", "/bin/sh"} {
435 if isExecutableFile(shell) {
436 return shell
437 }
438 }
439 return ""
440 }
441
442 func runShellPATHCommand(parent context.Context, shell string, args []string) []byte {
443 ctx, cancel := context.WithTimeout(parent, 2*time.Second)
444 defer cancel()
445 cmd := proc.CommandContext(ctx, shell, args...)
446 // Explicit env so the login-shell probe honors [secrets]
447 // filter_subprocess_env instead of inheriting the full environment.
448 cmd.Env = secrets.ProcessEnv()
449 prepareStdioShellPATHProbe(cmd)
450 cmd.Stdin = strings.NewReader("")
451 out, _ := cmd.CombinedOutput()
452 return out
453 }
454
455 func prepareStdioShellPATHProbe(cmd *exec.Cmd) {
456 proc.PrepareShellPATHProbe(cmd)
457 }
458
459 func parseShellPATH(out []byte, marker string) string {
460 lines := strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n")
461 for _, line := range slices.Backward(lines) {
462 if rest, ok := strings.CutPrefix(line, marker); ok {
463 return strings.TrimSpace(rest)
464 }
465 }
466 return ""
467 }
468
469 func mergeEnv(base []string, overrides map[string]string) []string {
470 out := append([]string(nil), base...)
471 for k, v := range overrides {
472 out = setEnvValue(out, k, v)
473 }
474 return out
475 }
476
477 func setEnvValue(env []string, key, value string) []string {
478 out := make([]string, 0, len(env))
479 replaced := false
480 for _, kv := range env {
481 k, _, ok := strings.Cut(kv, "=")
482 if ok && envKeyEqual(k, key) {
483 if !replaced {
484 out = append(out, key+"="+value)
485 replaced = true
486 }
487 continue
488 }
489 out = append(out, kv)
490 }
491 if !replaced {
492 out = append(out, key+"="+value)
493 }
494 return out
495 }
496
497 func envValue(env []string, key string) (string, bool) {
498 for _, entry := range slices.Backward(env) {
499 k, v, ok := strings.Cut(entry, "=")
500 if ok && envKeyEqual(k, key) {
501 return v, true
502 }
503 }
504 return "", false
505 }
506
507 func envKeyEqual(a, b string) bool {
508 if runtime.GOOS == "windows" {
509 return strings.EqualFold(a, b)
510 }
511 return a == b
512 }
513
514 func mergePathLists(primary, secondary string) string {
515 var out []string
516 seen := map[string]bool{}
517 for _, path := range []string{primary, secondary} {
518 for _, dir := range filepath.SplitList(path) {
519 if dir == "" || seen[dir] {
520 continue
521 }
522 seen[dir] = true
523 out = append(out, dir)
524 }
525 }
526 return strings.Join(out, string(os.PathListSeparator))
527 }
528
529 func (t *stdioTransport) startupStderr() string {
530 if t == nil || t.stderr == nil {
531 return ""
532 }
533 return secrets.RedactCredentials(t.stderr.String())
534 }
535
536 func (t *stdioTransport) withStderr(err error) error {
537 if t == nil || t.stderr == nil {
538 return err
539 }
540 waitWithBudget(t.wait, closeWaitBudget)
541 message := secrets.RedactCredentials(t.stderr.String())
542 if message == "" {
543 return err
544 }
545 return fmt.Errorf("%w: stderr: %s", err, message)
546 }
547
548 // wait reaps the child exactly once; cmd.Wait blocks until the stderr-copy
549 // goroutine completes, so the tail buffer is settled before anyone reads it.
550 func (t *stdioTransport) wait() {
551 t.waitOnce.Do(func() {
552 if t.cmd != nil && t.cmd.Process != nil {
553 _ = t.cmd.Wait()
554 }
555 })
556 }
557
558 // waitWithBudget runs wait in a goroutine and returns once it finishes or the
559 // budget elapses, whichever comes first. On timeout the goroutine is left to
560 // complete the reap in the background, so wait must be safe to abandon
561 // (stdioTransport.wait is single-shot via waitOnce).
562 func waitWithBudget(wait func(), budget time.Duration) {
563 _ = waitFinishedWithinBudget(wait, budget)
564 }
565
566 func waitFinishedWithinBudget(wait func(), budget time.Duration) bool {
567 done := make(chan struct{})
568 go func() { wait(); close(done) }()
569 select {
570 case <-done:
571 return true
572 case <-time.After(budget):
573 return false
574 }
575 }
576
577 // close first offers a short stdin-EOF grace period, then kills the whole
578 // process tree if needed (a launcher's surviving grandchild can otherwise keep
579 // inherited pipes open). Both paths are budgeted so one wedged server can never
580 // stall a boot or turn teardown.
581 func (t *stdioTransport) close() {
582 t.closeOnce.Do(func() {
583 if t.releaseSlot != nil {
584 defer t.releaseSlot()
585 }
586 if t.stdin != nil {
587 _ = t.stdin.Close()
588 }
589 if t.stdout != nil {
590 _ = t.stdout.Close()
591 }
592 if t.cmd == nil || t.cmd.Process == nil {
593 return
594 }
595 // Give protocol-aware servers a short chance to observe stdin EOF and
596 // clean up resources they launched outside the process group. Hard-kill
597 // after the bounded grace period so teardown cannot wedge.
598 if waitFinishedWithinBudget(t.wait, gracefulCloseWaitBudget) {
599 proc.FinishTracked(t.job)
600 return
601 }
602 proc.KillTracked(t.cmd, t.job)
603 waitWithBudget(t.wait, closeWaitBudget)
604 })
605 }
606
606 lines GO