返回 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"
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"
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 }
42
43 func TestRemoteConfigDecodeAndDefaults(t *testing.T) {
44 isolateUserConfigHome(t)
45 home := t.TempDir()
46 t.Setenv("REASONIX_HOME", home)
47 toml := `
48 [remote]
49 import_ssh_config = true
50
51 [[remote.hosts]]
52 name = "gpu-box"
53 host = "203.0.113.7"
54 port = 2222
55 user = "dev"
56 identity_file = "~/.ssh/id_ed25519"
57 passphrase_env = "REASONIX_REMOTE_GPUBOX_PASSPHRASE"
58 proxy_jump = "bastion.corp"
59 workspace = "~/projects/app"
60 serve_install = "npm"
61 use_ssh_config = true
62
63 [[remote.hosts.forwards]]
64 type = "local"
65 bind = "127.0.0.1:5432"
66 target = "127.0.0.1:5432"
67
68 [[remote.hosts]]
69 name = "minimal"
70 host = "10.0.0.1"
71 `
72 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(toml), 0o644); err != nil {
73 t.Fatal(err)
74 }
75 cfg, err := Load()
76 if err != nil {
77 t.Fatalf("Load: %v", err)
78 }
79 if !cfg.Remote.ImportSSHConfig {
80 t.Error("import_ssh_config not decoded")
81 }
82 h, ok := cfg.RemoteHost("gpu-box")
83 if !ok {
84 t.Fatal("gpu-box host missing")
85 }
86 if h.Port != 2222 || h.User != "dev" || h.ProxyJump != "bastion.corp" || !h.UseSSHConfig {
87 t.Fatalf("gpu-box decoded wrong: %+v", h)
88 }
89 if h.ServeInstallMode() != "npm" {
90 t.Fatalf("ServeInstallMode = %q", h.ServeInstallMode())
91 }
92 if len(h.Forwards) != 1 || h.Forwards[0].Type != "local" || h.Forwards[0].Bind != "127.0.0.1:5432" {
93 t.Fatalf("forwards decoded wrong: %+v", h.Forwards)
94 }
95 m, ok := cfg.RemoteHost("minimal")
96 if !ok {
97 t.Fatal("minimal host missing")
98 }
99 if m.PortOrDefault() != 22 {
100 t.Fatalf("PortOrDefault = %d, want 22", m.PortOrDefault())
101 }
102 if m.ServeInstallMode() != "auto" {
103 t.Fatalf("default ServeInstallMode = %q, want auto", m.ServeInstallMode())
104 }
105 }
106
107 // TestUpsertRemoteHostRoundTripsThroughSave pins that hosts written via the
108 // CRUD helpers survive a full user-scope re-render (SaveTo renders the whole
109 // file from the struct — a missing [remote] renderer would silently drop
110 // every saved host on the next unrelated settings save).
111 func TestUpsertRemoteHostRoundTripsThroughSave(t *testing.T) {
112 isolateUserConfigHome(t)
113 home := t.TempDir()
114 t.Setenv("REASONIX_HOME", home)
115 path := filepath.Join(home, "config.toml")
116 if err := os.WriteFile(path, []byte("default_model = \"deepseek\"\n"), 0o644); err != nil {
117 t.Fatal(err)
118 }
119
120 cfg := LoadForEdit(path)
121 if cfg == nil {
122 t.Fatal("LoadForEdit returned nil")
123 }
124 host := RemoteHostEntry{
125 Name: "box",
126 Host: "198.51.100.4",
127 Port: 22,
128 User: "dev",
129 PassphraseEnv: "REASONIX_REMOTE_BOX_PASSPHRASE",
130 Forwards: []RemoteForwardEntry{{Type: "local", Bind: "127.0.0.1:8080", Target: "127.0.0.1:80"}},
131 }
132 if err := cfg.UpsertRemoteHost(host); err != nil {
133 t.Fatalf("UpsertRemoteHost: %v", err)
134 }
135 if err := cfg.SaveTo(path); err != nil {
136 t.Fatalf("SaveTo: %v", err)
137 }
138
139 raw, err := os.ReadFile(path)
140 if err != nil {
141 t.Fatal(err)
142 }
143 for _, want := range []string{"[[remote.hosts]]", `name = "box"`, `host = "198.51.100.4"`, "[[remote.hosts.forwards]]", `bind = "127.0.0.1:8080"`} {
144 if !strings.Contains(string(raw), want) {
145 t.Fatalf("saved config missing %q:\n%s", want, raw)
146 }
147 }
148
149 reloaded := LoadForEdit(path)
150 got, ok := reloaded.RemoteHost("box")
151 if !ok {
152 t.Fatal("host lost after save/reload")
153 }
154 if got.PassphraseEnv != host.PassphraseEnv || len(got.Forwards) != 1 {
155 t.Fatalf("host mutated across round-trip: %+v", got)
156 }
157
158 // Replace + remove.
159 host.User = "ops"
160 if err := reloaded.UpsertRemoteHost(host); err != nil {
161 t.Fatal(err)
162 }
163 if h, _ := reloaded.RemoteHost("box"); h.User != "ops" || len(reloaded.Remote.Hosts) != 1 {
164 t.Fatalf("upsert did not replace in place: %+v", reloaded.Remote.Hosts)
165 }
166 if !reloaded.RemoveRemoteHost("box") {
167 t.Fatal("RemoveRemoteHost reported missing")
168 }
169 if reloaded.RemoveRemoteHost("box") {
170 t.Fatal("second remove reported present")
171 }
172 }
173
174 func TestUpsertRemoteHostValidates(t *testing.T) {
175 cfg := Default()
176 bad := []RemoteHostEntry{
177 {Name: "", Host: "h"},
178 {Name: "a b", Host: "h"},
179 {Name: "user@host", Host: "h"},
180 {Name: "ok", Host: ""},
181 {Name: "ok", Host: "h", Port: 70000},
182 {Name: "ok", Host: "h", ServeInstall: "curlpipe"},
183 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "dynamic", Bind: "1", Target: "2"}}},
184 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "", Target: "2"}}},
185 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "abc", Target: "svc:80"}}},
186 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:0"}}},
187 {Name: "ok", Host: "h", Forwards: []RemoteForwardEntry{{Type: "local", Bind: "8080", Target: "svc:80"}, {Type: "local", Bind: "127.0.0.1:8080", Target: "other:80"}}},
188 }
189 for i, e := range bad {
190 if err := cfg.UpsertRemoteHost(e); err == nil {
191 t.Errorf("case %d (%+v): invalid host accepted", i, e)
192 }
193 }
194 if len(cfg.Remote.Hosts) != 0 {
195 t.Fatalf("invalid hosts persisted: %+v", cfg.Remote.Hosts)
196 }
197 }
198
199 // TestRemoteCredentialEnvNamesCollected pins that remote passphrase/password
200 // env names flow into CredentialEnvNames -> secrets.RegisterCredentialEnvKeys
201 // so they are filtered from tool subprocess environments.
202 func TestRemoteCredentialEnvNamesCollected(t *testing.T) {
203 cfg := Default()
204 cfg.Remote.Hosts = []RemoteHostEntry{
205 {Name: "a", Host: "h1", PassphraseEnv: "REMOTE_A_PASSPHRASE"},
206 {Name: "b", Host: "h2", PasswordEnv: "REMOTE_B_PASSWORD"},
207 {Name: "c", Host: "h3", PassphraseEnv: "REMOTE_A_PASSPHRASE"}, // dup collapses
208 }
209 names := credentialEnvNamesFromConfig(cfg)
210 got := map[string]bool{}
211 for _, n := range names {
212 got[n] = true
213 }
214 if !got["REMOTE_A_PASSPHRASE"] || !got["REMOTE_B_PASSWORD"] {
215 t.Fatalf("remote credential envs missing from %v", names)
216 }
217 }
218
219 func TestRemotePathHelpers(t *testing.T) {
220 home := t.TempDir()
221 t.Setenv("REASONIX_HOME", home)
222 if got := RemoteStateDir(); got != filepath.Join(home, "remote") {
223 t.Fatalf("RemoteStateDir = %q", got)
224 }
225 if got := RemoteKnownHostsPath(); got != filepath.Join(home, "remote", "known_hosts") {
226 t.Fatalf("RemoteKnownHostsPath = %q", got)
227 }
228 }
229
229 lines GO