| 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 |