server_test.go
1 package daemon 2 3 import ( 4 "bufio" 5 "bytes" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "net/http" 12 "net/http/httptest" 13 "os" 14 "path/filepath" 15 "strconv" 16 "strings" 17 "testing" 18 "time" 19 20 "github.com/Kocoro-lab/ShanClaw/internal/agents" 21 "github.com/Kocoro-lab/ShanClaw/internal/config" 22 "github.com/Kocoro-lab/ShanClaw/internal/mcp" 23 "github.com/Kocoro-lab/ShanClaw/internal/skills" 24 "github.com/spf13/viper" 25 "gopkg.in/yaml.v3" 26 ) 27 28 func writeTestGlobalSkill(t *testing.T, shannonDir, name string) { 29 t.Helper() 30 if err := skills.WriteGlobalSkill(shannonDir, &skills.Skill{ 31 Name: name, 32 Description: name + " description", 33 Prompt: "prompt for " + name, 34 }); err != nil { 35 t.Fatalf("write global skill %s: %v", name, err) 36 } 37 } 38 39 func TestServer_GlobalSkillStickyRoundTrip(t *testing.T) { 40 shannonDir := t.TempDir() 41 if err := skills.WriteGlobalSkill(shannonDir, &skills.Skill{ 42 Name: "policy", 43 Description: "policy description", 44 Prompt: "# policy\n\nUse the API.", 45 License: "MIT", 46 StickyInstructions: true, 47 StickySnippetOverride: "Use the http tool for platform operations.", 48 }); err != nil { 49 t.Fatalf("seed global skill: %v", err) 50 } 51 52 deps := &ServerDeps{ShannonDir: shannonDir} 53 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 54 srv := NewServer(0, c, deps, "test") 55 ctx, cancel := context.WithCancel(context.Background()) 56 defer cancel() 57 58 go srv.Start(ctx) 59 time.Sleep(100 * time.Millisecond) 60 61 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/skills/policy", srv.Port())) 62 if err != nil { 63 t.Fatal(err) 64 } 65 defer resp.Body.Close() 66 67 if resp.StatusCode != http.StatusOK { 68 t.Fatalf("GET /skills/policy status = %d", resp.StatusCode) 69 } 70 var detail struct { 71 Name string `json:"name"` 72 StickyInstructions bool `json:"sticky_instructions"` 73 StickySnippet string `json:"sticky_snippet"` 74 } 75 if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { 76 t.Fatalf("decode GET body: %v", err) 77 } 78 if detail.Name != "policy" { 79 t.Fatalf("GET returned name %q", detail.Name) 80 } 81 if !detail.StickyInstructions { 82 t.Fatal("GET dropped sticky_instructions") 83 } 84 if detail.StickySnippet != "Use the http tool for platform operations." { 85 t.Fatalf("GET sticky_snippet = %q", detail.StickySnippet) 86 } 87 88 reqBody := `{"description":"updated description","prompt":"# policy\n\nUpdated.","license":"Apache-2.0"}` 89 req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("http://127.0.0.1:%d/skills/policy", srv.Port()), strings.NewReader(reqBody)) 90 if err != nil { 91 t.Fatal(err) 92 } 93 req.Header.Set("Content-Type", "application/json") 94 resp2, err := http.DefaultClient.Do(req) 95 if err != nil { 96 t.Fatal(err) 97 } 98 defer resp2.Body.Close() 99 if resp2.StatusCode != http.StatusOK { 100 body, _ := io.ReadAll(resp2.Body) 101 t.Fatalf("PUT /skills/policy status = %d body=%s", resp2.StatusCode, string(body)) 102 } 103 104 loaded, err := skills.LoadSkills(skills.SkillSource{ 105 Dir: filepath.Join(shannonDir, "skills"), 106 Source: skills.SourceGlobal, 107 }) 108 if err != nil { 109 t.Fatalf("reload skills: %v", err) 110 } 111 var policy *skills.Skill 112 for _, skill := range loaded { 113 if skill.Name == "policy" { 114 policy = skill 115 break 116 } 117 } 118 if policy == nil { 119 t.Fatal("reloaded skill not found") 120 } 121 if !policy.StickyInstructions { 122 t.Fatal("PUT dropped sticky instructions") 123 } 124 if policy.StickySnippetOverride != "Use the http tool for platform operations." { 125 t.Fatalf("PUT dropped sticky snippet override: %q", policy.StickySnippetOverride) 126 } 127 if policy.License != "Apache-2.0" { 128 t.Fatalf("license not updated: %q", policy.License) 129 } 130 } 131 132 func TestServer_Health(t *testing.T) { 133 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 134 srv := NewServer(0, c, nil, "test") 135 ctx, cancel := context.WithCancel(context.Background()) 136 defer cancel() 137 138 go srv.Start(ctx) 139 time.Sleep(100 * time.Millisecond) 140 141 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())) 142 if err != nil { 143 t.Fatal(err) 144 } 145 defer resp.Body.Close() 146 147 if resp.StatusCode != http.StatusOK { 148 t.Errorf("status = %d", resp.StatusCode) 149 } 150 var body map[string]string 151 json.NewDecoder(resp.Body).Decode(&body) 152 if body["status"] != "ok" { 153 t.Errorf("body = %v", body) 154 } 155 if body["version"] != "test" { 156 t.Errorf("version = %q, want %q", body["version"], "test") 157 } 158 } 159 160 func TestServer_Status(t *testing.T) { 161 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 162 srv := NewServer(0, c, nil, "test") 163 ctx, cancel := context.WithCancel(context.Background()) 164 defer cancel() 165 166 go srv.Start(ctx) 167 time.Sleep(100 * time.Millisecond) 168 169 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/status", srv.Port())) 170 if err != nil { 171 t.Fatal(err) 172 } 173 defer resp.Body.Close() 174 175 var body struct { 176 IsConnected bool `json:"is_connected"` 177 ActiveAgent string `json:"active_agent"` 178 Uptime int `json:"uptime"` 179 Version string `json:"version"` 180 } 181 json.NewDecoder(resp.Body).Decode(&body) 182 if body.IsConnected { 183 t.Error("should not be connected") 184 } 185 if body.Uptime < 0 { 186 t.Error("uptime should be non-negative") 187 } 188 if body.Version != "test" { 189 t.Errorf("version = %q, want %q", body.Version, "test") 190 } 191 } 192 193 func TestServer_Shutdown(t *testing.T) { 194 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 195 srv := NewServer(0, c, nil, "test") 196 ctx, cancel := context.WithCancel(context.Background()) 197 198 go srv.Start(ctx) 199 time.Sleep(100 * time.Millisecond) 200 201 cancel() 202 time.Sleep(200 * time.Millisecond) 203 204 _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())) 205 if err == nil { 206 t.Error("expected connection refused after shutdown") 207 } 208 } 209 210 func TestServer_Agents_Empty(t *testing.T) { 211 agentsDir := t.TempDir() 212 sessDir := t.TempDir() 213 deps := &ServerDeps{ 214 AgentsDir: agentsDir, 215 SessionCache: NewSessionCache(sessDir), 216 } 217 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 218 srv := NewServer(0, c, deps, "test") 219 ctx, cancel := context.WithCancel(context.Background()) 220 defer cancel() 221 222 go srv.Start(ctx) 223 time.Sleep(100 * time.Millisecond) 224 225 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port())) 226 if err != nil { 227 t.Fatal(err) 228 } 229 defer resp.Body.Close() 230 231 if resp.StatusCode != http.StatusOK { 232 t.Fatalf("status = %d", resp.StatusCode) 233 } 234 body, _ := io.ReadAll(resp.Body) 235 var parsed map[string]json.RawMessage 236 json.Unmarshal(body, &parsed) 237 if string(parsed["agents"]) != "[]" { 238 t.Errorf("expected empty agents array, got %s", string(body)) 239 } 240 } 241 242 func TestServer_Sessions_Empty(t *testing.T) { 243 sessDir := t.TempDir() 244 deps := &ServerDeps{ 245 SessionCache: NewSessionCache(sessDir), 246 } 247 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 248 srv := NewServer(0, c, deps, "test") 249 ctx, cancel := context.WithCancel(context.Background()) 250 defer cancel() 251 252 go srv.Start(ctx) 253 time.Sleep(100 * time.Millisecond) 254 255 resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/sessions", srv.Port())) 256 if err != nil { 257 t.Fatal(err) 258 } 259 defer resp.Body.Close() 260 261 if resp.StatusCode != http.StatusOK { 262 t.Fatalf("status = %d", resp.StatusCode) 263 } 264 body, _ := io.ReadAll(resp.Body) 265 var parsed map[string]json.RawMessage 266 json.Unmarshal(body, &parsed) 267 if string(parsed["sessions"]) != "[]" { 268 t.Errorf("expected empty sessions array, got %s", string(body)) 269 } 270 } 271 272 func TestServer_Message_MissingText(t *testing.T) { 273 deps := &ServerDeps{} 274 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 275 srv := NewServer(0, c, deps, "test") 276 ctx, cancel := context.WithCancel(context.Background()) 277 defer cancel() 278 279 go srv.Start(ctx) 280 time.Sleep(100 * time.Millisecond) 281 282 resp, err := http.Post( 283 fmt.Sprintf("http://127.0.0.1:%d/message", srv.Port()), 284 "application/json", 285 strings.NewReader(`{}`), 286 ) 287 if err != nil { 288 t.Fatal(err) 289 } 290 defer resp.Body.Close() 291 292 if resp.StatusCode != http.StatusBadRequest { 293 t.Errorf("expected 400, got %d", resp.StatusCode) 294 } 295 } 296 297 func TestServer_Message_AgentNotFound(t *testing.T) { 298 sessDir := t.TempDir() 299 deps := &ServerDeps{ 300 Config: &config.Config{}, 301 AgentsDir: t.TempDir(), 302 SessionCache: NewSessionCache(sessDir), 303 } 304 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 305 srv := NewServer(0, c, deps, "test") 306 ctx, cancel := context.WithCancel(context.Background()) 307 defer cancel() 308 309 go srv.Start(ctx) 310 time.Sleep(100 * time.Millisecond) 311 312 resp, err := http.Post( 313 fmt.Sprintf("http://127.0.0.1:%d/message", srv.Port()), 314 "application/json", 315 strings.NewReader(`{"text":"hello","agent":"nonexistent"}`), 316 ) 317 if err != nil { 318 t.Fatal(err) 319 } 320 defer resp.Body.Close() 321 322 // Agent falls back to default when not found, but RunAgent will fail 323 // because deps are incomplete (no gateway, registry). 500 is expected. 324 if resp.StatusCode != http.StatusInternalServerError { 325 t.Errorf("expected 500, got %d", resp.StatusCode) 326 } 327 body, _ := io.ReadAll(resp.Body) 328 if !strings.Contains(string(body), "error") { 329 t.Errorf("expected error in body, got %s", string(body)) 330 } 331 } 332 333 func TestServer_ChromeHandlersUseConfiguredPlaywrightPort(t *testing.T) { 334 oldShow := showChromeOnPortFn 335 oldHide := hideChromeOnPortFn 336 oldStatus := getChromeStatusOnPortFn 337 defer func() { 338 showChromeOnPortFn = oldShow 339 hideChromeOnPortFn = oldHide 340 getChromeStatusOnPortFn = oldStatus 341 }() 342 343 var showPort, hidePort, statusPort int 344 showChromeOnPortFn = func(port int) error { 345 showPort = port 346 return nil 347 } 348 hideChromeOnPortFn = func(port int) error { 349 hidePort = port 350 return nil 351 } 352 getChromeStatusOnPortFn = func(port int) mcp.CDPChromeStatus { 353 statusPort = port 354 return mcp.CDPChromeStatus{Running: true, Visible: true} 355 } 356 357 deps := &ServerDeps{ 358 Config: &config.Config{ 359 MCPServers: map[string]mcp.MCPServerConfig{ 360 "playwright": { 361 Args: []string{"--cdp-endpoint", "http://127.0.0.1:9333"}, 362 }, 363 }, 364 }, 365 } 366 srv := NewServer(0, nil, deps, "test") 367 368 showRec := httptest.NewRecorder() 369 srv.handleChromeShow(showRec, httptest.NewRequest(http.MethodPost, "/chrome/show", nil)) 370 if showPort != 9333 { 371 t.Fatalf("show used port %d, want 9333", showPort) 372 } 373 if showRec.Code != http.StatusOK { 374 t.Fatalf("show status = %d, want 200", showRec.Code) 375 } 376 var showBody map[string]string 377 if err := json.NewDecoder(showRec.Body).Decode(&showBody); err != nil { 378 t.Fatalf("decode show body: %v", err) 379 } 380 if showBody["status"] != "visible" { 381 t.Fatalf("show body = %v, want visible status", showBody) 382 } 383 384 hideRec := httptest.NewRecorder() 385 srv.handleChromeHide(hideRec, httptest.NewRequest(http.MethodPost, "/chrome/hide", nil)) 386 if hidePort != 9333 { 387 t.Fatalf("hide used port %d, want 9333", hidePort) 388 } 389 if hideRec.Code != http.StatusOK { 390 t.Fatalf("hide status = %d, want 200", hideRec.Code) 391 } 392 var hideBody map[string]string 393 if err := json.NewDecoder(hideRec.Body).Decode(&hideBody); err != nil { 394 t.Fatalf("decode hide body: %v", err) 395 } 396 if hideBody["status"] != "hidden" { 397 t.Fatalf("hide body = %v, want hidden status", hideBody) 398 } 399 400 statusRec := httptest.NewRecorder() 401 srv.handleChromeStatus(statusRec, httptest.NewRequest(http.MethodGet, "/chrome/status", nil)) 402 if statusPort != 9333 { 403 t.Fatalf("status used port %d, want 9333", statusPort) 404 } 405 if statusRec.Code != http.StatusOK { 406 t.Fatalf("status code = %d, want 200", statusRec.Code) 407 } 408 var statusBody map[string]bool 409 if err := json.NewDecoder(statusRec.Body).Decode(&statusBody); err != nil { 410 t.Fatalf("decode status body: %v", err) 411 } 412 if !statusBody["running"] || !statusBody["visible"] { 413 t.Fatalf("status body = %v, want running+visible", statusBody) 414 } 415 if statusBody["probe_error"] { 416 t.Fatalf("status body = %v, want probe_error=false", statusBody) 417 } 418 } 419 420 func TestServer_ChromeHandlersNormalizeLegacyPlaywrightPort(t *testing.T) { 421 oldShow := showChromeOnPortFn 422 defer func() { showChromeOnPortFn = oldShow }() 423 424 var showPort int 425 showChromeOnPortFn = func(port int) error { 426 showPort = port 427 return nil 428 } 429 430 deps := &ServerDeps{ 431 Config: &config.Config{ 432 MCPServers: map[string]mcp.MCPServerConfig{ 433 "playwright": { 434 Args: []string{"--cdp-endpoint", "http://localhost:9222"}, 435 }, 436 }, 437 }, 438 } 439 srv := NewServer(0, nil, deps, "test") 440 441 rec := httptest.NewRecorder() 442 srv.handleChromeShow(rec, httptest.NewRequest(http.MethodPost, "/chrome/show", nil)) 443 if showPort != mcp.DefaultCDPPort { 444 t.Fatalf("show used port %d, want normalized default %d", showPort, mcp.DefaultCDPPort) 445 } 446 if rec.Code != http.StatusOK { 447 t.Fatalf("show status = %d, want 200", rec.Code) 448 } 449 var body map[string]string 450 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 451 t.Fatalf("decode show body: %v", err) 452 } 453 if body["status"] != "visible" { 454 t.Fatalf("show body = %v, want visible status", body) 455 } 456 } 457 458 func TestServer_ChromeProfileHandlerUsesConfiguredProfile(t *testing.T) { 459 oldGet := getChromeProfileStateFn 460 defer func() { getChromeProfileStateFn = oldGet }() 461 462 var configured string 463 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 464 configured = profile 465 return mcp.ChromeProfileState{ 466 Mode: "explicit", 467 ConfiguredProfile: profile, 468 EffectiveProfile: profile, 469 CloneStatus: mcp.ChromeProfileCloneCurrent, 470 Profiles: []mcp.ChromeProfileOption{ 471 {Name: "Profile 6", DisplayName: "Work", Exists: true, IsConfigured: true, IsEffective: true}, 472 }, 473 }, nil 474 } 475 476 deps := &ServerDeps{ 477 Config: &config.Config{ 478 Daemon: config.DaemonConfig{ChromeProfile: "Profile 6"}, 479 }, 480 } 481 srv := NewServer(0, nil, deps, "test") 482 483 rec := httptest.NewRecorder() 484 srv.handleChromeProfile(rec, httptest.NewRequest(http.MethodGet, "/chrome/profile", nil)) 485 if rec.Code != http.StatusOK { 486 t.Fatalf("status code = %d, want 200", rec.Code) 487 } 488 if configured != "Profile 6" { 489 t.Fatalf("expected configured profile 'Profile 6', got %q", configured) 490 } 491 var body mcp.ChromeProfileState 492 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 493 t.Fatalf("decode body: %v", err) 494 } 495 if body.EffectiveProfile != "Profile 6" { 496 t.Fatalf("expected effective profile 'Profile 6', got %q", body.EffectiveProfile) 497 } 498 if body.CloneStatus != mcp.ChromeProfileCloneCurrent { 499 t.Fatalf("expected clone status %q, got %q", mcp.ChromeProfileCloneCurrent, body.CloneStatus) 500 } 501 } 502 503 func TestServer_ChromeProfileUpdateExplicitPersistsAndRefreshesClone(t *testing.T) { 504 oldGet := getChromeProfileStateFn 505 oldStop := stopChromeFn 506 oldReset := resetChromeProfileCloneFn 507 oldProfile := mcp.GetCDPChromeProfile() 508 defer func() { 509 getChromeProfileStateFn = oldGet 510 stopChromeFn = oldStop 511 resetChromeProfileCloneFn = oldReset 512 mcp.SetCDPChromeProfile(oldProfile) 513 }() 514 515 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 516 state := mcp.ChromeProfileState{ 517 Mode: "explicit", 518 ConfiguredProfile: profile, 519 EffectiveProfile: profile, 520 CloneStatus: mcp.ChromeProfileCloneMissing, 521 Profiles: []mcp.ChromeProfileOption{ 522 {Name: "Default", DisplayName: "Default", Exists: true}, 523 {Name: "Profile 6", DisplayName: "Work", Exists: true, IsConfigured: profile == "Profile 6", IsEffective: profile == "Profile 6"}, 524 }, 525 } 526 if profile == "" { 527 state.Mode = "auto" 528 state.DetectedProfile = "Profile 6" 529 state.EffectiveProfile = "Profile 6" 530 } 531 return state, nil 532 } 533 534 stopCalls := 0 535 stopChromeFn = func() { stopCalls++ } 536 resetCalls := 0 537 resetChromeProfileCloneFn = func() error { 538 resetCalls++ 539 return nil 540 } 541 542 shannonDir := t.TempDir() 543 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte("{}\n"), 0o600); err != nil { 544 t.Fatalf("write config: %v", err) 545 } 546 deps := &ServerDeps{ 547 ShannonDir: shannonDir, 548 Config: &config.Config{}, 549 } 550 srv := NewServer(0, nil, deps, "test") 551 552 rec := httptest.NewRecorder() 553 req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`)) 554 req.Header.Set("Content-Type", "application/json") 555 srv.handleChromeProfileUpdate(rec, req) 556 557 if rec.Code != http.StatusOK { 558 t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String()) 559 } 560 if deps.Config.Daemon.ChromeProfile != "Profile 6" { 561 t.Fatalf("expected in-memory config to be updated, got %q", deps.Config.Daemon.ChromeProfile) 562 } 563 if mcp.GetCDPChromeProfile() != "Profile 6" { 564 t.Fatalf("expected runtime chrome profile override, got %q", mcp.GetCDPChromeProfile()) 565 } 566 if stopCalls != 1 || resetCalls != 1 { 567 t.Fatalf("expected stop/reset to be called once each, got stop=%d reset=%d", stopCalls, resetCalls) 568 } 569 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 570 if err != nil { 571 t.Fatalf("read config: %v", err) 572 } 573 if !strings.Contains(string(data), "chrome_profile: Profile 6") { 574 t.Fatalf("expected config to persist chrome_profile, got %s", string(data)) 575 } 576 577 var body mcp.ChromeProfileState 578 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 579 t.Fatalf("decode body: %v", err) 580 } 581 if body.ConfiguredProfile != "Profile 6" || body.EffectiveProfile != "Profile 6" { 582 t.Fatalf("unexpected response body: %+v", body) 583 } 584 if body.CloneStatus != mcp.ChromeProfileCloneMissing { 585 t.Fatalf("expected clone status %q, got %q", mcp.ChromeProfileCloneMissing, body.CloneStatus) 586 } 587 } 588 589 func TestServer_ChromeProfileUpdateAutoClearsConfigKey(t *testing.T) { 590 oldGet := getChromeProfileStateFn 591 oldStop := stopChromeFn 592 oldReset := resetChromeProfileCloneFn 593 oldProfile := mcp.GetCDPChromeProfile() 594 defer func() { 595 getChromeProfileStateFn = oldGet 596 stopChromeFn = oldStop 597 resetChromeProfileCloneFn = oldReset 598 mcp.SetCDPChromeProfile(oldProfile) 599 }() 600 601 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 602 return mcp.ChromeProfileState{ 603 Mode: "auto", 604 DetectedProfile: "Profile 6", 605 EffectiveProfile: "Profile 6", 606 CloneStatus: mcp.ChromeProfileCloneMissing, 607 Profiles: []mcp.ChromeProfileOption{ 608 {Name: "Profile 6", DisplayName: "Work", Exists: true, IsLastUsed: true, IsEffective: true}, 609 }, 610 }, nil 611 } 612 stopChromeFn = func() {} 613 resetChromeProfileCloneFn = func() error { return nil } 614 615 shannonDir := t.TempDir() 616 initial := "daemon:\n auto_approve: true\n chrome_profile: Profile 6\n" 617 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil { 618 t.Fatalf("write config: %v", err) 619 } 620 deps := &ServerDeps{ 621 ShannonDir: shannonDir, 622 Config: &config.Config{ 623 Daemon: config.DaemonConfig{AutoApprove: true, ChromeProfile: "Profile 6"}, 624 }, 625 } 626 srv := NewServer(0, nil, deps, "test") 627 628 rec := httptest.NewRecorder() 629 req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"auto"}`)) 630 req.Header.Set("Content-Type", "application/json") 631 srv.handleChromeProfileUpdate(rec, req) 632 633 if rec.Code != http.StatusOK { 634 t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String()) 635 } 636 if deps.Config.Daemon.ChromeProfile != "" { 637 t.Fatalf("expected in-memory chrome_profile to be cleared, got %q", deps.Config.Daemon.ChromeProfile) 638 } 639 if mcp.GetCDPChromeProfile() != "" { 640 t.Fatalf("expected runtime chrome profile override to be cleared, got %q", mcp.GetCDPChromeProfile()) 641 } 642 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 643 if err != nil { 644 t.Fatalf("read config: %v", err) 645 } 646 text := string(data) 647 if strings.Contains(text, "chrome_profile:") { 648 t.Fatalf("expected chrome_profile key to be removed, got %s", text) 649 } 650 if !strings.Contains(text, "auto_approve: true") { 651 t.Fatalf("expected sibling daemon setting to remain, got %s", text) 652 } 653 654 var body mcp.ChromeProfileState 655 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 656 t.Fatalf("decode body: %v", err) 657 } 658 if body.Mode != "auto" || body.CloneStatus != mcp.ChromeProfileCloneMissing { 659 t.Fatalf("unexpected response body: %+v", body) 660 } 661 } 662 663 func TestServer_ChromeProfileUpdateDoesNotPersistWhenResetFails(t *testing.T) { 664 oldGet := getChromeProfileStateFn 665 oldStop := stopChromeFn 666 oldReset := resetChromeProfileCloneFn 667 oldProfile := mcp.GetCDPChromeProfile() 668 defer func() { 669 getChromeProfileStateFn = oldGet 670 stopChromeFn = oldStop 671 resetChromeProfileCloneFn = oldReset 672 mcp.SetCDPChromeProfile(oldProfile) 673 }() 674 675 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 676 return mcp.ChromeProfileState{ 677 Mode: "auto", 678 DetectedProfile: "Default", 679 EffectiveProfile: "Default", 680 CloneStatus: mcp.ChromeProfileCloneCurrent, 681 Profiles: []mcp.ChromeProfileOption{ 682 {Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true}, 683 {Name: "Profile 6", DisplayName: "Work", Exists: true}, 684 }, 685 }, nil 686 } 687 stopChromeFn = func() {} 688 resetChromeProfileCloneFn = func() error { return errors.New("directory not empty") } 689 690 shannonDir := t.TempDir() 691 t.Cleanup(func() { _ = os.Chmod(shannonDir, 0o700) }) 692 initial := "daemon:\n auto_approve: true\n" 693 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil { 694 t.Fatalf("write config: %v", err) 695 } 696 deps := &ServerDeps{ 697 ShannonDir: shannonDir, 698 Config: &config.Config{ 699 Daemon: config.DaemonConfig{AutoApprove: true}, 700 }, 701 } 702 srv := NewServer(0, nil, deps, "test") 703 704 rec := httptest.NewRecorder() 705 req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`)) 706 req.Header.Set("Content-Type", "application/json") 707 srv.handleChromeProfileUpdate(rec, req) 708 709 if rec.Code != http.StatusInternalServerError { 710 t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String()) 711 } 712 var body map[string]string 713 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 714 t.Fatalf("decode error body: %v", err) 715 } 716 if body["error"] != "directory not empty" { 717 t.Fatalf("unexpected error body: %v", body) 718 } 719 if deps.Config.Daemon.ChromeProfile != "" { 720 t.Fatalf("expected in-memory chrome_profile to remain unchanged, got %q", deps.Config.Daemon.ChromeProfile) 721 } 722 if mcp.GetCDPChromeProfile() != oldProfile { 723 t.Fatalf("expected runtime chrome profile override to remain unchanged, got %q", mcp.GetCDPChromeProfile()) 724 } 725 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 726 if err != nil { 727 t.Fatalf("read config: %v", err) 728 } 729 text := string(data) 730 if strings.Contains(text, "chrome_profile:") { 731 t.Fatalf("expected rolled-back config to remove chrome_profile, got %s", text) 732 } 733 if !strings.Contains(text, "auto_approve: true") { 734 t.Fatalf("expected rolled-back config to keep sibling settings, got %s", text) 735 } 736 } 737 738 func TestServer_ChromeProfileUpdateRollbackFailureKeepsMemoryAlignedWithDisk(t *testing.T) { 739 oldGet := getChromeProfileStateFn 740 oldStop := stopChromeFn 741 oldReset := resetChromeProfileCloneFn 742 oldProfile := mcp.GetCDPChromeProfile() 743 defer func() { 744 getChromeProfileStateFn = oldGet 745 stopChromeFn = oldStop 746 resetChromeProfileCloneFn = oldReset 747 mcp.SetCDPChromeProfile(oldProfile) 748 }() 749 750 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 751 return mcp.ChromeProfileState{ 752 Mode: "auto", 753 DetectedProfile: "Default", 754 EffectiveProfile: "Default", 755 CloneStatus: mcp.ChromeProfileCloneCurrent, 756 Profiles: []mcp.ChromeProfileOption{ 757 {Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true}, 758 {Name: "Profile 6", DisplayName: "Work", Exists: true}, 759 }, 760 }, nil 761 } 762 stopChromeFn = func() {} 763 764 shannonDir := t.TempDir() 765 initial := "daemon:\n auto_approve: true\n" 766 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil { 767 t.Fatalf("write config: %v", err) 768 } 769 resetChromeProfileCloneFn = func() error { 770 if err := os.RemoveAll(shannonDir); err != nil { 771 t.Fatalf("remove shannon dir: %v", err) 772 } 773 return errors.New("directory not empty") 774 } 775 776 deps := &ServerDeps{ 777 ShannonDir: shannonDir, 778 Config: &config.Config{ 779 Daemon: config.DaemonConfig{AutoApprove: true}, 780 }, 781 } 782 srv := NewServer(0, nil, deps, "test") 783 784 rec := httptest.NewRecorder() 785 req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`)) 786 req.Header.Set("Content-Type", "application/json") 787 srv.handleChromeProfileUpdate(rec, req) 788 789 if rec.Code != http.StatusInternalServerError { 790 t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String()) 791 } 792 var body map[string]string 793 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 794 t.Fatalf("decode error body: %v", err) 795 } 796 if !strings.Contains(body["error"], "rollback failed") { 797 t.Fatalf("expected rollback failure in error body, got %v", body) 798 } 799 if deps.Config.Daemon.ChromeProfile != "Profile 6" { 800 t.Fatalf("expected in-memory chrome_profile to stay aligned with disk, got %q", deps.Config.Daemon.ChromeProfile) 801 } 802 if mcp.GetCDPChromeProfile() != "Profile 6" { 803 t.Fatalf("expected runtime chrome profile override to stay aligned with disk, got %q", mcp.GetCDPChromeProfile()) 804 } 805 if _, err := os.Stat(shannonDir); !os.IsNotExist(err) { 806 t.Fatalf("expected rollback failure setup to remove shannon dir, got err=%v", err) 807 } 808 } 809 810 func TestServer_ChromeProfileUpdateDoesNotStopWhenConfigWriteFails(t *testing.T) { 811 oldGet := getChromeProfileStateFn 812 oldStop := stopChromeFn 813 oldReset := resetChromeProfileCloneFn 814 oldProfile := mcp.GetCDPChromeProfile() 815 defer func() { 816 getChromeProfileStateFn = oldGet 817 stopChromeFn = oldStop 818 resetChromeProfileCloneFn = oldReset 819 mcp.SetCDPChromeProfile(oldProfile) 820 }() 821 822 getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) { 823 return mcp.ChromeProfileState{ 824 Mode: "auto", 825 DetectedProfile: "Default", 826 EffectiveProfile: "Default", 827 CloneStatus: mcp.ChromeProfileCloneCurrent, 828 Profiles: []mcp.ChromeProfileOption{ 829 {Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true}, 830 {Name: "Profile 6", DisplayName: "Work", Exists: true}, 831 }, 832 }, nil 833 } 834 835 stopCalls := 0 836 resetCalls := 0 837 stopChromeFn = func() { stopCalls++ } 838 resetChromeProfileCloneFn = func() error { 839 resetCalls++ 840 return nil 841 } 842 843 mcp.SetCDPChromeProfile("Default") 844 deps := &ServerDeps{ 845 ShannonDir: filepath.Join(t.TempDir(), "missing-config-dir"), 846 Config: &config.Config{ 847 Daemon: config.DaemonConfig{ChromeProfile: "Default"}, 848 }, 849 } 850 srv := NewServer(0, nil, deps, "test") 851 852 rec := httptest.NewRecorder() 853 req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`)) 854 req.Header.Set("Content-Type", "application/json") 855 srv.handleChromeProfileUpdate(rec, req) 856 857 if rec.Code != http.StatusInternalServerError { 858 t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String()) 859 } 860 var body map[string]string 861 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 862 t.Fatalf("decode error body: %v", err) 863 } 864 if body["error"] == "" { 865 t.Fatalf("expected non-empty error body, got %v", body) 866 } 867 if stopCalls != 0 || resetCalls != 0 { 868 t.Fatalf("expected no destructive ops when config write fails, got stop=%d reset=%d", stopCalls, resetCalls) 869 } 870 if deps.Config.Daemon.ChromeProfile != "Default" { 871 t.Fatalf("expected in-memory chrome_profile to remain unchanged, got %q", deps.Config.Daemon.ChromeProfile) 872 } 873 if mcp.GetCDPChromeProfile() != "Default" { 874 t.Fatalf("expected runtime chrome profile override to remain unchanged, got %q", mcp.GetCDPChromeProfile()) 875 } 876 } 877 878 func TestServer_PatchConfigNullRemovesChromeProfileKey(t *testing.T) { 879 shannonDir := t.TempDir() 880 initial := "daemon:\n auto_approve: true\n chrome_profile: Profile 6\n" 881 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil { 882 t.Fatalf("write config: %v", err) 883 } 884 885 srv := NewServer(0, nil, &ServerDeps{ShannonDir: shannonDir}, "test") 886 887 rec := httptest.NewRecorder() 888 req := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(`{"daemon":{"chrome_profile":null}}`)) 889 req.Header.Set("Content-Type", "application/json") 890 srv.handlePatchConfig(rec, req) 891 892 if rec.Code != http.StatusOK { 893 t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String()) 894 } 895 var body map[string]string 896 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 897 t.Fatalf("decode body: %v", err) 898 } 899 if body["status"] != "updated" { 900 t.Fatalf("unexpected response body: %v", body) 901 } 902 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 903 if err != nil { 904 t.Fatalf("read config: %v", err) 905 } 906 text := string(data) 907 if strings.Contains(text, "chrome_profile:") { 908 t.Fatalf("expected chrome_profile key to be removed, got %s", text) 909 } 910 if !strings.Contains(text, "auto_approve: true") { 911 t.Fatalf("expected sibling daemon setting to remain, got %s", text) 912 } 913 } 914 915 // --- Issue 1: rollback on create failure --- 916 917 func TestServer_CreateAgent_Conflict(t *testing.T) { 918 agentsDir := t.TempDir() 919 sessDir := t.TempDir() 920 deps := &ServerDeps{ 921 AgentsDir: agentsDir, 922 ShannonDir: t.TempDir(), 923 SessionCache: NewSessionCache(sessDir), 924 } 925 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 926 srv := NewServer(0, c, deps, "test") 927 ctx, cancel := context.WithCancel(context.Background()) 928 defer cancel() 929 930 go srv.Start(ctx) 931 time.Sleep(100 * time.Millisecond) 932 933 body := `{"name":"testbot","prompt":"hello world"}` 934 resp, err := http.Post( 935 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 936 "application/json", 937 strings.NewReader(body), 938 ) 939 if err != nil { 940 t.Fatal(err) 941 } 942 resp.Body.Close() 943 if resp.StatusCode != http.StatusCreated { 944 t.Fatalf("create: expected 201, got %d", resp.StatusCode) 945 } 946 947 // Duplicate create — should get 409 948 resp2, err := http.Post( 949 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 950 "application/json", 951 strings.NewReader(body), 952 ) 953 if err != nil { 954 t.Fatal(err) 955 } 956 resp2.Body.Close() 957 if resp2.StatusCode != http.StatusConflict { 958 t.Errorf("duplicate: expected 409, got %d", resp2.StatusCode) 959 } 960 } 961 962 func TestServer_CreateAgent_RollbackOnWriteFailure(t *testing.T) { 963 agentsDir := t.TempDir() 964 sessDir := t.TempDir() 965 deps := &ServerDeps{ 966 AgentsDir: agentsDir, 967 ShannonDir: t.TempDir(), 968 SessionCache: NewSessionCache(sessDir), 969 } 970 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 971 srv := NewServer(0, c, deps, "test") 972 ctx, cancel := context.WithCancel(context.Background()) 973 defer cancel() 974 975 go srv.Start(ctx) 976 time.Sleep(100 * time.Millisecond) 977 978 // Make agents dir read-only so WriteAgentPrompt's MkdirAll fails 979 os.Chmod(agentsDir, 0500) 980 defer os.Chmod(agentsDir, 0700) // restore for cleanup 981 982 body := `{"name":"failbot","prompt":"should fail"}` 983 resp, err := http.Post( 984 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 985 "application/json", 986 strings.NewReader(body), 987 ) 988 if err != nil { 989 t.Fatal(err) 990 } 991 resp.Body.Close() 992 if resp.StatusCode != http.StatusInternalServerError { 993 t.Fatalf("expected 500, got %d", resp.StatusCode) 994 } 995 996 // Restore permissions and verify no orphaned directory 997 os.Chmod(agentsDir, 0700) 998 if _, err := os.Stat(filepath.Join(agentsDir, "failbot")); !os.IsNotExist(err) { 999 t.Error("agent dir should not exist after rollback") 1000 } 1001 } 1002 1003 func TestServer_CreateAgent_DoesNotCreateSessionManager(t *testing.T) { 1004 agentsDir := t.TempDir() 1005 sessDir := t.TempDir() 1006 sessionCache := NewSessionCache(sessDir) 1007 deps := &ServerDeps{ 1008 AgentsDir: agentsDir, 1009 ShannonDir: t.TempDir(), 1010 SessionCache: sessionCache, 1011 } 1012 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1013 srv := NewServer(0, c, deps, "test") 1014 ctx, cancel := context.WithCancel(context.Background()) 1015 defer cancel() 1016 1017 go srv.Start(ctx) 1018 time.Sleep(100 * time.Millisecond) 1019 1020 body := `{"name":"cache-test","prompt":"hello world"}` 1021 resp, err := http.Post( 1022 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 1023 "application/json", 1024 strings.NewReader(body), 1025 ) 1026 if err != nil { 1027 t.Fatal(err) 1028 } 1029 defer resp.Body.Close() 1030 1031 if resp.StatusCode != http.StatusCreated { 1032 t.Fatalf("create: expected 201, got %d", resp.StatusCode) 1033 } 1034 1035 sessionCache.mu.Lock() 1036 route, ok := sessionCache.routes["agent:cache-test"] 1037 sessionCache.mu.Unlock() 1038 if !ok { 1039 t.Fatalf("expected route cache entry for agent:cache-test to exist") 1040 } 1041 if route.manager != nil { 1042 t.Fatalf("expected create path to avoid creating a route manager") 1043 } 1044 } 1045 1046 func TestServer_CreateAgent_AttachesInstalledSkills(t *testing.T) { 1047 shannonDir := t.TempDir() 1048 agentsDir := filepath.Join(shannonDir, "agents") 1049 if err := os.MkdirAll(agentsDir, 0700); err != nil { 1050 t.Fatalf("mkdir agents dir: %v", err) 1051 } 1052 sessDir := t.TempDir() 1053 writeTestGlobalSkill(t, shannonDir, "check") 1054 deps := &ServerDeps{ 1055 AgentsDir: agentsDir, 1056 ShannonDir: shannonDir, 1057 SessionCache: NewSessionCache(sessDir), 1058 } 1059 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1060 srv := NewServer(0, c, deps, "test") 1061 ctx, cancel := context.WithCancel(context.Background()) 1062 defer cancel() 1063 1064 go srv.Start(ctx) 1065 time.Sleep(100 * time.Millisecond) 1066 1067 body := `{"name":"attach-bot","prompt":"hello world","skills":[{"name":"check"}]}` 1068 resp, err := http.Post( 1069 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 1070 "application/json", 1071 strings.NewReader(body), 1072 ) 1073 if err != nil { 1074 t.Fatal(err) 1075 } 1076 defer resp.Body.Close() 1077 if resp.StatusCode != http.StatusCreated { 1078 t.Fatalf("expected 201, got %d", resp.StatusCode) 1079 } 1080 1081 loaded, err := agents.LoadAgent(agentsDir, "attach-bot") 1082 if err != nil { 1083 t.Fatalf("load agent: %v", err) 1084 } 1085 if len(loaded.Skills) != 1 || loaded.Skills[0].Name != "check" { 1086 t.Fatalf("expected attached global skill 'check', got %+v", loaded.Skills) 1087 } 1088 1089 attached, err := agents.ReadAttachedSkills(agentsDir, "attach-bot") 1090 if err != nil { 1091 t.Fatalf("read attached skills: %v", err) 1092 } 1093 if len(attached) != 1 || attached[0] != "check" { 1094 t.Fatalf("expected manifest to contain check, got %v", attached) 1095 } 1096 1097 if _, err := os.Stat(filepath.Join(agentsDir, "attach-bot", "skills")); !os.IsNotExist(err) { 1098 t.Fatalf("expected no agent-local skill directory, got err=%v", err) 1099 } 1100 } 1101 1102 func TestServer_PutSkill_AttachesInstalledGlobalSkill(t *testing.T) { 1103 shannonDir := t.TempDir() 1104 agentsDir := filepath.Join(shannonDir, "agents") 1105 if err := os.MkdirAll(agentsDir, 0700); err != nil { 1106 t.Fatalf("mkdir agents dir: %v", err) 1107 } 1108 sessDir := t.TempDir() 1109 writeTestGlobalSkill(t, shannonDir, "check") 1110 deps := &ServerDeps{ 1111 AgentsDir: agentsDir, 1112 ShannonDir: shannonDir, 1113 SessionCache: NewSessionCache(sessDir), 1114 } 1115 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1116 srv := NewServer(0, c, deps, "test") 1117 ctx, cancel := context.WithCancel(context.Background()) 1118 defer cancel() 1119 1120 go srv.Start(ctx) 1121 time.Sleep(100 * time.Millisecond) 1122 1123 createBody := `{"name":"skill-bot","prompt":"hello world"}` 1124 resp, err := http.Post( 1125 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 1126 "application/json", 1127 strings.NewReader(createBody), 1128 ) 1129 if err != nil { 1130 t.Fatal(err) 1131 } 1132 resp.Body.Close() 1133 if resp.StatusCode != http.StatusCreated { 1134 t.Fatalf("create: expected 201, got %d", resp.StatusCode) 1135 } 1136 1137 req, err := http.NewRequest( 1138 http.MethodPut, 1139 fmt.Sprintf("http://127.0.0.1:%d/agents/skill-bot/skills/check", srv.Port()), 1140 strings.NewReader(`{}`), 1141 ) 1142 if err != nil { 1143 t.Fatal(err) 1144 } 1145 req.Header.Set("Content-Type", "application/json") 1146 resp, err = http.DefaultClient.Do(req) 1147 if err != nil { 1148 t.Fatal(err) 1149 } 1150 defer resp.Body.Close() 1151 if resp.StatusCode != http.StatusOK { 1152 t.Fatalf("attach: expected 200, got %d", resp.StatusCode) 1153 } 1154 1155 loaded, err := agents.LoadAgent(agentsDir, "skill-bot") 1156 if err != nil { 1157 t.Fatalf("load agent: %v", err) 1158 } 1159 if len(loaded.Skills) != 1 || loaded.Skills[0].Name != "check" { 1160 t.Fatalf("expected attached global skill 'check', got %+v", loaded.Skills) 1161 } 1162 } 1163 1164 func TestServer_DeleteSkill_DetachesManifestAndCleansLegacySkillDir(t *testing.T) { 1165 shannonDir := t.TempDir() 1166 agentsDir := filepath.Join(shannonDir, "agents") 1167 if err := os.MkdirAll(agentsDir, 0700); err != nil { 1168 t.Fatalf("mkdir agents dir: %v", err) 1169 } 1170 sessDir := t.TempDir() 1171 writeTestGlobalSkill(t, shannonDir, "check") 1172 deps := &ServerDeps{ 1173 AgentsDir: agentsDir, 1174 ShannonDir: shannonDir, 1175 SessionCache: NewSessionCache(sessDir), 1176 } 1177 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1178 srv := NewServer(0, c, deps, "test") 1179 ctx, cancel := context.WithCancel(context.Background()) 1180 defer cancel() 1181 1182 go srv.Start(ctx) 1183 time.Sleep(100 * time.Millisecond) 1184 1185 createBody := `{"name":"detach-bot","prompt":"hello world","skills":[{"name":"check"}]}` 1186 resp, err := http.Post( 1187 fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()), 1188 "application/json", 1189 strings.NewReader(createBody), 1190 ) 1191 if err != nil { 1192 t.Fatal(err) 1193 } 1194 resp.Body.Close() 1195 if resp.StatusCode != http.StatusCreated { 1196 t.Fatalf("create: expected 201, got %d", resp.StatusCode) 1197 } 1198 1199 if err := agents.WriteAgentSkill(agentsDir, "detach-bot", &skills.Skill{ 1200 Name: "check", 1201 Description: "legacy local copy", 1202 Prompt: "legacy prompt", 1203 }); err != nil { 1204 t.Fatalf("write legacy agent-local skill: %v", err) 1205 } 1206 1207 req, err := http.NewRequest( 1208 http.MethodDelete, 1209 fmt.Sprintf("http://127.0.0.1:%d/agents/detach-bot/skills/check", srv.Port()), 1210 nil, 1211 ) 1212 if err != nil { 1213 t.Fatal(err) 1214 } 1215 resp, err = http.DefaultClient.Do(req) 1216 if err != nil { 1217 t.Fatal(err) 1218 } 1219 defer resp.Body.Close() 1220 if resp.StatusCode != http.StatusOK { 1221 t.Fatalf("delete: expected 200, got %d", resp.StatusCode) 1222 } 1223 1224 attached, err := agents.ReadAttachedSkills(agentsDir, "detach-bot") 1225 if err != nil { 1226 t.Fatalf("read attached skills: %v", err) 1227 } 1228 if len(attached) != 0 { 1229 t.Fatalf("expected empty attached skills after delete, got %v", attached) 1230 } 1231 1232 loaded, err := agents.LoadAgent(agentsDir, "detach-bot") 1233 if err != nil { 1234 t.Fatalf("load agent: %v", err) 1235 } 1236 if len(loaded.Skills) != 0 { 1237 t.Fatalf("expected no loaded skills after detach, got %+v", loaded.Skills) 1238 } 1239 1240 if _, err := os.Stat(filepath.Join(agentsDir, "detach-bot", "skills", "check")); !os.IsNotExist(err) { 1241 t.Fatalf("expected legacy agent-local skill dir to be removed, got err=%v", err) 1242 } 1243 } 1244 1245 // --- deepMerge unit tests --- 1246 1247 func TestDeepMerge(t *testing.T) { 1248 tests := []struct { 1249 name string 1250 dst, src map[string]interface{} 1251 want map[string]interface{} 1252 }{ 1253 { 1254 name: "scalar replace", 1255 dst: map[string]interface{}{"a": "old"}, 1256 src: map[string]interface{}{"a": "new"}, 1257 want: map[string]interface{}{"a": "new"}, 1258 }, 1259 { 1260 name: "null deletes key", 1261 dst: map[string]interface{}{"a": "val", "b": "keep"}, 1262 src: map[string]interface{}{"a": nil}, 1263 want: map[string]interface{}{"b": "keep"}, 1264 }, 1265 { 1266 name: "nested merge preserves siblings", 1267 dst: map[string]interface{}{ 1268 "agent": map[string]interface{}{"model": "old", "temp": 0.7}, 1269 }, 1270 src: map[string]interface{}{ 1271 "agent": map[string]interface{}{"model": "new"}, 1272 }, 1273 want: map[string]interface{}{ 1274 "agent": map[string]interface{}{"model": "new", "temp": 0.7}, 1275 }, 1276 }, 1277 { 1278 name: "3-level deep merge", 1279 dst: map[string]interface{}{ 1280 "a": map[string]interface{}{ 1281 "b": map[string]interface{}{"c": 1, "d": 2}, 1282 }, 1283 }, 1284 src: map[string]interface{}{ 1285 "a": map[string]interface{}{ 1286 "b": map[string]interface{}{"c": 99}, 1287 }, 1288 }, 1289 want: map[string]interface{}{ 1290 "a": map[string]interface{}{ 1291 "b": map[string]interface{}{"c": 99, "d": 2}, 1292 }, 1293 }, 1294 }, 1295 { 1296 name: "src map replaces dst scalar", 1297 dst: map[string]interface{}{"a": "scalar"}, 1298 src: map[string]interface{}{"a": map[string]interface{}{"nested": true}}, 1299 want: map[string]interface{}{"a": map[string]interface{}{"nested": true}}, 1300 }, 1301 { 1302 name: "src scalar replaces dst map", 1303 dst: map[string]interface{}{"a": map[string]interface{}{"nested": true}}, 1304 src: map[string]interface{}{"a": "scalar"}, 1305 want: map[string]interface{}{"a": "scalar"}, 1306 }, 1307 { 1308 name: "new key added", 1309 dst: map[string]interface{}{"a": 1}, 1310 src: map[string]interface{}{"b": 2}, 1311 want: map[string]interface{}{"a": 1, "b": 2}, 1312 }, 1313 { 1314 name: "empty src is no-op", 1315 dst: map[string]interface{}{"a": 1}, 1316 src: map[string]interface{}{}, 1317 want: map[string]interface{}{"a": 1}, 1318 }, 1319 } 1320 1321 for _, tc := range tests { 1322 t.Run(tc.name, func(t *testing.T) { 1323 deepMerge(tc.dst, tc.src) 1324 gotJSON, _ := json.Marshal(tc.dst) 1325 wantJSON, _ := json.Marshal(tc.want) 1326 if string(gotJSON) != string(wantJSON) { 1327 t.Errorf("got %s, want %s", gotJSON, wantJSON) 1328 } 1329 }) 1330 } 1331 } 1332 1333 // --- Issue 2: PATCH config deep merge --- 1334 1335 func TestServer_PatchConfig_DeepMerge(t *testing.T) { 1336 shannonDir := t.TempDir() 1337 sessDir := t.TempDir() 1338 deps := &ServerDeps{ 1339 ShannonDir: shannonDir, 1340 SessionCache: NewSessionCache(sessDir), 1341 Config: &config.Config{}, 1342 } 1343 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1344 srv := NewServer(0, c, deps, "test") 1345 ctx, cancel := context.WithCancel(context.Background()) 1346 defer cancel() 1347 1348 go srv.Start(ctx) 1349 time.Sleep(100 * time.Millisecond) 1350 1351 base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port()) 1352 1353 // Step 1: Set initial config with nested agent block 1354 initial := map[string]interface{}{ 1355 "agent": map[string]interface{}{ 1356 "model": "claude-3-5-sonnet", 1357 "max_iterations": 10, 1358 "temperature": 0.7, 1359 }, 1360 "top_level_key": "keep_me", 1361 } 1362 initialYAML, _ := yaml.Marshal(initial) 1363 os.WriteFile(filepath.Join(shannonDir, "config.yaml"), initialYAML, 0600) 1364 1365 // Step 2: PATCH only agent.model — should preserve max_iterations and temperature 1366 patch := `{"agent": {"model": "claude-4-opus"}}` 1367 req, _ := http.NewRequest("PATCH", base+"/config", strings.NewReader(patch)) 1368 req.Header.Set("Content-Type", "application/json") 1369 resp, err := http.DefaultClient.Do(req) 1370 if err != nil { 1371 t.Fatal(err) 1372 } 1373 resp.Body.Close() 1374 if resp.StatusCode != http.StatusOK { 1375 t.Fatalf("PATCH: expected 200, got %d", resp.StatusCode) 1376 } 1377 1378 // Step 3: Read config back and verify deep merge 1379 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 1380 if err != nil { 1381 t.Fatal(err) 1382 } 1383 var result map[string]interface{} 1384 if err := yaml.Unmarshal(data, &result); err != nil { 1385 t.Fatal(err) 1386 } 1387 1388 agentBlock, ok := result["agent"].(map[string]interface{}) 1389 if !ok { 1390 t.Fatalf("agent block not a map: %T", result["agent"]) 1391 } 1392 1393 // model should be updated 1394 if agentBlock["model"] != "claude-4-opus" { 1395 t.Errorf("model = %v, want claude-4-opus", agentBlock["model"]) 1396 } 1397 1398 // max_iterations and temperature should be preserved (deep merge) 1399 if agentBlock["max_iterations"] == nil { 1400 t.Error("max_iterations was lost during PATCH — shallow merge instead of deep merge") 1401 } 1402 if agentBlock["temperature"] == nil { 1403 t.Error("temperature was lost during PATCH — shallow merge instead of deep merge") 1404 } 1405 1406 // top_level_key should still be there 1407 if result["top_level_key"] != "keep_me" { 1408 t.Errorf("top_level_key = %v, want keep_me", result["top_level_key"]) 1409 } 1410 } 1411 1412 func TestServer_PatchConfig_NullDeletes(t *testing.T) { 1413 shannonDir := t.TempDir() 1414 sessDir := t.TempDir() 1415 deps := &ServerDeps{ 1416 ShannonDir: shannonDir, 1417 SessionCache: NewSessionCache(sessDir), 1418 Config: &config.Config{}, 1419 } 1420 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1421 srv := NewServer(0, c, deps, "test") 1422 ctx, cancel := context.WithCancel(context.Background()) 1423 defer cancel() 1424 1425 go srv.Start(ctx) 1426 time.Sleep(100 * time.Millisecond) 1427 1428 base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port()) 1429 1430 // Set initial config 1431 initial := map[string]interface{}{ 1432 "agent": map[string]interface{}{"model": "gpt-4"}, 1433 "to_delete": "bye", 1434 } 1435 initialYAML, _ := yaml.Marshal(initial) 1436 os.WriteFile(filepath.Join(shannonDir, "config.yaml"), initialYAML, 0600) 1437 1438 // PATCH with null to delete a key 1439 patch := `{"to_delete": null}` 1440 req, _ := http.NewRequest("PATCH", base+"/config", strings.NewReader(patch)) 1441 req.Header.Set("Content-Type", "application/json") 1442 resp, err := http.DefaultClient.Do(req) 1443 if err != nil { 1444 t.Fatal(err) 1445 } 1446 resp.Body.Close() 1447 if resp.StatusCode != http.StatusOK { 1448 t.Fatalf("PATCH: expected 200, got %d", resp.StatusCode) 1449 } 1450 1451 data, _ := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 1452 var result map[string]interface{} 1453 yaml.Unmarshal(data, &result) 1454 1455 if _, exists := result["to_delete"]; exists { 1456 t.Error("to_delete should have been removed by null patch") 1457 } 1458 if result["agent"] == nil { 1459 t.Error("agent block should still exist") 1460 } 1461 } 1462 1463 // --- Issue 3: request body size limit --- 1464 1465 func TestServer_BodySizeLimit(t *testing.T) { 1466 agentsDir := t.TempDir() 1467 sessDir := t.TempDir() 1468 deps := &ServerDeps{ 1469 AgentsDir: agentsDir, 1470 ShannonDir: t.TempDir(), 1471 SessionCache: NewSessionCache(sessDir), 1472 Config: &config.Config{}, 1473 } 1474 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1475 srv := NewServer(0, c, deps, "test") 1476 ctx, cancel := context.WithCancel(context.Background()) 1477 defer cancel() 1478 1479 go srv.Start(ctx) 1480 time.Sleep(100 * time.Millisecond) 1481 1482 base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port()) 1483 1484 // Send a body exceeding maxBodySize (50MB) to POST /agents — should be rejected 1485 bigBody := bytes.Repeat([]byte("x"), 51*1024*1024) 1486 payload := append([]byte(`{"name":"big","prompt":"`), bigBody...) 1487 payload = append(payload, '"', '}') 1488 1489 resp, err := http.Post(base+"/agents", "application/json", bytes.NewReader(payload)) 1490 if err != nil { 1491 t.Fatal(err) 1492 } 1493 defer resp.Body.Close() 1494 1495 // Should get 413 or 400 (body too large), not 201 1496 if resp.StatusCode == http.StatusCreated { 1497 t.Error("expected rejection for oversized body, got 201 Created") 1498 } 1499 if resp.StatusCode != http.StatusRequestEntityTooLarge { 1500 t.Logf("status = %d (acceptable if 400, ideal is 413)", resp.StatusCode) 1501 } 1502 } 1503 1504 func TestEventsSSEEndpoint(t *testing.T) { 1505 bus := NewEventBus() 1506 s := &Server{eventBus: bus} 1507 1508 handler := http.HandlerFunc(s.handleEvents) 1509 srv := httptest.NewServer(handler) 1510 defer srv.Close() 1511 1512 resp, err := http.Get(srv.URL) 1513 if err != nil { 1514 t.Fatal(err) 1515 } 1516 defer resp.Body.Close() 1517 1518 if resp.Header.Get("Content-Type") != "text/event-stream" { 1519 t.Fatalf("expected text/event-stream, got %s", resp.Header.Get("Content-Type")) 1520 } 1521 1522 // Wait for SSE handler to subscribe before emitting 1523 time.Sleep(50 * time.Millisecond) 1524 1525 bus.Emit(Event{ 1526 Type: EventAgentReply, 1527 Payload: json.RawMessage(`{"agent":"test","text":"hello"}`), 1528 }) 1529 1530 scanner := bufio.NewScanner(resp.Body) 1531 var eventLine, dataLine string 1532 for scanner.Scan() { 1533 line := scanner.Text() 1534 if strings.HasPrefix(line, "event:") { 1535 eventLine = line 1536 } 1537 if strings.HasPrefix(line, "data:") { 1538 dataLine = line 1539 break 1540 } 1541 } 1542 1543 if eventLine != "event: agent_reply" { 1544 t.Fatalf("expected 'event: agent_reply', got %q", eventLine) 1545 } 1546 if !strings.Contains(dataLine, `"agent":"test"`) { 1547 t.Fatalf("expected agent in data, got %q", dataLine) 1548 } 1549 } 1550 1551 // SSE endpoint must replay missed events when last_event_id is provided, 1552 // then switch to live events. This is the core of Desktop reconnection. 1553 func TestEventsSSEReplay(t *testing.T) { 1554 bus := NewEventBus() 1555 s := &Server{eventBus: bus} 1556 1557 // Pre-emit 5 events into ring buffer (IDs 1..5) before any client connects. 1558 for i := 0; i < 5; i++ { 1559 bus.Emit(Event{Type: "test", Payload: json.RawMessage(`{"seq":` + strconv.Itoa(i+1) + `}`)}) 1560 } 1561 1562 handler := http.HandlerFunc(s.handleEvents) 1563 srv := httptest.NewServer(handler) 1564 defer srv.Close() 1565 1566 // Connect with last_event_id=3 → expect replay of IDs 4, 5 1567 resp, err := http.Get(srv.URL + "?last_event_id=3") 1568 if err != nil { 1569 t.Fatal(err) 1570 } 1571 defer resp.Body.Close() 1572 1573 scanner := bufio.NewScanner(resp.Body) 1574 var replayed []uint64 1575 deadline := time.After(2 * time.Second) 1576 1577 for len(replayed) < 2 { 1578 lineCh := make(chan string, 1) 1579 go func() { 1580 if scanner.Scan() { 1581 lineCh <- scanner.Text() 1582 } 1583 }() 1584 select { 1585 case line := <-lineCh: 1586 if strings.HasPrefix(line, "id: ") { 1587 id, _ := strconv.ParseUint(strings.TrimPrefix(line, "id: "), 10, 64) 1588 replayed = append(replayed, id) 1589 } 1590 case <-deadline: 1591 t.Fatalf("timeout waiting for replayed events, got %d so far: %v", len(replayed), replayed) 1592 } 1593 } 1594 1595 if replayed[0] != 4 || replayed[1] != 5 { 1596 t.Fatalf("expected replayed IDs [4, 5], got %v", replayed) 1597 } 1598 } 1599 1600 // SSE endpoint must also support the standard Last-Event-ID header 1601 // (used by browser EventSource on reconnect). 1602 func TestEventsSSEReplayViaHeader(t *testing.T) { 1603 bus := NewEventBus() 1604 s := &Server{eventBus: bus} 1605 1606 for i := 0; i < 5; i++ { 1607 bus.Emit(Event{Type: "test", Payload: json.RawMessage(`{"seq":` + strconv.Itoa(i+1) + `}`)}) 1608 } 1609 1610 handler := http.HandlerFunc(s.handleEvents) 1611 srv := httptest.NewServer(handler) 1612 defer srv.Close() 1613 1614 // Use Last-Event-ID header instead of query param 1615 req, _ := http.NewRequest("GET", srv.URL, nil) 1616 req.Header.Set("Last-Event-ID", "3") 1617 resp, err := http.DefaultClient.Do(req) 1618 if err != nil { 1619 t.Fatal(err) 1620 } 1621 defer resp.Body.Close() 1622 1623 scanner := bufio.NewScanner(resp.Body) 1624 var replayed []uint64 1625 deadline := time.After(2 * time.Second) 1626 1627 for len(replayed) < 2 { 1628 lineCh := make(chan string, 1) 1629 go func() { 1630 if scanner.Scan() { 1631 lineCh <- scanner.Text() 1632 } 1633 }() 1634 select { 1635 case line := <-lineCh: 1636 if strings.HasPrefix(line, "id: ") { 1637 id, _ := strconv.ParseUint(strings.TrimPrefix(line, "id: "), 10, 64) 1638 replayed = append(replayed, id) 1639 } 1640 case <-deadline: 1641 t.Fatalf("timeout waiting for replayed events via header, got %d so far: %v", len(replayed), replayed) 1642 } 1643 } 1644 1645 if replayed[0] != 4 || replayed[1] != 5 { 1646 t.Fatalf("expected replayed IDs [4, 5], got %v", replayed) 1647 } 1648 } 1649 1650 // SSE endpoint without last_event_id must behave identically to before 1651 // (backward compatible — no replay, live events only). 1652 func TestEventsSSENoReplayWithoutParam(t *testing.T) { 1653 bus := NewEventBus() 1654 s := &Server{eventBus: bus} 1655 1656 // Pre-emit events 1657 for i := 0; i < 3; i++ { 1658 bus.Emit(Event{Type: "old", Payload: json.RawMessage(`{}`)}) 1659 } 1660 1661 handler := http.HandlerFunc(s.handleEvents) 1662 srv := httptest.NewServer(handler) 1663 defer srv.Close() 1664 1665 resp, err := http.Get(srv.URL) // no last_event_id 1666 if err != nil { 1667 t.Fatal(err) 1668 } 1669 defer resp.Body.Close() 1670 1671 // Wait for handler to subscribe 1672 time.Sleep(50 * time.Millisecond) 1673 1674 // Emit a live event 1675 bus.Emit(Event{Type: "live", Payload: json.RawMessage(`{"new":true}`)}) 1676 1677 scanner := bufio.NewScanner(resp.Body) 1678 deadline := time.After(2 * time.Second) 1679 var firstEventType string 1680 1681 for firstEventType == "" { 1682 lineCh := make(chan string, 1) 1683 go func() { 1684 if scanner.Scan() { 1685 lineCh <- scanner.Text() 1686 } 1687 }() 1688 select { 1689 case line := <-lineCh: 1690 if strings.HasPrefix(line, "event: ") { 1691 firstEventType = strings.TrimPrefix(line, "event: ") 1692 } 1693 case <-deadline: 1694 t.Fatal("timeout waiting for live event") 1695 } 1696 } 1697 1698 // Must receive the live event, not the old pre-emitted ones 1699 if firstEventType != "live" { 1700 t.Fatalf("expected first event type 'live', got %q (old events leaked without last_event_id)", firstEventType) 1701 } 1702 } 1703 1704 func TestNormalizePatchKeys(t *testing.T) { 1705 tests := []struct { 1706 name string 1707 input map[string]interface{} 1708 want map[string]interface{} 1709 applyTwice bool // set to verify idempotency 1710 }{ 1711 { 1712 name: "camelCase mcpServers renamed", 1713 input: map[string]interface{}{"mcpServers": map[string]interface{}{"x-twitter": map[string]interface{}{}}}, 1714 want: map[string]interface{}{"mcp_servers": map[string]interface{}{"x-twitter": map[string]interface{}{}}}, 1715 }, 1716 { 1717 name: "PascalCase MCPServers renamed", 1718 input: map[string]interface{}{"MCPServers": map[string]interface{}{}}, 1719 want: map[string]interface{}{"mcp_servers": map[string]interface{}{}}, 1720 }, 1721 { 1722 name: "apiKey renamed", 1723 input: map[string]interface{}{"apiKey": "sk_abc"}, 1724 want: map[string]interface{}{"api_key": "sk_abc"}, 1725 }, 1726 { 1727 name: "canonical snake_case unchanged", 1728 input: map[string]interface{}{"mcp_servers": map[string]interface{}{}, "api_key": "sk_abc"}, 1729 want: map[string]interface{}{"mcp_servers": map[string]interface{}{}, "api_key": "sk_abc"}, 1730 }, 1731 { 1732 name: "idempotent: applying twice gives same result", 1733 input: map[string]interface{}{"mcpServers": map[string]interface{}{"s": map[string]interface{}{}}}, 1734 want: map[string]interface{}{"mcp_servers": map[string]interface{}{"s": map[string]interface{}{}}}, 1735 applyTwice: true, 1736 }, 1737 { 1738 name: "alias + canonical both present: canonical wins, alias discarded", 1739 input: map[string]interface{}{"mcpServers": map[string]interface{}{"alias": map[string]interface{}{}}, "mcp_servers": map[string]interface{}{"canonical": map[string]interface{}{}}}, 1740 want: map[string]interface{}{"mcp_servers": map[string]interface{}{"canonical": map[string]interface{}{}}}, 1741 }, 1742 } 1743 for _, tt := range tests { 1744 t.Run(tt.name, func(t *testing.T) { 1745 normalizePatchKeys(tt.input) 1746 if tt.applyTwice { 1747 normalizePatchKeys(tt.input) 1748 } 1749 if len(tt.input) != len(tt.want) { 1750 t.Fatalf("key count mismatch: got %v, want %v", tt.input, tt.want) 1751 } 1752 for k := range tt.want { 1753 if _, ok := tt.input[k]; !ok { 1754 t.Errorf("missing expected key %q in result %v", k, tt.input) 1755 } 1756 } 1757 for k := range tt.input { 1758 if _, ok := tt.want[k]; !ok { 1759 t.Errorf("unexpected key %q in result %v", k, tt.input) 1760 } 1761 } 1762 }) 1763 } 1764 } 1765 1766 func TestStripRedactedSecrets(t *testing.T) { 1767 tests := []struct { 1768 name string 1769 input map[string]interface{} 1770 wantDeleted []string // top-level keys that should be absent 1771 wantKept []string // top-level keys that should still be present 1772 wantEnvDeleted []string // mcp_servers.x-twitter.env keys that should be absent 1773 wantEnvKept []string // mcp_servers.x-twitter.env keys that should still be present 1774 }{ 1775 { 1776 name: "api_key *** is dropped", 1777 input: map[string]interface{}{"api_key": "***"}, 1778 wantDeleted: []string{"api_key"}, 1779 }, 1780 { 1781 name: "api_key real value is kept", 1782 input: map[string]interface{}{"api_key": "sk_real"}, 1783 wantKept: []string{"api_key"}, 1784 }, 1785 { 1786 name: "mcp env *** dropped, real kept", 1787 input: map[string]interface{}{ 1788 "mcp_servers": map[string]interface{}{ 1789 "x-twitter": map[string]interface{}{ 1790 "env": map[string]interface{}{ 1791 "ACCESS_TOKEN": "***", 1792 "ACCESS_TOKEN2": "realvalue", 1793 }, 1794 }, 1795 }, 1796 }, 1797 wantEnvDeleted: []string{"ACCESS_TOKEN"}, 1798 wantEnvKept: []string{"ACCESS_TOKEN2"}, 1799 }, 1800 { 1801 name: "literal *** in non-sensitive field is kept", 1802 input: map[string]interface{}{"model_tier": "***"}, 1803 wantKept: []string{"model_tier"}, 1804 }, 1805 } 1806 for _, tt := range tests { 1807 t.Run(tt.name, func(t *testing.T) { 1808 stripRedactedSecrets(tt.input) 1809 for _, k := range tt.wantDeleted { 1810 if _, ok := tt.input[k]; ok { 1811 t.Errorf("expected key %q to be deleted, still present", k) 1812 } 1813 } 1814 for _, k := range tt.wantKept { 1815 if _, ok := tt.input[k]; !ok { 1816 t.Errorf("expected key %q to be kept, was deleted", k) 1817 } 1818 } 1819 if len(tt.wantEnvDeleted) > 0 || len(tt.wantEnvKept) > 0 { 1820 servers := tt.input["mcp_servers"].(map[string]interface{}) 1821 env := servers["x-twitter"].(map[string]interface{})["env"].(map[string]interface{}) 1822 for _, k := range tt.wantEnvDeleted { 1823 if _, ok := env[k]; ok { 1824 t.Errorf("expected env key %q to be dropped, still present", k) 1825 } 1826 } 1827 for _, k := range tt.wantEnvKept { 1828 if _, ok := env[k]; !ok { 1829 t.Errorf("expected env key %q to be kept, was deleted", k) 1830 } 1831 } 1832 } 1833 }) 1834 } 1835 } 1836 1837 func TestServer_EditMessage_Validation(t *testing.T) { 1838 shannonDir := t.TempDir() 1839 deps := &ServerDeps{ 1840 ShannonDir: shannonDir, 1841 SessionCache: NewSessionCache(shannonDir), 1842 } 1843 srv := &Server{deps: deps} 1844 1845 tests := []struct { 1846 name string 1847 sessionID string 1848 body string 1849 wantStatus int 1850 wantErr string 1851 }{ 1852 { 1853 name: "empty new_content and no content blocks", 1854 sessionID: "test-session", 1855 body: `{"message_index":0,"new_content":""}`, 1856 wantStatus: http.StatusBadRequest, 1857 wantErr: "new_content or content is required", 1858 }, 1859 { 1860 name: "empty new_content with content blocks passes validation", 1861 sessionID: "nonexistent", 1862 body: `{"message_index":0,"new_content":"","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]}`, 1863 wantStatus: http.StatusBadRequest, 1864 wantErr: "no such file or directory", 1865 }, 1866 { 1867 name: "valid new_content only passes validation", 1868 sessionID: "nonexistent", 1869 body: `{"message_index":0,"new_content":"hello"}`, 1870 wantStatus: http.StatusBadRequest, 1871 wantErr: "no such file or directory", 1872 }, 1873 { 1874 name: "valid new_content with content blocks passes validation", 1875 sessionID: "nonexistent", 1876 body: `{"message_index":0,"new_content":"analyze this","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]}`, 1877 wantStatus: http.StatusBadRequest, 1878 wantErr: "no such file or directory", 1879 }, 1880 { 1881 name: "missing session id", 1882 sessionID: "", 1883 body: `{"message_index":0,"new_content":"hello"}`, 1884 wantStatus: http.StatusBadRequest, 1885 wantErr: "session id required", 1886 }, 1887 { 1888 name: "invalid agent name", 1889 sessionID: "test-session", 1890 body: `{"message_index":0,"new_content":"hello","agent":"../evil"}`, 1891 wantStatus: http.StatusBadRequest, 1892 }, 1893 } 1894 1895 for _, tc := range tests { 1896 t.Run(tc.name, func(t *testing.T) { 1897 rec := httptest.NewRecorder() 1898 req := httptest.NewRequest(http.MethodPost, "/sessions/"+tc.sessionID+"/edit", strings.NewReader(tc.body)) 1899 req.Header.Set("Content-Type", "application/json") 1900 req.SetPathValue("id", tc.sessionID) 1901 1902 srv.handleEditMessage(rec, req) 1903 1904 if rec.Code != tc.wantStatus { 1905 t.Errorf("status = %d, want %d, body = %s", rec.Code, tc.wantStatus, rec.Body.String()) 1906 } 1907 if tc.wantErr != "" && !strings.Contains(rec.Body.String(), tc.wantErr) { 1908 t.Errorf("body = %q, want substring %q", rec.Body.String(), tc.wantErr) 1909 } 1910 }) 1911 } 1912 } 1913 1914 func TestRunSyncLoop_StaysAliveWhenInitiallyDisabled(t *testing.T) { 1915 // Regression: prior to the post-PR-78 fix, the goroutine returned early 1916 // if sync.enabled was false at startup, so flipping enabled=true via 1917 // config edit did nothing until daemon restart. The fix keeps the 1918 // goroutine alive and re-checks Enabled per tick. 1919 1920 viper.Reset() 1921 defer viper.Reset() 1922 1923 // Sync disabled at startup but with a valid (short) interval so the 1924 // ticker actually fires while the test is watching. 1925 viper.Set("sync.enabled", false) 1926 viper.Set("sync.daemon_interval", "100ms") 1927 viper.Set("sync.daemon_startup_delay", "0") 1928 1929 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1930 srv := NewServer(0, c, nil, "test") 1931 1932 ctx, cancel := context.WithCancel(context.Background()) 1933 defer cancel() 1934 1935 done := make(chan struct{}) 1936 go func() { 1937 srv.runSyncLoop(ctx) 1938 close(done) 1939 }() 1940 1941 // Wait through several ticker periods. Goroutine MUST still be running. 1942 // (Pre-fix: it returned within microseconds because !Enabled at startup.) 1943 select { 1944 case <-done: 1945 t.Fatalf("runSyncLoop returned early while sync.enabled=false — should stay alive for hot-enable") 1946 case <-time.After(500 * time.Millisecond): 1947 // Good: goroutine is still in its tick loop. 1948 } 1949 1950 // Cancel ctx and confirm goroutine exits promptly. 1951 cancel() 1952 select { 1953 case <-done: 1954 // Good. 1955 case <-time.After(2 * time.Second): 1956 t.Fatalf("runSyncLoop did not exit within 2s of ctx cancel") 1957 } 1958 } 1959 1960 func TestRunSyncLoop_ReturnsImmediatelyOnZeroInterval(t *testing.T) { 1961 // The only legitimate early-return path: misconfigured DaemonInterval <= 0. 1962 viper.Reset() 1963 defer viper.Reset() 1964 1965 viper.Set("sync.enabled", true) 1966 viper.Set("sync.daemon_interval", "0s") // misconfigured 1967 1968 c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil) 1969 srv := NewServer(0, c, nil, "test") 1970 1971 done := make(chan struct{}) 1972 go func() { 1973 srv.runSyncLoop(context.Background()) 1974 close(done) 1975 }() 1976 1977 select { 1978 case <-done: 1979 // Good: returned immediately on misconfig. 1980 case <-time.After(500 * time.Millisecond): 1981 t.Fatalf("runSyncLoop should return immediately when DaemonInterval <= 0") 1982 } 1983 } 1984 1985 func TestServer_PatchConfigSetsDaemonAutoApprove(t *testing.T) { 1986 shannonDir := t.TempDir() 1987 if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte("daemon:\n auto_approve: false\n"), 0o600); err != nil { 1988 t.Fatalf("write config: %v", err) 1989 } 1990 1991 srv := NewServer(0, nil, &ServerDeps{ShannonDir: shannonDir}, "test") 1992 1993 rec := httptest.NewRecorder() 1994 req := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(`{"daemon":{"auto_approve":true}}`)) 1995 req.Header.Set("Content-Type", "application/json") 1996 srv.handlePatchConfig(rec, req) 1997 1998 if rec.Code != http.StatusOK { 1999 t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String()) 2000 } 2001 var body map[string]string 2002 if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { 2003 t.Fatalf("decode body: %v", err) 2004 } 2005 if body["status"] != "updated" { 2006 t.Fatalf("unexpected response body: %v", body) 2007 } 2008 2009 data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml")) 2010 if err != nil { 2011 t.Fatalf("read config: %v", err) 2012 } 2013 if !strings.Contains(string(data), "auto_approve: true") { 2014 t.Fatalf("expected auto_approve: true in persisted config, got %s", data) 2015 } 2016 }