| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/remote" |
| 11 | ) |
| 12 | |
| 13 | func credProxyOpts(baseURL string) *CredentialProxyOptions { |
| 14 | return &CredentialProxyOptions{ |
| 15 | BaseURL: baseURL, |
| 16 | Token: "virtual-token-123", |
| 17 | Provider: "reasonix-desktop-proxy", |
| 18 | Model: "deepseek-v4-flash", |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // TestEnsureCredentialProviderAppendsAndIsIdempotent covers install and healing. |
| 23 | func TestEnsureCredentialProviderAppendsAndIsIdempotent(t *testing.T) { |
| 24 | skipOnWindows(t) |
| 25 | root := t.TempDir() |
| 26 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 27 | fs, err := conn.SFTP() |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | ctx := context.Background() |
| 32 | |
| 33 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 34 | t.Fatalf("first install: %v", err) |
| 35 | } |
| 36 | first, rerr := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 37 | if rerr != nil { |
| 38 | t.Fatal(rerr) |
| 39 | } |
| 40 | for _, want := range []string{ |
| 41 | `[[providers]]`, |
| 42 | `name = "reasonix-desktop-proxy"`, |
| 43 | `base_url = "http://127.0.0.1:18999"`, |
| 44 | `api_key_env = "REASONIX_PROXY_TOKEN"`, |
| 45 | `model = "deepseek-v4-flash"`, |
| 46 | } { |
| 47 | if !strings.Contains(string(first), want) { |
| 48 | t.Fatalf("config missing %q:\n%s", want, first) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Idempotent: same options ⇒ no rewrite. |
| 53 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 54 | t.Fatalf("second install: %v", err) |
| 55 | } |
| 56 | second, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 57 | if string(first) != string(second) { |
| 58 | t.Fatalf("idempotent run rewrote the config:\n%s\n---\n%s", first, second) |
| 59 | } |
| 60 | |
| 61 | // A base_url change (tunnel port moved) rewrites only that assignment. |
| 62 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:19000")); err != nil { |
| 63 | t.Fatalf("port change: %v", err) |
| 64 | } |
| 65 | third, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 66 | if !strings.Contains(string(third), `base_url = "http://127.0.0.1:19000"`) { |
| 67 | t.Fatalf("port change not applied:\n%s", third) |
| 68 | } |
| 69 | if strings.Count(string(third), "[[providers]]") != 1 { |
| 70 | t.Fatalf("port change duplicated the block:\n%s", third) |
| 71 | } |
| 72 | other := credProxyOpts("http://127.0.0.1:19000") |
| 73 | other.Provider, other.TokenEnv, other.Token = "reasonix-desktop-proxy-other", "REASONIX_PROXY_TOKEN_OTHER", "other-token" |
| 74 | if _, err := ensureCredentialProvider(ctx, fs, root, other); err != nil { |
| 75 | t.Fatalf("second workspace: %v", err) |
| 76 | } |
| 77 | cfg, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 78 | env, _ := os.ReadFile(filepath.Join(root, ".reasonix", ".env")) |
| 79 | if !strings.Contains(string(cfg), `name = "reasonix-desktop-proxy-other"`) || !strings.Contains(string(env), "REASONIX_PROXY_TOKEN=virtual-token-123") || !strings.Contains(string(env), "REASONIX_PROXY_TOKEN_OTHER=other-token") { |
| 80 | t.Fatalf("workspace credentials collided:\nconfig=%s\nenv=%s", cfg, env) |
| 81 | } |
| 82 | healed := credProxyOpts("http://127.0.0.1:20000") |
| 83 | if _, err := ensureCredentialProvider(ctx, fs, root, healed); err != nil { |
| 84 | t.Fatalf("multi-workspace port heal: %v", err) |
| 85 | } |
| 86 | cfg, _ = os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 87 | if got := strings.Count(string(cfg), `base_url = "http://127.0.0.1:20000"`); got != 2 { |
| 88 | t.Fatalf("managed workspace providers did not heal together (got %d):\n%s", got, cfg) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // TestEnsureCredentialProviderPreservesUserConfig keeps existing content. |
| 93 | func TestEnsureCredentialProviderPreservesUserConfig(t *testing.T) { |
| 94 | skipOnWindows(t) |
| 95 | root := t.TempDir() |
| 96 | if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | existing := "default_model = \"deepseek/deepseek-v4-flash\"\n\n[[providers]]\nname = \"mine\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\napi_key_env = \"MY_KEY\"\n" |
| 100 | if err := os.WriteFile(filepath.Join(root, ".reasonix", "config.toml"), []byte(existing), 0o600); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 104 | fs, err := conn.SFTP() |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if _, err := ensureCredentialProvider(context.Background(), fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | got, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 112 | if !strings.HasPrefix(string(got), existing) { |
| 113 | t.Fatalf("user config prefix disturbed:\n%s", got) |
| 114 | } |
| 115 | if strings.Count(string(got), "[[providers]]") != 2 { |
| 116 | t.Fatalf("expected two provider blocks:\n%s", got) |
| 117 | } |
| 118 | if idx := providerBlockIndex(string(got), "mine"); idx < 0 { |
| 119 | t.Fatalf("user provider block lost:\n%s", got) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // TestLaunchCommandCredentialInjection keeps tokens out of the shell command. |
| 124 | func TestLaunchCommandCredentialInjection(t *testing.T) { |
| 125 | paths := StatePaths{Dir: "/d", TokenFile: "/d/t", PortFile: "/d/p", PidFile: "/d/i", LogFile: "/d/l"} |
| 126 | cmd := LaunchCommand("/usr/bin/reasonix", "/ws", paths, &CredentialProxyOptions{ |
| 127 | BaseURL: "http://127.0.0.1:18999", Token: "to'ken $x", Provider: "reasonix-desktop-proxy", Model: "m", |
| 128 | }, nil) |
| 129 | for _, want := range []string{ |
| 130 | `--model 'reasonix-desktop-proxy'`, |
| 131 | `nohup '/usr/bin/reasonix' serve`, |
| 132 | } { |
| 133 | if !strings.Contains(cmd, want) { |
| 134 | t.Errorf("LaunchCommand missing %q:\n%s", want, cmd) |
| 135 | } |
| 136 | } |
| 137 | if strings.Contains(cmd, "to'ken") || strings.Contains(cmd, TokenEnvName) { |
| 138 | t.Errorf("virtual token leaked into launch command:\n%s", cmd) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // TestEnsureCredentialProviderMaterializesBuiltinDefault keeps default_model valid. |
| 143 | func TestEnsureCredentialProviderMaterializesBuiltinDefault(t *testing.T) { |
| 144 | skipOnWindows(t) |
| 145 | root := t.TempDir() |
| 146 | if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | cfg := "config_version = 6\n\ndefault_model = \"deepseek-flash\" # user's choice stays\n\n[ui]\ntheme = \"dark\"\n" |
| 150 | if err := os.WriteFile(filepath.Join(root, ".reasonix", "config.toml"), []byte(cfg), 0o600); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 154 | fs, err := conn.SFTP() |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | ctx := context.Background() |
| 159 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | first, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 163 | got := string(first) |
| 164 | for _, want := range []string{ |
| 165 | `default_model = "deepseek-flash" # user's choice stays`, |
| 166 | `name = "deepseek-flash"`, |
| 167 | `api_key_env = "DEEPSEEK_API_KEY"`, |
| 168 | `name = "reasonix-desktop-proxy"`, |
| 169 | } { |
| 170 | if !strings.Contains(got, want) { |
| 171 | t.Fatalf("config missing %q:\n%s", want, got) |
| 172 | } |
| 173 | } |
| 174 | if n := strings.Count(got, "[[providers]]"); n != 2 { |
| 175 | t.Fatalf("provider blocks = %d, want 2 (materialized + ours):\n%s", n, got) |
| 176 | } |
| 177 | // The materialized entry must come BEFORE ours so file order reads |
| 178 | // user-default then desktop-proxy. |
| 179 | if strings.Index(got, `name = "deepseek-flash"`) > strings.Index(got, `name = "reasonix-desktop-proxy"`) { |
| 180 | t.Fatalf("materialized entry should precede ours:\n%s", got) |
| 181 | } |
| 182 | |
| 183 | // Idempotent. |
| 184 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | second, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 188 | if string(first) != string(second) { |
| 189 | t.Fatalf("second run rewrote the config:\n%s\n---\n%s", first, second) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // TestMaterializeDefaultProviderSkipsNonBuiltin leaves user-owned gaps alone. |
| 194 | func TestMaterializeDefaultProviderSkipsNonBuiltin(t *testing.T) { |
| 195 | before := "default_model = \"custom/pro-model\"\n" |
| 196 | after := materializeDefaultProvider(before) |
| 197 | if before != after { |
| 198 | t.Fatalf("non-builtin default was rewritten:\n%s", after) |
| 199 | } |
| 200 | if got := defaultModelProvider("default_model = \"deepseek/deepseek-v4-flash\"\n"); got != "deepseek" { |
| 201 | t.Fatalf("provider extraction = %q, want deepseek", got) |
| 202 | } |
| 203 | if got := defaultModelProvider("[ui]\ndefault_model = \"deepseek-flash\"\n"); got != "" { |
| 204 | t.Fatalf("table-scoped default_model leaked: %q", got) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // TestEnsureCredentialProviderRewritesKindDrift heals provider kind changes. |
| 209 | func TestEnsureCredentialProviderRewritesKindDrift(t *testing.T) { |
| 210 | skipOnWindows(t) |
| 211 | root := t.TempDir() |
| 212 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 213 | fs, err := conn.SFTP() |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | ctx := context.Background() |
| 218 | |
| 219 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | installed, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 223 | if !strings.Contains(string(installed), "kind = \"openai\"") { |
| 224 | t.Fatalf("default install missing the openai kind:\n%s", installed) |
| 225 | } |
| 226 | |
| 227 | switched := credProxyOpts("http://127.0.0.1:18999") |
| 228 | switched.Kind = "anthropic" |
| 229 | if _, err := ensureCredentialProvider(ctx, fs, root, switched); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | rewritten, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 233 | if !strings.Contains(string(rewritten), "kind = \"anthropic\"") || strings.Contains(string(rewritten), "kind = \"openai\"") { |
| 234 | t.Fatalf("kind drift not rewritten:\n%s", rewritten) |
| 235 | } |
| 236 | if !strings.Contains(string(rewritten), "base_url = \"http://127.0.0.1:18999\"") { |
| 237 | t.Fatalf("base_url lost in the kind rewrite:\n%s", rewritten) |
| 238 | } |
| 239 | |
| 240 | // Idempotent once the kind matches. |
| 241 | if _, err := ensureCredentialProvider(ctx, fs, root, switched); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | again, _ := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 245 | if string(again) != string(rewritten) { |
| 246 | t.Fatalf("matching re-run rewrote the config:\n%s\n---\n%s", rewritten, again) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | func TestEnsureCredentialProviderPersistsLateBuiltinMaterialization(t *testing.T) { |
| 251 | skipOnWindows(t) |
| 252 | root := t.TempDir() |
| 253 | configDir := filepath.Join(root, ".reasonix") |
| 254 | if err := os.MkdirAll(configDir, 0o755); err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | // Simulate an older desktop: its proxy block is current, but it never |
| 258 | // materialized the builtin selected by default_model. |
| 259 | legacy := "default_model = \"deepseek-flash\"\n" + credentialProviderBlock(credProxyOpts("http://127.0.0.1:18999")) |
| 260 | if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(legacy), 0o600); err != nil { |
| 261 | t.Fatal(err) |
| 262 | } |
| 263 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 264 | fs, err := conn.SFTP() |
| 265 | if err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | changed, err := ensureCredentialProvider(context.Background(), fs, root, credProxyOpts("http://127.0.0.1:18999")) |
| 269 | if err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if !changed { |
| 273 | t.Fatal("late builtin materialization was not reported as a config change") |
| 274 | } |
| 275 | got, err := os.ReadFile(filepath.Join(configDir, "config.toml")) |
| 276 | if err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | if !strings.Contains(string(got), `name = "deepseek-flash"`) { |
| 280 | t.Fatalf("materialized builtin was not persisted:\n%s", got) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | func TestEnsureCredentialProviderRewritesModelDrift(t *testing.T) { |
| 285 | skipOnWindows(t) |
| 286 | root := t.TempDir() |
| 287 | conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) |
| 288 | fs, err := conn.SFTP() |
| 289 | if err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | ctx := context.Background() |
| 293 | if _, err := ensureCredentialProvider(ctx, fs, root, credProxyOpts("http://127.0.0.1:18999")); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | switched := credProxyOpts("http://127.0.0.1:18999") |
| 297 | switched.Model = "deepseek-v4-pro" |
| 298 | if _, err := ensureCredentialProvider(ctx, fs, root, switched); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | got, err := os.ReadFile(filepath.Join(root, ".reasonix", "config.toml")) |
| 302 | if err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | if !strings.Contains(string(got), `model = "deepseek-v4-pro"`) || strings.Contains(string(got), `model = "deepseek-v4-flash"`) { |
| 306 | t.Fatalf("model drift not rewritten:\n%s", got) |
| 307 | } |
| 308 | } |
| 309 |