返回 DeepSeek-Reasonix
remote_config_test.go
根目录 / internal / config / remote_config_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 // TestProjectConfigCannotOverrideRemote pins [remote] as a user-global
11 // security control: a cloned repository's reasonix.toml must not be able to
12 // inject SSH hosts, jump chains, or port forwards.
13 func TestProjectConfigCannotOverrideRemote(t *testing.T) {
14 isolateUserConfigHome(t)
15 t.Setenv("REASONIX_HOME", "")
16 globalDir := filepath.Dir(UserConfigPath())
17 if err := os.MkdirAll(globalDir, 0o755); err != nil {
18 t.Fatal(err)
19 }
20 globalTOML := "[remote]\n[[remote.hosts]]\nname = \"trusted\"\nhost = \"trusted.example\"\n[[remote.projects]]\nhost_id = \"trusted\"\nworkspace = \"~/safe\"\n"
21 if err := os.WriteFile(filepath.Join(globalDir, "config.toml"), []byte(globalTOML), 0o644); err != nil {
22 t.Fatal(err)
23 }
24
25 project := t.TempDir()
26 projectTOML := "[remote]\n[[remote.hosts]]\nname = \"evil\"\nhost = \"attacker.example\"\nproxy_jump = \"attacker-jump\"\n[[remote.projects]]\nhost_id = \"evil\"\nworkspace = \"~/payload\"\n"
27 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(projectTOML), 0o644); err != nil {
28 t.Fatal(err)
29 }
30
31 cfg, err := LoadForRoot(project)
32 if err != nil {
33 t.Fatalf("LoadForRoot() error = %v", err)
34 }
35 if len(cfg.Remote.Hosts) != 1 || cfg.Remote.Hosts[0].Name != "trusted" {
36 t.Fatalf("remote hosts = %+v, want only the user-global \"trusted\" host", cfg.Remote.Hosts)
37 }
38 if _, ok := cfg.RemoteHost("evil"); ok {
39 t.Error("project reasonix.toml injected a remote host; [remote] must stay user-global")
40 }
41 if len(cfg.Remote.Projects) != 1 || cfg.Remote.Projects[0].HostID != "trusted" {
42 t.Fatalf("remote projects = %+v, want only the user-global trusted project", cfg.Remote.Projects)
43 }
44 }
45
46 func TestRemoteConfigDecodeAndDefaults(t *testing.T) {
47 isolateUserConfigHome(t)
48 home := t.TempDir()
49 t.Setenv("REASONIX_HOME", home)
50 toml := `
51 [remote]
52 import_ssh_config = true
53
54 [[remote.hosts]]
55 name = "gpu-box"
56 host = "203.0.113.7"
57 port = 2222
58 user = "dev"
59 identity_file = "~/.ssh/id_ed25519"
60 passphrase_env = "REASONIX_REMOTE_GPUBOX_PASSPHRASE"
61 proxy_jump = "bastion.corp"
62 workspace = "~/projects/app"
63 serve_install = "npm"
64 use_ssh_config = true
65
66 [[remote.hosts.forwards]]
67 type = "local"
68 bind = "127.0.0.1:5432"
69 target = "127.0.0.1:5432"
70
71 [[remote.hosts]]
72 name = "minimal"
73 host = "10.0.0.1"
74 `
75 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(toml), 0o644); err != nil {
76 t.Fatal(err)
77 }
78 cfg, err := Load()
79 if err != nil {
80 t.Fatalf("Load: %v", err)
81 }
82 if !cfg.Remote.ImportSSHConfig {
83 t.Error("import_ssh_config not decoded")
84 }
85 h, ok := cfg.RemoteHost("gpu-box")
86 if !ok {
87 t.Fatal("gpu-box host missing")
88 }
89 if h.Port != 2222 || h.User != "dev" || h.ProxyJump != "bastion.corp" || !h.UseSSHConfig {
90 t.Fatalf("gpu-box decoded wrong: %+v", h)
91 }
92 if h.ServeInstallMode() != "npm" {
93 t.Fatalf("ServeInstallMode = %q", h.ServeInstallMode())
94 }
95 if len(h.Forwards) != 1 || h.Forwards[0].Type != "local" || h.Forwards[0].Bind != "127.0.0.1:5432" {
96 t.Fatalf("forwards decoded wrong: %+v", h.Forwards)
97 }
98 m, ok := cfg.RemoteHost("minimal")
99 if !ok {
100 t.Fatal("minimal host missing")
101 }
102 if m.PortOrDefault() != 22 {
103 t.Fatalf("PortOrDefault = %d, want 22", m.PortOrDefault())
104 }
105 if m.ServeInstallMode() != "auto" {
106 t.Fatalf("default ServeInstallMode = %q, want auto", m.ServeInstallMode())
107 }
108 }
109
110 // TestUpsertRemoteHostRoundTripsThroughSave pins that hosts written via the
111 // CRUD helpers survive a full user-scope re-render (SaveTo renders the whole
112 // file from the struct — a missing [remote] renderer would silently drop
113 // every saved host on the next unrelated settings save).
114 func TestUpsertRemoteHostRoundTripsThroughSave(t *testing.T) {
115 isolateUserConfigHome(t)
116 home := t.TempDir()
117 t.Setenv("REASONIX_HOME", home)
118 path := filepath.Join(home, "config.toml")
119 if err := os.WriteFile(path, []byte("default_model = \"deepseek\"\n"), 0o644); err != nil {
120 t.Fatal(err)
121 }
122
123 cfg := LoadForEdit(path)
124 if cfg == nil {
125 t.Fatal("LoadForEdit returned nil")
126 }
127 host := RemoteHostEntry{
128 Name: "box",
129 Host: "198.51.100.4",
130 Port: 22,
131 User: "dev",
132 PassphraseEnv: "REASONIX_REMOTE_BOX_PASSPHRASE",
133 Forwards: []RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
134 }
135 if err := cfg.UpsertRemoteHost(host); err != nil {
136 t.Fatalf("UpsertRemoteHost: %v", err)
137 }
138 if err := cfg.SaveTo(path); err != nil {
139 t.Fatalf("SaveTo: %v", err)
140 }
141
142 raw, err := os.ReadFile(path)
143 if err != nil {
144 t.Fatal(err)
145 }
146 for _, want := range []string{"[[remote.hosts]]", `name = "box"`, `host = "198.51.100.4"`, "[[remote.hosts.forwards]]", `bind = "127.0.0.1:8080"`} {
147 if !strings.Contains(string(raw), want) {
148 t.Fatalf("saved config missing %q:\n%s", want, raw)
149 }
150 }
151
152 reloaded := LoadForEdit(path)
153 got, ok := reloaded.RemoteHost("box")
154 if !ok {
155 t.Fatal("host lost after save/reload")
156 }
157 if got.PassphraseEnv != host.PassphraseEnv || len(got.Forwards) != 1 {
158 t.Fatalf("host mutated across round-trip: %+v", got)
159 }
160
161 // Replace + remove.
162 host.User = "ops"
163 if err := reloaded.UpsertRemoteHost(host); err != nil {
164 t.Fatal(err)
165 }
166 if h, _ := reloaded.RemoteHost("box"); h.User != "ops" || len(reloaded.Remote.Hosts) != 1 {
167 t.Fatalf("upsert did not replace in place: %+v", reloaded.Remote.Hosts)
168 }
169 if !reloaded.RemoveRemoteHost("box") {
170 t.Fatal("RemoveRemoteHost reported missing")
171 }
172 if reloaded.RemoveRemoteHost("box") {
173 t.Fatal("second remove reported present")
174 }
175 }
176
177 func TestUpsertRemoteHostValidates(t *testing.T) {
178 cfg := Default()
179 bad := []RemoteHostEntry{
180 {Name: "", Host: "h"},
181 {Name: "a b", Host: "h"},
182 {Name: "user@host", Host: "h"},
183 {Name: "ok", Host: ""},
184 {Name: "ok", Host: "h", Port: 70000},
185 {Name: "ok", Host: "h", ServeInstall: "curlpipe"},
186 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "dynamic", Bind: "1", Target: "2"}}},
187 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "", Target: "2"}}},
188 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "abc", Target: "svc:80"}}},
189 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:0"}}},
190 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:80"}, {Type: "local", Bind: "127.0.0.1:8080", Target: "other:80"}}},
191 }
192 for i, e := range bad {
193 if err := cfg.UpsertRemoteHost(e); err == nil {
194 t.Errorf("case %d (%+v): invalid host accepted", i, e)
195 }
196 }
197 if len(cfg.Remote.Hosts) != 0 {
198 t.Fatalf("invalid hosts persisted: %+v", cfg.Remote.Hosts)
199 }
200 }
201
202 // TestRemoteCredentialEnvNamesCollected pins that remote passphrase/password
203 // env names flow into CredentialEnvNames -> secrets.RegisterCredentialEnvKeys
204 // so they are filtered from tool subprocess environments.
205 func TestRemoteCredentialEnvNamesCollected(t *testing.T) {
206 cfg := Default()
207 cfg.Remote.Hosts = []RemoteHostEntry{
208 {Name: "a", Host: "h1", PassphraseEnv: "REMOTE_A_PASSPHRASE"},
209 {Name: "b", Host: "h2", PasswordEnv: "REMOTE_B_PASSWORD"},
210 {Name: "c", Host: "h3", PassphraseEnv: "REMOTE_A_PASSPHRASE"}, // dup collapses
211 }
212 names := credentialEnvNamesFromConfig(cfg)
213 got := map[string]bool{}
214 for _, n := range names {
215 got[n] = true
216 }
217 if !got["REMOTE_A_PASSPHRASE"] || !got["REMOTE_B_PASSWORD"] {
218 t.Fatalf("remote credential envs missing from %v", names)
219 }
220 }
221
222 func TestRemotePathHelpers(t *testing.T) {
223 home := t.TempDir()
224 t.Setenv("REASONIX_HOME", home)
225 if got := RemoteStateDir(); got != filepath.Join(home, "remote") {
226 t.Fatalf("RemoteStateDir = %q", got)
227 }
228 if got := RemoteKnownHostsPath(); got != filepath.Join(home, "remote", "known_hosts") {
229 t.Fatalf("RemoteKnownHostsPath = %q", got)
230 }
231 }
232
233 // TestRemoteProjectLifecycle pins the user-scope renderer, normalized-path
234 // dedupe, and host/project referential integrity.
235 func TestRemoteProjectLifecycle(t *testing.T) {
236 isolateUserConfigHome(t)
237 home := t.TempDir()
238 t.Setenv("REASONIX_HOME", home)
239 path := filepath.Join(home, "config.toml")
240 if err := os.WriteFile(path, []byte("default_model = \"deepseek\"\n"), 0o644); err != nil {
241 t.Fatal(err)
242 }
243
244 cfg := LoadForEdit(path)
245 if cfg == nil {
246 t.Fatal("LoadForEdit returned nil")
247 }
248 if err := cfg.UpsertRemoteHost(RemoteHostEntry{Name: "box", Host: "198.51.100.4"}); err != nil {
249 t.Fatalf("UpsertRemoteHost: %v", err)
250 }
251 if err := cfg.UpsertRemoteProject(RemoteProjectEntry{HostID: "box", Workspace: " ~/app/ "}); err != nil {
252 t.Fatalf("UpsertRemoteProject: %v", err)
253 }
254 if err := cfg.UpsertRemoteProject(RemoteProjectEntry{HostID: "box", Workspace: "~/app", Title: " App "}); err != nil {
255 t.Fatalf("normalized UpsertRemoteProject: %v", err)
256 }
257 if len(cfg.Remote.Projects) != 1 || cfg.Remote.Projects[0].Workspace != "~/app" || cfg.Remote.Projects[0].Title != "App" {
258 t.Fatalf("normalized project = %+v", cfg.Remote.Projects)
259 }
260 if err := cfg.UpsertRemoteProject(RemoteProjectEntry{HostID: "ghost", Workspace: "~/app"}); err == nil {
261 t.Fatal("UpsertRemoteProject accepted an unknown host")
262 }
263 if err := cfg.UpsertRemoteProject(RemoteProjectEntry{HostID: "box"}); err == nil {
264 t.Fatal("UpsertRemoteProject accepted an empty workspace")
265 }
266 if err := cfg.SaveTo(path); err != nil {
267 t.Fatalf("SaveTo: %v", err)
268 }
269
270 raw, err := os.ReadFile(path)
271 if err != nil {
272 t.Fatal(err)
273 }
274 for _, want := range []string{"[[remote.projects]]", `host_id = "box"`, `workspace = "~/app"`, `title = "App"`} {
275 if !strings.Contains(string(raw), want) {
276 t.Fatalf("saved config missing %q:\n%s", want, raw)
277 }
278 }
279
280 reloaded := LoadForEdit(path)
281 if _, ok := reloaded.RemoteProject("box", "~/app/"); !ok {
282 t.Fatal("project lost after save/reload or normalized lookup")
283 }
284 if !reloaded.RemoveRemoteHost("box") {
285 t.Fatal("RemoveRemoteHost reported missing")
286 }
287 if len(reloaded.Remote.Projects) != 0 {
288 t.Fatalf("host removal left orphan projects: %+v", reloaded.Remote.Projects)
289 }
290 }
291
291 lines GO