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