返回 DeepSeek-Reasonix
launch.go
根目录 / internal / remote / bootstrap / launch.go
1 package bootstrap
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/remote"
8 )
9
10 // StatePaths are the absolute remote-side paths for one workspace's serve
11 // state. All are under ~/.reasonix/remote.
12 type StatePaths struct {
13 Dir string // ~/.reasonix/remote
14 StateJSON string
15 TokenFile string
16 LogFile string
17 PortFile string
18 PidFile string
19 LockDir string
20 LockOwner string
21 }
22
23 // shellQuote wraps s in single quotes safe for POSIX sh, escaping embedded
24 // single quotes as '\”. This is the only quoting used for remote command
25 // operands; every interpolated path/workspace passes through it.
26 func shellQuote(s string) string {
27 return remote.ShellQuote(s)
28 }
29
30 // LaunchCommand starts a detached serve with shell-quoted operands, 0600 log,
31 // and file-based port, pid, and auth token state. It uses setsid when present
32 // and falls back to nohup on stock macOS. Credential-proxy mode selects the
33 // tunnel-backed provider; its scoped token remains in the remote 0600 .env
34 // and never appears in this command. A browser broker rides the serve's
35 // environment only, never argv or the config file.
36 func LaunchCommand(bin, workspace string, p StatePaths, cred *CredentialProxyOptions, browser *BrowserBrokerOptions) string {
37 modelFlag := ""
38 if cred != nil {
39 modelFlag = " --model " + shellQuote(cred.Provider)
40 }
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 "%s$SX nohup %s serve --addr 127.0.0.1:0 --auth token --token-file %s --port-file %s --pid-file %s%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 browserEnvPrefix(browser),
52 shellQuote(bin),
53 shellQuote(p.TokenFile),
54 shellQuote(p.PortFile),
55 shellQuote(p.PidFile),
56 modelFlag,
57 shellQuote(p.LogFile),
58 )
59 }
60
61 // StopCommand builds a script that TERMs the pid, waits up to ~5s, then KILLs
62 // if still alive. pid is validated numeric by the caller, and the caller has
63 // already confirmed (ServeAliveCommand) that the pid is our serve, so PID reuse
64 // cannot cause an unrelated process to be signalled.
65 func StopCommand(pid int, p StatePaths) string {
66 return fmt.Sprintf(
67 "T=%s; P=%s; ours() { A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
68 "case \"$A\" in *reasonix*serve*\"$T\"*\"$P\"*) return 0;; *) return 1;; esac; }; "+
69 "ours || exit 0; kill -TERM %d 2>/dev/null; "+
70 "for i in 1 2 3 4 5; do kill -0 %d 2>/dev/null || exit 0; ours || exit 0; sleep 1; done; "+
71 "ours && kill -KILL %d 2>/dev/null; exit 0",
72 shellQuote(p.TokenFile), shellQuote(p.PortFile), pid, pid, pid, pid, pid,
73 )
74 }
75
76 // ServeAliveCommand prints "1" only when pid is running AND its command line
77 // looks like a reasonix serve process. Checking the args (not just `kill -0`)
78 // prevents a recycled PID — now owned by an unrelated process — from being
79 // mistaken for the serve and later signalled by StopCommand. Each requireArgs
80 // fragment must additionally appear in the args, in order after the token and
81 // port files: local-proxy mode requires "--model <proxy provider>" so a serve
82 // launched under different settings (e.g. before the host switched credential
83 // modes) is not treated as reusable.
84 func ServeAliveCommand(pid int, p StatePaths, requireArgs ...string) string {
85 var decls strings.Builder
86 fmt.Fprintf(&decls, "T=%s; P=%s; ", shellQuote(p.TokenFile), shellQuote(p.PortFile))
87 var pattern strings.Builder
88 pattern.WriteString("*reasonix*serve*\"$T\"*\"$P\"*")
89 for i, arg := range requireArgs {
90 fmt.Fprintf(&decls, "R%d=%s; ", i, shellQuote(arg))
91 fmt.Fprintf(&pattern, "\"$R%d\"*", i)
92 }
93 return fmt.Sprintf(
94 "%skill -0 %d 2>/dev/null || { echo 0; exit 0; }; "+
95 "A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+
96 "case \"$A\" in %s) echo 1;; *) echo 0;; esac",
97 decls.String(), pid, pid, pid, pattern.String(),
98 )
99 }
100
101 // LogsCommand tails n lines of the log file (n<=0 => 200).
102 func LogsCommand(logFile string, n int) string {
103 if n <= 0 {
104 n = 200
105 }
106 return fmt.Sprintf("tail -n %d %s 2>/dev/null || true", n, shellQuote(logFile))
107 }
108
109 // servePortFileMarker is what LocateCommand greps for in `serve --help` to
110 // decide the located binary supports --port-file/--token-file. It must match
111 // the flag name registered in runServe.
112 const servePortFileMarker = "port-file"
113
114 // serveSessionEventsMarker gates on the multi-session capability: serves
115 // advertising --session-events tag SSE frames with sessionPath and keep
116 // background sessions running across switches.
117 const serveSessionEventsMarker = "session-events"
118
119 // serveDetachedHealMarker gates on the credential-heal fix: provider reloads
120 // retire background controllers instead of leaving them on a stale tunnel.
121 const serveDetachedHealMarker = "detached-heal"
122
123 // ServeCapsToken is the rolling capability revision advertised in serve help.
124 // Bump this when the desktop requires a newer wire/runtime contract. The CLI
125 // imports this value so the advertised token cannot drift from the probe.
126 const ServeCapsToken = "reasonix-serve-caps-20260928a"
127
128 // LocateCommand probes for a usable reasonix binary and the exact Serve
129 // capabilities required by the desktop. Capability probes are authoritative:
130 // an old binary can have an otherwise acceptable product version.
131 func LocateCommand(uploadedBin string) string {
132 return locateCommand(uploadedBin, false)
133 }
134
135 // LocateUploadedCommand probes exactly the freshly written managed binary.
136 // A stale PATH candidate must not shadow an upload performed to repair missing
137 // Serve capabilities.
138 func LocateUploadedCommand(uploadedBin string) string {
139 return locateCommand(uploadedBin, true)
140 }
141
142 // LocateNPMGlobalCommand probes exactly the binary installed under npm's
143 // current global prefix. A stale login-PATH binary must not shadow a package
144 // that was just installed to repair missing Serve capabilities.
145 func LocateNPMGlobalCommand() string {
146 resolve := "BIN=; P=\"$(npm prefix -g 2>/dev/null)\"; if [ -n \"$P\" ] && [ -x \"$P/bin/reasonix\" ]; then BIN=\"$P/bin/reasonix\"; fi; "
147 return locateResolvedCommand(resolve)
148 }
149
150 func locateCommand(uploadedBin string, preferUploaded bool) string {
151 resolve := fmt.Sprintf(
152 "BIN=\"$(command -v reasonix 2>/dev/null)\"; if [ -z \"$BIN\" ] && [ -x %s ]; then BIN=%s; fi; ",
153 shellQuote(uploadedBin), shellQuote(uploadedBin),
154 )
155 fallback := "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; "
156 if preferUploaded {
157 resolve = fmt.Sprintf("BIN=; if [ -x %s ]; then BIN=%s; fi; ", shellQuote(uploadedBin), shellQuote(uploadedBin))
158 fallback = ""
159 }
160 return locateResolvedCommand(resolve + fallback)
161 }
162
163 func locateResolvedCommand(resolve string) string {
164 return fmt.Sprintf(
165 resolve+
166 "echo \"$BIN\"; "+
167 "if [ -n \"$BIN\" ]; then \"$BIN\" --version 2>/dev/null; "+
168 "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo portfile:yes; else echo portfile:no; fi; "+
169 "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo sessionevents:yes; else echo sessionevents:no; fi; "+
170 "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo detachedheal:yes; else echo detachedheal:no; fi; "+
171 "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo caps:yes; else echo caps:no; fi; fi",
172 shellQuote(servePortFileMarker), shellQuote(serveSessionEventsMarker), shellQuote(serveDetachedHealMarker), shellQuote(ServeCapsToken),
173 )
174 }
175
176 // SupportsRequiredServeCapabilitiesCommand probes the executable backing a
177 // running Serve on Linux, where /proc exposes the still-mapped executable even
178 // after its pathname is replaced. Platforms without that live-image handle
179 // fail closed and rely on the capability token recorded at managed launch.
180 func SupportsRequiredServeCapabilitiesCommand(pid int) string {
181 return fmt.Sprintf(
182 "BIN=$(readlink /proc/%d/exe 2>/dev/null); "+
183 "if [ -n \"$BIN\" ] && [ -x \"$BIN\" ] && \"$BIN\" serve --help 2>&1 | grep -q -- %s && \"$BIN\" serve --help 2>&1 | grep -q -- %s && \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo yes; else echo no; fi",
184 pid, shellQuote(serveSessionEventsMarker), shellQuote(serveDetachedHealMarker), shellQuote(ServeCapsToken),
185 )
186 }
187
187 lines GO