| 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 starts a detached serve with shell-quoted operands, 0600 log, |
| 29 | // and file-based port, pid, and auth token state. It uses setsid when present |
| 30 | // and falls back to nohup on stock macOS. Credential-proxy mode selects the |
| 31 | // tunnel-backed provider; its scoped token remains in the remote 0600 .env |
| 32 | // and never appears in this command. A browser broker rides the serve's |
| 33 | // environment only, never argv or the config file. |
| 34 | func LaunchCommand(bin, workspace string, p StatePaths, cred *CredentialProxyOptions, browser *BrowserBrokerOptions) string { |
| 35 | modelFlag := "" |
| 36 | if cred != nil { |
| 37 | modelFlag = " --model " + shellQuote(cred.Provider) |
| 38 | } |
| 39 | return fmt.Sprintf( |
| 40 | "mkdir -p %s && cd %s && rm -f %s %s && umask 077 && : >>%s && chmod 600 %s && "+ |
| 41 | "SX=; command -v setsid >/dev/null 2>&1 && SX=setsid; "+ |
| 42 | "%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 $!", |
| 43 | shellQuote(p.Dir), |
| 44 | shellQuote(workspace), |
| 45 | shellQuote(p.PortFile), |
| 46 | shellQuote(p.PidFile), |
| 47 | shellQuote(p.LogFile), |
| 48 | shellQuote(p.LogFile), |
| 49 | browserEnvPrefix(browser), |
| 50 | shellQuote(bin), |
| 51 | shellQuote(p.TokenFile), |
| 52 | shellQuote(p.PortFile), |
| 53 | shellQuote(p.PidFile), |
| 54 | modelFlag, |
| 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. Each requireArgs |
| 78 | // fragment must additionally appear in the args, in order after the token and |
| 79 | // port files: local-proxy mode requires "--model <proxy provider>" so a serve |
| 80 | // launched under different settings (e.g. before the host switched credential |
| 81 | // modes) is not treated as reusable. |
| 82 | func ServeAliveCommand(pid int, p StatePaths, requireArgs ...string) string { |
| 83 | var decls strings.Builder |
| 84 | fmt.Fprintf(&decls, "T=%s; P=%s; ", shellQuote(p.TokenFile), shellQuote(p.PortFile)) |
| 85 | var pattern strings.Builder |
| 86 | pattern.WriteString("*reasonix*serve*\"$T\"*\"$P\"*") |
| 87 | for i, arg := range requireArgs { |
| 88 | fmt.Fprintf(&decls, "R%d=%s; ", i, shellQuote(arg)) |
| 89 | fmt.Fprintf(&pattern, "\"$R%d\"*", i) |
| 90 | } |
| 91 | return fmt.Sprintf( |
| 92 | "%skill -0 %d 2>/dev/null || { echo 0; exit 0; }; "+ |
| 93 | "A=$(ps -p %d -o args= 2>/dev/null || ps -p %d -o command= 2>/dev/null); "+ |
| 94 | "case \"$A\" in %s) echo 1;; *) echo 0;; esac", |
| 95 | decls.String(), pid, pid, pid, pattern.String(), |
| 96 | ) |
| 97 | } |
| 98 | |
| 99 | // LogsCommand tails n lines of the log file (n<=0 => 200). |
| 100 | func LogsCommand(logFile string, n int) string { |
| 101 | if n <= 0 { |
| 102 | n = 200 |
| 103 | } |
| 104 | return fmt.Sprintf("tail -n %d %s 2>/dev/null || true", n, shellQuote(logFile)) |
| 105 | } |
| 106 | |
| 107 | // servePortFileMarker is what LocateCommand greps for in `serve --help` to |
| 108 | // decide the located binary supports --port-file/--token-file. It must match |
| 109 | // the flag name registered in runServe. |
| 110 | const servePortFileMarker = "port-file" |
| 111 | |
| 112 | // serveSessionEventsMarker gates on the multi-session capability: serves |
| 113 | // advertising --session-events tag SSE frames with sessionPath and keep |
| 114 | // background sessions running across switches. |
| 115 | const serveSessionEventsMarker = "session-events" |
| 116 | |
| 117 | // serveDetachedHealMarker gates on the credential-heal fix: provider reloads |
| 118 | // retire background controllers instead of leaving them on a stale tunnel. |
| 119 | const serveDetachedHealMarker = "detached-heal" |
| 120 | |
| 121 | // ServeCapsToken is the rolling capability revision advertised in serve help. |
| 122 | // Bump this when the desktop requires a newer wire/runtime contract. The CLI |
| 123 | // imports this value so the advertised token cannot drift from the probe. |
| 124 | const ServeCapsToken = "reasonix-serve-caps-20260826a" |
| 125 | |
| 126 | // LocateCommand probes for a usable reasonix binary and the exact Serve |
| 127 | // capabilities required by the desktop. Capability probes are authoritative: |
| 128 | // an old binary can have an otherwise acceptable product version. |
| 129 | func LocateCommand(uploadedBin string) string { |
| 130 | return locateCommand(uploadedBin, false) |
| 131 | } |
| 132 | |
| 133 | // LocateUploadedCommand probes exactly the freshly written managed binary. |
| 134 | // A stale PATH candidate must not shadow an upload performed to repair missing |
| 135 | // Serve capabilities. |
| 136 | func LocateUploadedCommand(uploadedBin string) string { |
| 137 | return locateCommand(uploadedBin, true) |
| 138 | } |
| 139 | |
| 140 | // LocateNPMGlobalCommand probes exactly the binary installed under npm's |
| 141 | // current global prefix. A stale login-PATH binary must not shadow a package |
| 142 | // that was just installed to repair missing Serve capabilities. |
| 143 | func LocateNPMGlobalCommand() string { |
| 144 | resolve := "BIN=; P=\"$(npm prefix -g 2>/dev/null)\"; if [ -n \"$P\" ] && [ -x \"$P/bin/reasonix\" ]; then BIN=\"$P/bin/reasonix\"; fi; " |
| 145 | return locateResolvedCommand(resolve) |
| 146 | } |
| 147 | |
| 148 | func locateCommand(uploadedBin string, preferUploaded bool) string { |
| 149 | resolve := fmt.Sprintf( |
| 150 | "BIN=\"$(command -v reasonix 2>/dev/null)\"; if [ -z \"$BIN\" ] && [ -x %s ]; then BIN=%s; fi; ", |
| 151 | shellQuote(uploadedBin), shellQuote(uploadedBin), |
| 152 | ) |
| 153 | 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; " |
| 154 | if preferUploaded { |
| 155 | resolve = fmt.Sprintf("BIN=; if [ -x %s ]; then BIN=%s; fi; ", shellQuote(uploadedBin), shellQuote(uploadedBin)) |
| 156 | fallback = "" |
| 157 | } |
| 158 | return locateResolvedCommand(resolve + fallback) |
| 159 | } |
| 160 | |
| 161 | func locateResolvedCommand(resolve string) string { |
| 162 | return fmt.Sprintf( |
| 163 | resolve+ |
| 164 | "echo \"$BIN\"; "+ |
| 165 | "if [ -n \"$BIN\" ]; then \"$BIN\" --version 2>/dev/null; "+ |
| 166 | "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo portfile:yes; else echo portfile:no; fi; "+ |
| 167 | "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo sessionevents:yes; else echo sessionevents:no; fi; "+ |
| 168 | "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo detachedheal:yes; else echo detachedheal:no; fi; "+ |
| 169 | "if \"$BIN\" serve --help 2>&1 | grep -q -- %s; then echo caps:yes; else echo caps:no; fi; fi", |
| 170 | shellQuote(servePortFileMarker), shellQuote(serveSessionEventsMarker), shellQuote(serveDetachedHealMarker), shellQuote(ServeCapsToken), |
| 171 | ) |
| 172 | } |
| 173 | |
| 174 | // SupportsRequiredServeCapabilitiesCommand probes the executable backing a |
| 175 | // running Serve on Linux, where /proc exposes the still-mapped executable even |
| 176 | // after its pathname is replaced. Platforms without that live-image handle |
| 177 | // fail closed and rely on the capability token recorded at managed launch. |
| 178 | func SupportsRequiredServeCapabilitiesCommand(pid int) string { |
| 179 | return fmt.Sprintf( |
| 180 | "BIN=$(readlink /proc/%d/exe 2>/dev/null); "+ |
| 181 | "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", |
| 182 | pid, shellQuote(serveSessionEventsMarker), shellQuote(serveDetachedHealMarker), shellQuote(ServeCapsToken), |
| 183 | ) |
| 184 | } |
| 185 |