返回 DeepSeek-Reasonix
launch.go
根目录 / internal / remote / bootstrap / launch.go
1 package bootstrap
2
3 import (
4 "fmt"
5 "strings"
6 )
7
8 // StatePaths are the absolute remote-side paths for one workspace's serve
9 // state. All are under ~/.reasonix/remote.
10 type StatePaths struct {
11 Dir string // ~/.reasonix/remote
12 StateJSON string
13 TokenFile string
14 LogFile string
15 PortFile string
16 PidFile string
17 LockDir string
18 LockOwner string
19 }
20
21 // shellQuote wraps s in single quotes safe for POSIX sh, escaping embedded
22 // single quotes as '\”. This is the only quoting used for remote command
23 // operands; every interpolated path/workspace passes through it.
24 func shellQuote(s string) string {
25 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
26 }
27
28 // LaunchCommand builds the `sh -c` script that starts a detached serve in
29 // workspace, writing the port/pid files and appending output to the log. The
30 // binary path and every operand are single-quote-escaped so hostile paths
31 // (spaces, quotes, `; rm -rf ~`) cannot break out.
32 //
33 // Detachment: `setsid` fully divorces the process from any session, but it is
34 // absent on stock macOS, so it is used only when present (`$SX`); `nohup` +
35 // backgrounding + `</dev/null` is sufficient over a non-interactive SSH exec.
36 // The log is created 0600 (umask 077 + explicit chmod) so a same-machine user
37 // cannot read serve output; serve is launched with `--port-file`, which
38 // suppresses its token share line, so the token never reaches the log.
39 // It echoes the shell's $! so the caller can record the pid immediately.
40 func LaunchCommand(bin, workspace string, p StatePaths) string {
41 return fmt.Sprintf(
42 "mkdir -p %s && cd %s && rm -f %s %s && umask 077 && : >>%s && chmod 600 %s && "+
43 "SX=; command -v setsid >/dev/null 2>&1 && SX=setsid; "+
44 "$SX nohup %s serve --addr 127.0.0.1:0 --auth token --token-file %s --port-file %s --pid-file %s </dev/null >>%s 2>&1 & echo $!",
45 shellQuote(p.Dir),
46 shellQuote(workspace),
47 shellQuote(p.PortFile),
48 shellQuote(p.PidFile),
49 shellQuote(p.LogFile),
50 shellQuote(p.LogFile),
51 shellQuote(bin),
52 shellQuote(p.TokenFile),
53 shellQuote(p.PortFile),
54 shellQuote(p.PidFile),
55 shellQuote(p.LogFile),
56 )
57 }
58
59 // StopCommand builds a script that TERMs the pid, waits up to ~5s, then KILLs
60 // if still alive. pid is validated numeric by the caller, and the caller has
61 // already confirmed (ServeAliveCommand) that the pid is our serve, so PID reuse
62 // cannot cause an unrelated process to be signalled.
63 func StopCommand(pid int, p StatePaths) string {
64 return fmt.Sprintf(
65 "T=%s; P=%s; ours() { A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
66 "case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) return 0;; *) return 1;; esac; }; "+
67 "ours || exit 0; kill -TERM %d 2>/dev/null; "+
68 "for i in 1 2 3 4 5; do kill -0 %d 2>/dev/null || exit 0; ours || exit 0; sleep 1; done; "+
69 "ours && kill -KILL %d 2>/dev/null; exit 0",
70 shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid, pid, pid,
71 )
72 }
73
74 // ServeAliveCommand prints "1" only when pid is running AND its command line
75 // looks like a reasonix serve process. Checking the args (not just `kill -0`)
76 // prevents a recycled PID — now owned by an unrelated process — from being
77 // mistaken for the serve and later signalled by StopCommand.
78 func ServeAliveCommand(pid int, p StatePaths) string {
79 return fmt.Sprintf(
80 "T=%s; P=%s; kill -0 %d 2>/dev/null || { echo 0; exit 0; }; "+
81 "A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
82 "case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) echo 1;; *) echo 0;; esac",
83 shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid,
84 )
85 }
86
87 // LogsCommand tails n lines of the log file (n<=0 => 200).
88 func LogsCommand(logFile string, n int) string {
89 if n <= 0 {
90 n = 200
91 }
92 return fmt.Sprintf("tail -n %d %s 2>/dev/null || true", n, shellQuote(logFile))
93 }
94
95 // servePortFileMarker is what LocateCommand greps for in `serve --help` to
96 // decide the located binary supports --port-file/--token-file. It must match
97 // the flag name registered in runServe.
98 const servePortFileMarker = "port-file"
99
100 // LocateCommand probes for a usable reasonix binary. It prints three lines:
101 // the resolved path (or empty), the `--version` output, and "portfile:yes" when
102 // `serve --help` advertises the --port-file flag. The bootstrap gates on the
103 // flag, not the version number, because --port-file/--token-file ship in this
104 // change: a version gate cannot know its own future release number, and any
105 // already-released binary would pass a numeric gate yet still lack the flags.
106 func LocateCommand(uploadedBin string) string {
107 return fmt.Sprintf(
108 "BIN=\"$(command -v reasonix 2>/dev/null)\"; "+
109 "if [ -z \"$BIN\" ] && [ -x %s ]; then BIN=%s; fi; "+
110 "if [ -z \"$BIN\" ]; then P=\"$(npm prefix -g 2>/dev/null)\"; if [ -n \"$P\" ] && [ -x \"$P/bin/reasonix\" ]; then BIN=\"$P/bin/reasonix\"; fi; fi; "+
111 "echo \"$BIN\"; "+
112 "if [ -n \"$BIN\" ]; then \"$BIN\" --version 2>/dev/null; "+
113 "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo portfile:yes; else echo portfile:no; fi; fi",
114 shellQuote(uploadedBin), shellQuote(uploadedBin), shellQuote(servePortFileMarker),
115 )
116 }
117
117 lines GO