返回 DeepSeek-Reasonix
bootstrap_pure_test.go
根目录 / internal / remote / bootstrap / bootstrap_pure_test.go
1 package bootstrap
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func TestParseUname(t *testing.T) {
9 cases := []struct {
10 in string
11 goos, goarch string
12 wantErr bool
13 }{
14 {"Linux x86_64", "linux", "amd64", false},
15 {"Linux aarch64", "linux", "arm64", false},
16 {"Darwin arm64", "darwin", "arm64", false},
17 {"Darwin x86_64", "darwin", "amd64", false},
18 {"Linux armv7l", "linux", "arm", false},
19 {" Linux x86_64 \n", "linux", "amd64", false},
20 {"MINGW64_NT-10.0 x86_64", "", "", true}, // Windows shell
21 {"Linux mips", "", "", true},
22 {"garbage", "", "", true},
23 }
24 for _, c := range cases {
25 goos, goarch, err := ParseUname(c.in)
26 if c.wantErr {
27 if err == nil {
28 t.Errorf("ParseUname(%q): expected error", c.in)
29 }
30 continue
31 }
32 if err != nil || goos != c.goos || goarch != c.goarch {
33 t.Errorf("ParseUname(%q) = (%q,%q,%v), want (%q,%q)", c.in, goos, goarch, err, c.goos, c.goarch)
34 }
35 }
36 }
37
38 func TestParseVersion(t *testing.T) {
39 cases := map[string]string{
40 "reasonix v1.9.0": "1.9.0",
41 "1.9.0": "1.9.0",
42 "reasonix version 2.0.1": "2.0.1",
43 "v1.10.0-rc.1": "1.10.0-rc.1",
44 }
45 for in, want := range cases {
46 got, err := ParseVersion(in)
47 if err != nil || got != want {
48 t.Errorf("ParseVersion(%q) = (%q,%v), want %q", in, got, err, want)
49 }
50 }
51 if _, err := ParseVersion("no version here"); err == nil {
52 t.Error("expected error for versionless output")
53 }
54 }
55
56 func TestCompareVersions(t *testing.T) {
57 cases := []struct {
58 a, b string
59 want int
60 }{
61 {"1.9.0", "1.9.0", 0},
62 {"1.9.0", "1.10.0", -1},
63 {"1.10.0", "1.9.0", 1},
64 {"2.0.0", "1.99.99", 1},
65 {"1.9", "1.9.0", 0},
66 {"1.9.1-rc.1", "1.9.1", 0}, // pre-release ignored for ordering
67 }
68 for _, c := range cases {
69 if got := CompareVersions(c.a, c.b); got != c.want {
70 t.Errorf("CompareVersions(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
71 }
72 }
73 }
74
75 // TestLaunchCommandQuotesHostilePaths is the security golden: a workspace or
76 // log path containing shell metacharacters must be fully single-quoted so it
77 // cannot break out of the launch command.
78 func TestLaunchCommandQuotesHostilePaths(t *testing.T) {
79 paths := StatePaths{
80 Dir: "/home/dev/.reasonix/remote",
81 TokenFile: "/home/dev/.reasonix/remote/serve-x.token",
82 PortFile: "/home/dev/.reasonix/remote/serve-x.port",
83 PidFile: "/home/dev/.reasonix/remote/serve-x.pid",
84 LogFile: "/home/dev/.reasonix/remote/serve-x.log",
85 }
86 hostile := "/tmp/'; rm -rf ~; echo '"
87 cmd := LaunchCommand("/usr/bin/reasonix", hostile, paths, nil, nil)
88
89 // The hostile workspace must appear only inside a quoted operand, escaped.
90 if strings.Contains(cmd, "; rm -rf ~; echo") && !strings.Contains(cmd, `'\''; rm -rf ~; echo '\''`) {
91 t.Fatalf("hostile workspace not properly escaped:\n%s", cmd)
92 }
93 // No unescaped `rm -rf` sequence that would execute.
94 if strings.Contains(cmd, "cd /tmp/'; rm -rf") {
95 t.Fatalf("workspace broke out of quoting:\n%s", cmd)
96 }
97 // Sanity: the essential flags are present.
98 for _, want := range []string{"--addr 127.0.0.1:0", "--auth token", "--token-file", "--port-file", "$SX nohup", "echo $!"} {
99 if !strings.Contains(cmd, want) {
100 t.Errorf("launch command missing %q:\n%s", want, cmd)
101 }
102 }
103 }
104
105 func TestShellQuote(t *testing.T) {
106 cases := map[string]string{
107 "simple": "'simple'",
108 "has space": "'has space'",
109 "a'b": `'a'\''b'`,
110 "'; rm -rf ~": `''\''; rm -rf ~'`,
111 }
112 for in, want := range cases {
113 if got := shellQuote(in); got != want {
114 t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
115 }
116 }
117 }
118
119 func TestStopAndServeAliveCommands(t *testing.T) {
120 paths := StatePaths{TokenFile: "/state/ws.token", PortFile: "/state/ws.port"}
121 stop := StopCommand(4321, paths)
122 for _, want := range []string{"kill -TERM 4321", "kill -0 4321", "kill -KILL 4321"} {
123 if !strings.Contains(stop, want) {
124 t.Errorf("StopCommand missing %q: %s", want, stop)
125 }
126 }
127 alive := ServeAliveCommand(99, paths)
128 // Must check liveness AND that the process is a reasonix serve (guards PID
129 // reuse), not just kill -0.
130 for _, want := range []string{"kill -0 99", "ps -p 99", "*reasonix*serve*", paths.TokenFile, paths.PortFile} {
131 if !strings.Contains(alive, want) {
132 t.Errorf("ServeAliveCommand missing %q: %s", want, alive)
133 }
134 }
135 if strings.Count(stop, "ours") < 3 {
136 t.Fatalf("StopCommand must revalidate ownership during TERM/KILL wait: %s", stop)
137 }
138 withModel := ServeAliveCommand(99, paths, "--model reasonix-desktop-proxy")
139 for _, want := range []string{`R0='--model reasonix-desktop-proxy'`, `"$R0"*`} {
140 if !strings.Contains(withModel, want) {
141 t.Errorf("ServeAliveCommand(requireArgs) missing %q: %s", want, withModel)
142 }
143 }
144 if strings.Contains(alive, "R0=") {
145 t.Errorf("plain ServeAliveCommand must not grow require-arg vars: %s", alive)
146 }
147 }
148
149 func TestLaunchCommandDetachAndLogHardening(t *testing.T) {
150 cmd := LaunchCommand("/usr/bin/reasonix", "/ws", StatePaths{
151 Dir: "/d", TokenFile: "/d/t", PortFile: "/d/p", PidFile: "/d/i", LogFile: "/d/l",
152 }, nil, nil)
153 // setsid must be optional (macOS lacks it) and the log created 0600 so the
154 // serve token line (already suppressed under --port-file) can't leak.
155 for _, want := range []string{"command -v setsid", "$SX nohup", "chmod 600", "umask 077", "--port-file"} {
156 if !strings.Contains(cmd, want) {
157 t.Errorf("LaunchCommand missing %q:\n%s", want, cmd)
158 }
159 }
160 if !strings.Contains(cmd, "rm -f '/d/p' '/d/i'") {
161 t.Fatalf("LaunchCommand does not clear stale port/pid files before launch:\n%s", cmd)
162 }
163 if strings.Contains(cmd, "setsid nohup") {
164 t.Errorf("setsid must be conditional, not hard-wired:\n%s", cmd)
165 }
166 }
167
168 func TestLocateCommandProbesRequiredServeCapabilities(t *testing.T) {
169 cmd := LocateCommand("/home/x/.reasonix/remote/bin/reasonix")
170 for _, want := range []string{"serve --help", "port-file", "session-events", "detached-heal", ServeCapsToken, "portfile:yes", "sessionevents:yes", "detachedheal:yes", "caps:yes"} {
171 if !strings.Contains(cmd, want) {
172 t.Errorf("LocateCommand missing %q:\n%s", want, cmd)
173 }
174 }
175 }
176
177 func TestLocateUploadedCommandBypassesPathCandidates(t *testing.T) {
178 uploaded := "/home/x/.reasonix/remote/bin/reasonix"
179 cmd := LocateUploadedCommand(uploaded)
180 if !strings.Contains(cmd, "BIN='"+uploaded+"'") || strings.Contains(cmd, "command -v reasonix") || strings.Contains(cmd, "npm prefix") {
181 t.Fatalf("uploaded probe did not target only the managed binary:\n%s", cmd)
182 }
183 }
184
185 func TestLocateNPMGlobalCommandBypassesPathCandidates(t *testing.T) {
186 cmd := LocateNPMGlobalCommand()
187 if !strings.Contains(cmd, `P="$(npm prefix -g 2>/dev/null)"`) ||
188 !strings.Contains(cmd, `BIN="$P/bin/reasonix"`) ||
189 strings.Contains(cmd, "command -v reasonix") {
190 t.Fatalf("npm-global probe did not target only npm's installed binary:\n%s", cmd)
191 }
192 }
193
194 func TestSupportsRequiredServeCapabilitiesCommand(t *testing.T) {
195 cmd := SupportsRequiredServeCapabilitiesCommand(42)
196 for _, want := range []string{"/proc/42/exe", "session-events", "detached-heal", ServeCapsToken} {
197 if !strings.Contains(cmd, want) {
198 t.Errorf("capability probe missing %q:\n%s", want, cmd)
199 }
200 }
201 if strings.Contains(cmd, "ps -p 42") {
202 t.Fatalf("capability probe must not execute a replaced pathname reported by ps:\n%s", cmd)
203 }
204 }
205
206 func TestTomlAssignmentString(t *testing.T) {
207 cases := []struct {
208 line string
209 key string
210 value string
211 ok bool
212 }{
213 {`name = "proxy-1"`, "name", "proxy-1", true},
214 {`name="proxy-1"`, "name", "proxy-1", true},
215 {`name = "proxy-1"`, "name", "proxy-1", true},
216 {`name = "proxy-1"`, "name", "proxy-1", true},
217 {`name = "proxy-1" # aligned by a config normalizer`, "name", "proxy-1", true},
218 {`name = "proxy-2"`, "name", "proxy-2", true},
219 {`names = "proxy-1"`, "name", "proxy-1", false},
220 {`name = proxy-1`, "name", "proxy-1", false},
221 {`base_url = "http://127.0.0.1:41333"`, "base_url", "http://127.0.0.1:41333", true},
222 {`kind = "openai"`, "kind", "openai", true},
223 {`model = "deepseek-v4-flash"`, "model", "deepseek-v4-flash", true},
224 {`max_output_tokens = 131072`, "model", "131072", false},
225 }
226 for _, c := range cases {
227 got, ok := tomlAssignmentString(c.line, c.key)
228 if ok != c.ok || (ok && got != c.value) {
229 t.Errorf("tomlAssignmentString(%q, %q) = %q, %v; want %q, %v", c.line, c.key, got, ok, c.value, c.ok)
230 }
231 }
232 }
233
234 // staleAlignedProxyConfig reproduces a remote config observed in the wild: an
235 // older heal installed the provider with aligned assignments and no marker
236 // comment, a later heal failed to find that block (exact-match parser) and
237 // appended a duplicate, and the loader resolved the name to the stale first
238 // block — leaving the serve dialing a dead tunnel port.
239 const staleAlignedProxyConfig = `# Reasonix configuration.
240 default_model = "deepseek/deepseek-v4-flash"
241
242 [[providers]]
243 name = "reasonix-desktop-proxy-bc965691ed1e10b8"
244 kind = "openai"
245 base_url = "http://127.0.0.1:46407"
246 model = "deepseek-v4-pro"
247 api_key_env = "REASONIX_PROXY_TOKEN_BC965691ED1E10B8"
248
249 [[providers]]
250 # managed by the Reasonix desktop credential proxy — safe to delete
251 name = "reasonix-desktop-proxy-bc965691ed1e10b8"
252 kind = "openai"
253 base_url = "http://127.0.0.1:41333"
254 model = "deepseek-v4-pro"
255 api_key_env = "REASONIX_PROXY_TOKEN_BC965691ED1E10B8"
256
257 [[providers]]
258 name = "mine"
259 kind = "openai"
260 base_url = "https://api.deepseek.com"
261 api_key_env = "MY_KEY"
262 `
263
264 func TestProviderBlockIndexFindsAlignedBlock(t *testing.T) {
265 if idx := providerBlockIndex(staleAlignedProxyConfig, "reasonix-desktop-proxy-bc965691ed1e10b8"); idx < 0 {
266 t.Fatal("aligned provider block not found")
267 }
268 if idx := providerBlockIndex(staleAlignedProxyConfig, "mine"); idx < 0 {
269 t.Fatal("user provider block not found")
270 }
271 if idx := providerBlockIndex(staleAlignedProxyConfig, "absent"); idx >= 0 {
272 t.Fatalf("absent provider reported at %d", idx)
273 }
274 }
275
276 func TestDropDuplicateProviderBlocks(t *testing.T) {
277 deduped, changed := dropDuplicateProviderBlocks(staleAlignedProxyConfig, "reasonix-desktop-proxy-bc965691ed1e10b8")
278 if !changed {
279 t.Fatal("duplicate blocks were not dropped")
280 }
281 if got := strings.Count(deduped, `"reasonix-desktop-proxy-bc965691ed1e10b8"`); got != 1 {
282 t.Fatalf("want exactly one provider block, got %d:\n%s", got, deduped)
283 }
284 if !strings.Contains(deduped, `name = "mine"`) {
285 t.Fatalf("unrelated provider block lost:\n%s", deduped)
286 }
287 if unchanged, changed := dropDuplicateProviderBlocks(deduped, "reasonix-desktop-proxy-bc965691ed1e10b8"); changed {
288 t.Fatalf("second dedup rewrote an already-clean config:\n%s", unchanged)
289 }
290 }
291
292 func TestRewriteManagedProviderBaseURLsHealsAlignedBlocks(t *testing.T) {
293 rewritten, changed := rewriteManagedProviderBaseURLs(staleAlignedProxyConfig, "http://127.0.0.1:41333")
294 if !changed {
295 t.Fatal("stale aligned base_url was not rewritten")
296 }
297 if got := strings.Count(rewritten, `base_url = "http://127.0.0.1:41333"`); got != 2 {
298 t.Fatalf("both managed blocks must follow the tunnel port, got %d:\n%s", got, rewritten)
299 }
300 if !strings.Contains(rewritten, `base_url = "https://api.deepseek.com"`) {
301 t.Fatalf("user provider base_url was rewritten:\n%s", rewritten)
302 }
303 }
304
304 lines GO