| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | fileencoding "reasonix/internal/fileutil/encoding" |
| 11 | ) |
| 12 | |
| 13 | func TestDefaultSystemPromptStaysLean(t *testing.T) { |
| 14 | if len(DefaultSystemPrompt) > 240 { |
| 15 | t.Fatalf("default system prompt grew to %d bytes; keep workflows in mode contracts and tool descriptions", len(DefaultSystemPrompt)) |
| 16 | } |
| 17 | for _, duplicate := range []string{"todo_write", "plan mode", "verify with tools", "acceptance criteria"} { |
| 18 | if strings.Contains(strings.ToLower(DefaultSystemPrompt), duplicate) { |
| 19 | t.Fatalf("default system prompt duplicates %q workflow guidance: %q", duplicate, DefaultSystemPrompt) |
| 20 | } |
| 21 | } |
| 22 | for _, want := range []string{"Reasonix", "available tools", "focused", "concise"} { |
| 23 | if !strings.Contains(DefaultSystemPrompt, want) { |
| 24 | t.Fatalf("default system prompt missing %q: %q", want, DefaultSystemPrompt) |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func TestResolveSystemPromptForRootRelativePath(t *testing.T) { |
| 30 | root := t.TempDir() |
| 31 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 32 | if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | if err := os.WriteFile(filepath.Join(root, "prompts", "session.md"), []byte(" project session prompt \n"), 0o644); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | t.Chdir(t.TempDir()) |
| 39 | |
| 40 | cfg := Default() |
| 41 | cfg.Agent.SystemPromptFile = filepath.Join("prompts", "session.md") |
| 42 | |
| 43 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 44 | if err != nil { |
| 45 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 46 | } |
| 47 | if got != "project session prompt" { |
| 48 | t.Fatalf("system prompt = %q, want %q", got, "project session prompt") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestResolveSystemPromptForRootAbsolutePath(t *testing.T) { |
| 53 | root := t.TempDir() |
| 54 | path := filepath.Join(root, "session.md") |
| 55 | if err := os.WriteFile(path, []byte(" absolute session prompt \n"), 0o644); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | t.Chdir(t.TempDir()) |
| 59 | |
| 60 | cfg := Default() |
| 61 | cfg.Agent.SystemPromptFile = path |
| 62 | |
| 63 | got, err := cfg.ResolveSystemPromptForRoot(t.TempDir()) |
| 64 | if err != nil { |
| 65 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 66 | } |
| 67 | if got != "absolute session prompt" { |
| 68 | t.Fatalf("system prompt = %q, want %q", got, "absolute session prompt") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestResolveSystemPromptForRootMissingFile(t *testing.T) { |
| 73 | home := t.TempDir() |
| 74 | root := t.TempDir() |
| 75 | t.Setenv("REASONIX_HOME", home) |
| 76 | |
| 77 | cfg := Default() |
| 78 | cfg.Agent.SystemPromptFile = "prompts/does-not-exist.md" |
| 79 | |
| 80 | _, err := cfg.ResolveSystemPromptForRoot(root) |
| 81 | if err == nil { |
| 82 | t.Fatal("expected error for missing system_prompt_file") |
| 83 | } |
| 84 | if !strings.Contains(err.Error(), "not found at any configured location") { |
| 85 | t.Fatalf("error %q missing friendly not-found message", err) |
| 86 | } |
| 87 | // All probed locations must be listed so users can see where it looked. |
| 88 | for _, tried := range []string{filepath.Join(root, "prompts", "does-not-exist.md"), filepath.Join(home, "prompts", "does-not-exist.md")} { |
| 89 | if !strings.Contains(err.Error(), tried) { |
| 90 | t.Fatalf("error %q does not list tried path %q", err, tried) |
| 91 | } |
| 92 | } |
| 93 | if got := cfg.InlineSystemPrompt(); got != DefaultSystemPrompt { |
| 94 | t.Fatalf("InlineSystemPrompt fallback = %q, want DefaultSystemPrompt", got) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestResolveSystemPromptProjectCannotReadReasonixHome(t *testing.T) { |
| 99 | home := t.TempDir() |
| 100 | root := t.TempDir() |
| 101 | t.Setenv("REASONIX_HOME", home) |
| 102 | secret := "OTHER_PROVIDER_SECRET=must-not-enter-prompt" |
| 103 | if err := os.WriteFile(filepath.Join(home, ".env"), []byte(secret), 0o600); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \".env\"\n"), 0o600); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | |
| 110 | cfg, err := LoadForRootReadOnly(root) |
| 111 | if err != nil { |
| 112 | t.Fatalf("LoadForRootReadOnly: %v", err) |
| 113 | } |
| 114 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 115 | if err == nil { |
| 116 | t.Fatalf("ResolveSystemPromptForRoot = %q, want missing workspace file error", got) |
| 117 | } |
| 118 | if !IsMissingSystemPromptFile(err) { |
| 119 | t.Fatalf("error = %v, want missing prompt classification", err) |
| 120 | } |
| 121 | if strings.Contains(err.Error(), filepath.Join(home, ".env")) || strings.Contains(got, secret) { |
| 122 | t.Fatalf("project prompt resolution probed Reasonix credentials: got=%q err=%v", got, err) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestMergeFileSnapshotKeepsProjectPromptSourceAtomic(t *testing.T) { |
| 127 | home := t.TempDir() |
| 128 | root := t.TempDir() |
| 129 | t.Setenv("REASONIX_HOME", home) |
| 130 | const secret = "PROVIDER_SECRET=must-not-enter-system-prompt" |
| 131 | if err := os.WriteFile(filepath.Join(home, ".env"), []byte(secret), 0o600); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | projectConfig := filepath.Join(root, "reasonix.toml") |
| 135 | if err := os.WriteFile(projectConfig, []byte("[agent]\nsystem_prompt_file = \".env\"\n"), 0o600); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | |
| 139 | cfg := Default() |
| 140 | reads := 0 |
| 141 | meta, err := mergeFileSnapshotWithRead(cfg, projectConfig, func(path string) ([]byte, error) { |
| 142 | reads++ |
| 143 | data, err := fileencoding.ReadFileUTF8(path) |
| 144 | if err != nil { |
| 145 | return nil, err |
| 146 | } |
| 147 | // Simulate an atomic config replacement after the read. The merged value |
| 148 | // and its provenance must still come from data, not from a second read. |
| 149 | if err := os.WriteFile(path, []byte("[agent]\nsystem_prompt = \"replacement\"\n"), 0o600); err != nil { |
| 150 | return nil, err |
| 151 | } |
| 152 | return data, nil |
| 153 | }) |
| 154 | if err != nil { |
| 155 | t.Fatalf("mergeFileSnapshotWithRead: %v", err) |
| 156 | } |
| 157 | if reads != 1 { |
| 158 | t.Fatalf("config reads = %d, want exactly one", reads) |
| 159 | } |
| 160 | if !meta.IsDefined("agent", "system_prompt_file") { |
| 161 | t.Fatal("snapshot metadata lost project system_prompt_file") |
| 162 | } |
| 163 | cfg.systemPromptFileSource = promptFileSourceProject |
| 164 | |
| 165 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 166 | if err == nil || !IsMissingSystemPromptFile(err) { |
| 167 | t.Fatalf("ResolveSystemPromptForRoot = %q, %v; want project-scoped missing error", got, err) |
| 168 | } |
| 169 | if strings.Contains(got, secret) || strings.Contains(err.Error(), filepath.Join(home, ".env")) { |
| 170 | t.Fatalf("project prompt resolution probed Reasonix credentials: got=%q err=%v", got, err) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestResolveSystemPromptUserConfigCanFallBackToReasonixHome(t *testing.T) { |
| 175 | home := t.TempDir() |
| 176 | root := t.TempDir() |
| 177 | t.Setenv("REASONIX_HOME", home) |
| 178 | if err := os.MkdirAll(filepath.Join(home, "prompts"), 0o755); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte("trusted home prompt"), 0o600); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | |
| 188 | cfg, err := LoadForRootReadOnly(root) |
| 189 | if err != nil { |
| 190 | t.Fatalf("LoadForRootReadOnly: %v", err) |
| 191 | } |
| 192 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 193 | if err != nil { |
| 194 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 195 | } |
| 196 | if got != "trusted home prompt" { |
| 197 | t.Fatalf("system prompt = %q, want trusted home prompt", got) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestResolveSystemPromptProjectPathStaysInWorkspace(t *testing.T) { |
| 202 | home := t.TempDir() |
| 203 | root := t.TempDir() |
| 204 | t.Setenv("REASONIX_HOME", home) |
| 205 | for _, dir := range []string{filepath.Join(home, "prompts"), filepath.Join(root, "prompts")} { |
| 206 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | } |
| 210 | if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte("home prompt"), 0o600); err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if err := os.WriteFile(filepath.Join(root, "prompts", "system.md"), []byte("workspace prompt"), 0o600); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | |
| 223 | cfg, err := LoadForRootReadOnly(root) |
| 224 | if err != nil { |
| 225 | t.Fatalf("LoadForRootReadOnly: %v", err) |
| 226 | } |
| 227 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 228 | if err != nil { |
| 229 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 230 | } |
| 231 | if got != "workspace prompt" { |
| 232 | t.Fatalf("system prompt = %q, want workspace prompt", got) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestResolveSystemPromptRejectsProjectPathEscapes(t *testing.T) { |
| 237 | home := t.TempDir() |
| 238 | t.Setenv("REASONIX_HOME", home) |
| 239 | for _, test := range []struct { |
| 240 | name string |
| 241 | path func(root string) string |
| 242 | }{ |
| 243 | {name: "parent", path: func(string) string { return filepath.Join("..", "outside.md") }}, |
| 244 | {name: "absolute", path: func(root string) string { return filepath.Join(filepath.Dir(root), "outside.md") }}, |
| 245 | } { |
| 246 | t.Run(test.name, func(t *testing.T) { |
| 247 | root := t.TempDir() |
| 248 | outside := test.path(root) |
| 249 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(fmt.Sprintf("[agent]\nsystem_prompt_file = %q\n", outside)), 0o600); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | cfg, err := LoadForRootReadOnly(root) |
| 253 | if err != nil { |
| 254 | t.Fatalf("LoadForRootReadOnly: %v", err) |
| 255 | } |
| 256 | if _, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) || !strings.Contains(err.Error(), "relative path within the workspace") { |
| 257 | t.Fatalf("ResolveSystemPromptForRoot error = %v, want fatal containment error", err) |
| 258 | } |
| 259 | }) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestResolveSystemPromptRejectsProjectSymlinkEscape(t *testing.T) { |
| 264 | home := t.TempDir() |
| 265 | root := t.TempDir() |
| 266 | outside := filepath.Join(t.TempDir(), "outside.md") |
| 267 | t.Setenv("REASONIX_HOME", home) |
| 268 | if err := os.WriteFile(outside, []byte("outside secret"), 0o600); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | if err := os.Symlink(outside, filepath.Join(root, "prompts", "system.md")); err != nil { |
| 275 | t.Skipf("symlink unavailable: %v", err) |
| 276 | } |
| 277 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil { |
| 278 | t.Fatal(err) |
| 279 | } |
| 280 | cfg, err := LoadForRootReadOnly(root) |
| 281 | if err != nil { |
| 282 | t.Fatalf("LoadForRootReadOnly: %v", err) |
| 283 | } |
| 284 | if got, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) || strings.Contains(got, "outside secret") { |
| 285 | t.Fatalf("ResolveSystemPromptForRoot = %q, %v; want fatal symlink containment error", got, err) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | func TestResolveSystemPromptMixedReadErrorsAreNotMissing(t *testing.T) { |
| 290 | home := t.TempDir() |
| 291 | root := t.TempDir() |
| 292 | t.Setenv("REASONIX_HOME", home) |
| 293 | if err := os.MkdirAll(filepath.Join(home, "prompts", "system.md"), 0o755); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | cfg := Default() |
| 297 | cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md") |
| 298 | if _, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) { |
| 299 | t.Fatalf("ResolveSystemPromptForRoot error = %v, want non-missing read failure", err) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestResolveSystemPromptForRootFallsBackToReasonixHome(t *testing.T) { |
| 304 | home := t.TempDir() |
| 305 | t.Setenv("REASONIX_HOME", home) |
| 306 | if err := os.MkdirAll(filepath.Join(home, "prompts"), 0o755); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte(" home prompt \n"), 0o644); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | |
| 313 | cfg := Default() |
| 314 | cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md") |
| 315 | |
| 316 | // Workspace root has no such file; the Reasonix-home copy must win the probe. |
| 317 | got, err := cfg.ResolveSystemPromptForRoot(t.TempDir()) |
| 318 | if err != nil { |
| 319 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 320 | } |
| 321 | if got != "home prompt" { |
| 322 | t.Fatalf("system prompt = %q, want %q", got, "home prompt") |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func TestResolveSystemPromptForRootWorkspaceWins(t *testing.T) { |
| 327 | home := t.TempDir() |
| 328 | root := t.TempDir() |
| 329 | t.Setenv("REASONIX_HOME", home) |
| 330 | for dir, content := range map[string]string{home: "home prompt", root: "workspace prompt"} { |
| 331 | if err := os.MkdirAll(filepath.Join(dir, "prompts"), 0o755); err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | if err := os.WriteFile(filepath.Join(dir, "prompts", "system.md"), []byte(content), 0o644); err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | cfg := Default() |
| 340 | cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md") |
| 341 | |
| 342 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 343 | if err != nil { |
| 344 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 345 | } |
| 346 | if got != "workspace prompt" { |
| 347 | t.Fatalf("system prompt = %q, want workspace copy to win, got %q", got, "workspace prompt") |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | func TestResolveSystemPromptForRootDecodesGB18030(t *testing.T) { |
| 352 | root := t.TempDir() |
| 353 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 354 | if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil { |
| 355 | t.Fatal(err) |
| 356 | } |
| 357 | path := filepath.Join(root, "prompts", "session.md") |
| 358 | if err := os.WriteFile(path, fileencoding.Encode(" 请始终使用中文回答。 \n", fileencoding.GB18030), 0o644); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | |
| 362 | cfg := Default() |
| 363 | cfg.Agent.SystemPromptFile = filepath.Join("prompts", "session.md") |
| 364 | |
| 365 | got, err := cfg.ResolveSystemPromptForRoot(root) |
| 366 | if err != nil { |
| 367 | t.Fatalf("ResolveSystemPromptForRoot: %v", err) |
| 368 | } |
| 369 | if got != "请始终使用中文回答。" { |
| 370 | t.Fatalf("system prompt = %q, want decoded Chinese prompt", got) |
| 371 | } |
| 372 | } |
| 373 |