server.go
1 package daemon 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "log" 10 "maps" 11 "net" 12 "net/http" 13 "os" 14 "path/filepath" 15 "regexp" 16 "slices" 17 "sort" 18 "strconv" 19 "strings" 20 "sync" 21 "time" 22 23 "github.com/Kocoro-lab/ShanClaw/internal/agent" 24 "github.com/Kocoro-lab/ShanClaw/internal/agents" 25 "github.com/Kocoro-lab/ShanClaw/internal/audit" 26 "github.com/Kocoro-lab/ShanClaw/internal/client" 27 "github.com/Kocoro-lab/ShanClaw/internal/config" 28 ctxwin "github.com/Kocoro-lab/ShanClaw/internal/context" 29 "github.com/Kocoro-lab/ShanClaw/internal/mcp" 30 "github.com/Kocoro-lab/ShanClaw/internal/memory" 31 "github.com/Kocoro-lab/ShanClaw/internal/permissions" 32 "github.com/Kocoro-lab/ShanClaw/internal/schedule" 33 "github.com/Kocoro-lab/ShanClaw/internal/session" 34 "github.com/Kocoro-lab/ShanClaw/internal/skills" 35 syncpkg "github.com/Kocoro-lab/ShanClaw/internal/sync" 36 "github.com/Kocoro-lab/ShanClaw/internal/tools" 37 "github.com/spf13/viper" 38 "gopkg.in/yaml.v3" 39 ) 40 41 type Server struct { 42 port int 43 client *Client 44 deps *ServerDeps 45 server *http.Server 46 listenerMu sync.Mutex // protects listener 47 listener net.Listener 48 version string 49 ctx context.Context // daemon lifecycle context, set on Start 50 cancel context.CancelFunc 51 approvalBroker *ApprovalBroker 52 eventBus *EventBus 53 notifyApprovalResolved func(p ApprovalResolvedPayload) error 54 // pendingBrokers maps requestID → per-request ApprovalBroker. 55 // SSE handlers register here so POST /approval can find the right broker. 56 pendingBrokers sync.Map // map[string]*ApprovalBroker 57 onReload func() // called after config reload to restart watchers/heartbeat 58 59 marketplace *skills.MarketplaceClient 60 slugLocks *skills.SlugLocks 61 secretsStore *skills.SecretsStore 62 memSvc *memory.Service 63 } 64 65 // requireDeps returns true if s.deps is non-nil, otherwise writes a 500 66 // and returns false. Marketplace handlers dereference s.deps.ShannonDir 67 // and s.deps.AgentsDir; without this guard they'd panic when the server 68 // is constructed with nil deps (which some existing tests and callers 69 // do — NewServer stays nil-safe via resolveRegistryURL below, so the 70 // handlers must match that contract). 71 func (s *Server) requireDeps(w http.ResponseWriter) bool { 72 if s == nil || s.deps == nil { 73 writeError(w, http.StatusInternalServerError, "daemon not fully initialized") 74 return false 75 } 76 return true 77 } 78 79 // auditHTTPOp logs an HTTP API write operation to the audit log. 80 func (s *Server) auditHTTPOp(method, path, summary string) { 81 if s.deps == nil || s.deps.Auditor == nil { 82 return 83 } 84 s.deps.Auditor.Log(audit.AuditEntry{ 85 Timestamp: time.Now(), 86 ToolName: "http_api", 87 InputSummary: method + " " + path + ": " + summary, 88 Decision: "approved", 89 Approved: true, 90 }) 91 } 92 93 // resolveRegistryURL returns the configured marketplace registry URL, falling 94 // back to the public default. Tolerates nil deps / nil Config so tests that 95 // construct NewServer with nil deps continue to work. 96 func resolveRegistryURL(deps *ServerDeps) string { 97 const defaultURL = "https://raw.githubusercontent.com/Kocoro-lab/shanclaw-skill-registry/main/index.json" 98 if deps == nil || deps.Config == nil { 99 return defaultURL 100 } 101 if u := deps.Config.Skills.Marketplace.RegistryURL; u != "" { 102 return u 103 } 104 return defaultURL 105 } 106 107 var ( 108 showChromeOnPortFn = mcp.ShowCDPChromeOnPort 109 hideChromeOnPortFn = mcp.HideCDPChromeOnPort 110 getChromeStatusOnPortFn = mcp.GetCDPChromeStatusOnPort 111 getChromeProfileStateFn = mcp.GetChromeProfileState 112 stopChromeFn = mcp.StopCDPChrome 113 resetChromeProfileCloneFn = mcp.ResetCDPProfileClone 114 ) 115 116 func NewServer(port int, client *Client, deps *ServerDeps, version string) *Server { 117 var shannonDir string 118 if deps != nil { 119 shannonDir = deps.ShannonDir 120 } 121 store := skills.NewSecretsStore(shannonDir) 122 if deps != nil { 123 deps.SecretsStore = store 124 } 125 return &Server{ 126 port: port, 127 client: client, 128 deps: deps, 129 version: version, 130 approvalBroker: NewApprovalBroker(func(req ApprovalRequest) error { return nil }), 131 eventBus: NewEventBus(), 132 notifyApprovalResolved: func(p ApprovalResolvedPayload) error { return nil }, 133 marketplace: skills.NewMarketplaceClient(resolveRegistryURL(deps), 1*time.Hour), 134 slugLocks: skills.NewSlugLocks(), 135 secretsStore: store, 136 } 137 } 138 139 func (s *Server) chromeControlPort() int { 140 if s == nil || s.deps == nil { 141 return mcp.DefaultCDPPort 142 } 143 cfg, _, _ := s.deps.Snapshot() 144 if cfg == nil || cfg.MCPServers == nil { 145 return mcp.DefaultCDPPort 146 } 147 playwright, ok := cfg.MCPServers["playwright"] 148 if !ok { 149 return mcp.DefaultCDPPort 150 } 151 return mcp.PlaywrightCDPPort(mcp.NormalizePlaywrightCDPConfig(playwright)) 152 } 153 154 func (s *Server) configuredChromeProfile() string { 155 if s == nil || s.deps == nil { 156 return "" 157 } 158 cfg, _, _ := s.deps.Snapshot() 159 if cfg == nil { 160 return "" 161 } 162 return cfg.Daemon.ChromeProfile 163 } 164 165 func (s *Server) setConfiguredChromeProfile(profile string) { 166 if s == nil || s.deps == nil { 167 return 168 } 169 s.deps.WriteLock() 170 if s.deps.Config == nil { 171 s.deps.Config = &config.Config{} 172 } 173 s.deps.Config.Daemon.ChromeProfile = profile 174 mcp.SetCDPChromeProfile(profile) 175 s.deps.WriteUnlock() 176 } 177 178 // SetApprovalResolvedNotifier sets the function called to notify Cloud when 179 // Ptfrog resolves an approval before the external channel does. 180 func (s *Server) SetApprovalResolvedNotifier(fn func(ApprovalResolvedPayload) error) { 181 s.notifyApprovalResolved = fn 182 } 183 184 func (s *Server) Port() int { 185 s.listenerMu.Lock() 186 ln := s.listener 187 s.listenerMu.Unlock() 188 if ln != nil { 189 return ln.Addr().(*net.TCPAddr).Port 190 } 191 return s.port 192 } 193 194 // SetCancelFunc sets a cancel function that handleShutdown will call to stop the daemon. 195 func (s *Server) SetCancelFunc(cancel context.CancelFunc) { 196 s.cancel = cancel 197 } 198 199 // SetOnReload sets a callback invoked after config reload to restart watchers/heartbeat. 200 func (s *Server) SetOnReload(fn func()) { 201 s.onReload = fn 202 } 203 204 func (s *Server) Start(ctx context.Context) error { 205 s.ctx = ctx 206 mux := http.NewServeMux() 207 mux.HandleFunc("GET /health", s.handleHealth) 208 mux.HandleFunc("GET /status", s.handleStatus) 209 mux.HandleFunc("GET /agents", s.handleAgents) 210 mux.HandleFunc("GET /agents/{name}", s.handleGetAgent) 211 mux.HandleFunc("POST /agents", s.handleCreateAgent) 212 mux.HandleFunc("PUT /agents/{name}", s.handleUpdateAgent) 213 mux.HandleFunc("DELETE /agents/{name}", s.handleDeleteAgent) 214 mux.HandleFunc("PUT /agents/{name}/config", s.handlePutAgentConfig) 215 mux.HandleFunc("DELETE /agents/{name}/config", s.handleDeleteAgentConfig) 216 mux.HandleFunc("PUT /agents/{name}/commands/{cmd}", s.handlePutCommand) 217 mux.HandleFunc("DELETE /agents/{name}/commands/{cmd}", s.handleDeleteCommand) 218 mux.HandleFunc("PUT /agents/{name}/skills/{skill}", s.handlePutSkill) 219 mux.HandleFunc("DELETE /agents/{name}/skills/{skill}", s.handleDeleteSkill) 220 mux.HandleFunc("GET /skills/downloadable", s.handleListDownloadableSkills) 221 mux.HandleFunc("POST /skills/install/{name}", s.handleInstallSkill) 222 mux.HandleFunc("POST /skills/marketplace/install/{slug}", s.handleMarketplaceInstall) 223 mux.HandleFunc("GET /skills/marketplace", s.handleMarketplaceList) 224 mux.HandleFunc("GET /skills/marketplace/entry/{slug}", s.handleMarketplaceDetail) 225 mux.HandleFunc("GET /skills", s.handleListSkills) 226 mux.HandleFunc("GET /skills/{name}", s.handleGetSkill) 227 mux.HandleFunc("PUT /skills/{name}", s.handlePutGlobalSkill) 228 mux.HandleFunc("DELETE /skills/{name}", s.handleDeleteGlobalSkill) 229 mux.HandleFunc("GET /skills/{name}/scripts", s.handleListSkillScripts) 230 mux.HandleFunc("PUT /skills/{name}/scripts/{filename}", s.handlePutSkillScripts) 231 mux.HandleFunc("DELETE /skills/{name}/scripts/{filename}", s.handleDeleteSkillScripts) 232 mux.HandleFunc("PUT /skills/{name}/secrets", s.handlePutSkillSecrets) 233 mux.HandleFunc("DELETE /skills/{name}/secrets", s.handleDeleteSkillSecrets) 234 mux.HandleFunc("DELETE /skills/{name}/secrets/{key}", s.handleDeleteSkillSecretKey) 235 mux.HandleFunc("GET /skills/{name}/references", s.handleListSkillReferences) 236 mux.HandleFunc("PUT /skills/{name}/references/{filename}", s.handlePutSkillReferences) 237 mux.HandleFunc("DELETE /skills/{name}/references/{filename}", s.handleDeleteSkillReferences) 238 mux.HandleFunc("GET /skills/{name}/assets", s.handleListSkillAssets) 239 mux.HandleFunc("GET /skills/{name}/usage", s.handleSkillUsage) 240 mux.HandleFunc("PUT /skills/{name}/assets/{filename}", s.handlePutSkillAssets) 241 mux.HandleFunc("DELETE /skills/{name}/assets/{filename}", s.handleDeleteSkillAssets) 242 mux.HandleFunc("GET /schedules", s.handleListSchedules) 243 mux.HandleFunc("GET /schedules/{id}", s.handleGetSchedule) 244 mux.HandleFunc("POST /schedules", s.handleCreateSchedule) 245 mux.HandleFunc("PATCH /schedules/{id}", s.handlePatchSchedule) 246 mux.HandleFunc("DELETE /schedules/{id}", s.handleDeleteSchedule) 247 mux.HandleFunc("GET /config", s.handleGetConfig) 248 mux.HandleFunc("GET /config/status", s.handleConfigStatus) 249 mux.HandleFunc("PATCH /config", s.handlePatchConfig) 250 mux.HandleFunc("POST /config/reload", s.handleConfigReload) 251 mux.HandleFunc("GET /instructions", s.handleGetInstructions) 252 mux.HandleFunc("PUT /instructions", s.handlePutInstructions) 253 mux.HandleFunc("GET /rules", s.handleListRules) 254 mux.HandleFunc("GET /rules/{name}", s.handleGetRule) 255 mux.HandleFunc("PUT /rules/{name}", s.handlePutRule) 256 mux.HandleFunc("DELETE /rules/{name}", s.handleDeleteRule) 257 mux.HandleFunc("POST /project/init", s.handleProjectInit) 258 mux.HandleFunc("GET /sessions", s.handleSessions) 259 mux.HandleFunc("GET /sessions/{id}", s.handleGetSession) 260 mux.HandleFunc("DELETE /sessions/{id}", s.handleDeleteSession) 261 mux.HandleFunc("PATCH /sessions/{id}", s.handlePatchSession) 262 mux.HandleFunc("POST /sessions/{id}/edit", s.handleEditMessage) 263 mux.HandleFunc("POST /sessions/{id}/reset", s.handleResetSession) 264 mux.HandleFunc("GET /sessions/{id}/summary", s.handleSessionSummary) 265 mux.HandleFunc("GET /sessions/search", s.handleSessionSearch) 266 mux.HandleFunc("GET /permissions", s.handlePermissions) 267 mux.HandleFunc("POST /permissions/request", s.handlePermissionsRequest) 268 mux.HandleFunc("POST /approval", s.handleApproval) 269 mux.HandleFunc("POST /message", s.handleMessage) 270 mux.HandleFunc("POST /cancel", s.handleCancel) 271 mux.HandleFunc("GET /events", s.handleEvents) 272 mux.HandleFunc("GET /chrome/status", s.handleChromeStatus) 273 mux.HandleFunc("GET /chrome/profile", s.handleChromeProfile) 274 mux.HandleFunc("POST /chrome/profile", s.handleChromeProfileUpdate) 275 mux.HandleFunc("POST /chrome/profile/refresh", s.handleChromeProfileRefresh) 276 mux.HandleFunc("POST /chrome/show", s.handleChromeShow) 277 mux.HandleFunc("POST /chrome/hide", s.handleChromeHide) 278 mux.HandleFunc("POST /shutdown", s.handleShutdown) 279 280 ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", s.port)) 281 if err != nil { 282 return fmt.Errorf("daemon server listen: %w", err) 283 } 284 s.listenerMu.Lock() 285 s.listener = ln 286 s.listenerMu.Unlock() 287 s.server = &http.Server{Handler: mux} 288 289 // Spawn the gated session-sync ticker. It self-disables when sync is 290 // not enabled, so it's always safe to start unconditionally here. 291 go s.runSyncLoop(ctx) 292 293 // Memory feature (Phase 2.3). Service is constructed once and Start runs 294 // the cold-path gates synchronously then spawns the supervisor goroutine. 295 // Failure modes (provider=disabled, tlm missing, cloud misconfigured) all 296 // resolve to Status=Disabled/Unavailable; the memory tool falls back. 297 memCfg := memory.LoadConfig(viper.GetViper()) 298 memCfg.APIKey = memory.ResolveAPIKey(viper.GetViper()) 299 memCfg.Endpoint = memory.ResolveEndpoint(viper.GetViper()) 300 var memAudit memory.AuditLogger 301 if s.deps != nil && s.deps.Auditor != nil { 302 memAudit = memoryAuditAdapter{logger: s.deps.Auditor} 303 } 304 s.memSvc = memory.NewService(memCfg, memAudit) 305 if s.deps != nil { 306 s.deps.MemSvc = s.memSvc 307 } 308 go func() { 309 if err := s.memSvc.Start(ctx); err != nil { 310 log.Printf("daemon memory: start error: %v", err) 311 } 312 }() 313 314 go func() { 315 <-ctx.Done() 316 // Stop the memory sidecar before HTTP shutdown so SIGTERM reaches the 317 // child process while the daemon is still alive to drain its exit. 318 if s.memSvc != nil { 319 _ = s.memSvc.Stop() 320 } 321 shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 322 defer cancel() 323 s.server.Shutdown(shutCtx) 324 }() 325 326 if err := s.server.Serve(ln); err != http.ErrServerClosed { 327 return err 328 } 329 return nil 330 } 331 332 func (s *Server) handleChromeShow(w http.ResponseWriter, r *http.Request) { 333 w.Header().Set("Content-Type", "application/json") 334 if err := showChromeOnPortFn(s.chromeControlPort()); err != nil { 335 if errors.Is(err, mcp.ErrChromeNotRunning) { 336 w.WriteHeader(http.StatusNotFound) 337 json.NewEncoder(w).Encode(map[string]string{"error": "chrome_not_running"}) 338 } else { 339 w.WriteHeader(http.StatusInternalServerError) 340 json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) 341 } 342 return 343 } 344 json.NewEncoder(w).Encode(map[string]string{"status": "visible"}) 345 } 346 347 func (s *Server) handleChromeHide(w http.ResponseWriter, r *http.Request) { 348 w.Header().Set("Content-Type", "application/json") 349 if err := hideChromeOnPortFn(s.chromeControlPort()); err != nil { 350 if errors.Is(err, mcp.ErrChromeNotRunning) { 351 w.WriteHeader(http.StatusNotFound) 352 json.NewEncoder(w).Encode(map[string]string{"error": "chrome_not_running"}) 353 } else { 354 w.WriteHeader(http.StatusInternalServerError) 355 json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) 356 } 357 return 358 } 359 json.NewEncoder(w).Encode(map[string]string{"status": "hidden"}) 360 } 361 362 func (s *Server) handleChromeStatus(w http.ResponseWriter, r *http.Request) { 363 w.Header().Set("Content-Type", "application/json") 364 status := getChromeStatusOnPortFn(s.chromeControlPort()) 365 json.NewEncoder(w).Encode(map[string]interface{}{ 366 "running": status.Running, 367 "visible": status.Visible, 368 "probe_error": status.ProbeError, 369 }) 370 } 371 372 func (s *Server) handleChromeProfile(w http.ResponseWriter, r *http.Request) { 373 state, err := getChromeProfileStateFn(s.configuredChromeProfile()) 374 if err != nil { 375 writeError(w, http.StatusInternalServerError, err.Error()) 376 return 377 } 378 writeJSON(w, http.StatusOK, state) 379 } 380 381 func (s *Server) handleChromeProfileUpdate(w http.ResponseWriter, r *http.Request) { 382 var req struct { 383 Mode string `json:"mode"` 384 Profile string `json:"profile,omitempty"` 385 } 386 if !decodeBody(w, r, &req) { 387 return 388 } 389 switch req.Mode { 390 case "auto": 391 req.Profile = "" 392 case "explicit": 393 if !mcp.ValidChromeProfileName(req.Profile) { 394 writeError(w, http.StatusBadRequest, "invalid chrome profile name") 395 return 396 } 397 state, err := getChromeProfileStateFn("") 398 if err != nil { 399 writeError(w, http.StatusInternalServerError, err.Error()) 400 return 401 } 402 found := false 403 for _, profile := range state.Profiles { 404 if profile.Name == req.Profile { 405 found = true 406 break 407 } 408 } 409 if !found { 410 writeError(w, http.StatusBadRequest, "chrome profile not found") 411 return 412 } 413 default: 414 writeError(w, http.StatusBadRequest, `mode must be "auto" or "explicit"`) 415 return 416 } 417 418 patch := map[string]interface{}{ 419 "daemon": map[string]interface{}{ 420 "chrome_profile": nil, 421 }, 422 } 423 if req.Profile != "" { 424 patch["daemon"] = map[string]interface{}{ 425 "chrome_profile": req.Profile, 426 } 427 } 428 prevProfile := s.configuredChromeProfile() 429 if err := s.patchGlobalConfig(patch); err != nil { 430 writeError(w, http.StatusInternalServerError, err.Error()) 431 return 432 } 433 s.setConfiguredChromeProfile(req.Profile) 434 stopChromeFn() 435 if err := resetChromeProfileCloneFn(); err != nil { 436 rollbackPatch := map[string]interface{}{ 437 "daemon": map[string]interface{}{ 438 "chrome_profile": nil, 439 }, 440 } 441 if prevProfile != "" { 442 rollbackPatch["daemon"] = map[string]interface{}{ 443 "chrome_profile": prevProfile, 444 } 445 } 446 if rollbackErr := s.patchGlobalConfig(rollbackPatch); rollbackErr != nil { 447 writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to refresh chrome profile clone: %v (rollback failed: %v)", err, rollbackErr)) 448 return 449 } 450 s.setConfiguredChromeProfile(prevProfile) 451 writeError(w, http.StatusInternalServerError, err.Error()) 452 return 453 } 454 455 state, err := getChromeProfileStateFn(req.Profile) 456 if err != nil { 457 writeError(w, http.StatusInternalServerError, err.Error()) 458 return 459 } 460 writeJSON(w, http.StatusOK, state) 461 } 462 463 func (s *Server) handleChromeProfileRefresh(w http.ResponseWriter, r *http.Request) { 464 stopChromeFn() 465 if err := resetChromeProfileCloneFn(); err != nil { 466 writeError(w, http.StatusInternalServerError, err.Error()) 467 return 468 } 469 state, err := getChromeProfileStateFn(s.configuredChromeProfile()) 470 if err != nil { 471 writeError(w, http.StatusInternalServerError, err.Error()) 472 return 473 } 474 writeJSON(w, http.StatusOK, state) 475 } 476 477 func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) { 478 w.Header().Set("Content-Type", "application/json") 479 json.NewEncoder(w).Encode(map[string]string{"status": "shutting_down"}) 480 if s.cancel != nil { 481 log.Println("daemon: shutdown requested via /shutdown") 482 mcp.StopCDPChrome() 483 go s.cancel() 484 } 485 } 486 487 func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) { 488 var req struct { 489 RouteKey string `json:"route_key,omitempty"` 490 SessionID string `json:"session_id,omitempty"` 491 Agent string `json:"agent,omitempty"` 492 } 493 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 494 http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) 495 return 496 } 497 498 key := req.RouteKey 499 if key == "" && req.SessionID != "" { 500 key = "session:" + sanitizeRouteValue(req.SessionID) 501 } 502 if key == "" && req.Agent != "" { 503 key = "agent:" + sanitizeRouteValue(req.Agent) 504 } 505 if key == "" { 506 http.Error(w, `{"error":"route_key, session_id, or agent required"}`, http.StatusBadRequest) 507 return 508 } 509 510 s.deps.SessionCache.CancelRoute(key) 511 w.Header().Set("Content-Type", "application/json") 512 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "route": key}) 513 } 514 515 func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { 516 var req ApprovalResponse 517 if !decodeBody(w, r, &req) { 518 return 519 } 520 if req.RequestID == "" { 521 http.Error(w, `{"error":"request_id required"}`, http.StatusBadRequest) 522 return 523 } 524 switch req.Decision { 525 case DecisionAllow, DecisionDeny, DecisionAlwaysAllow: 526 default: 527 http.Error(w, `{"error":"decision must be allow, deny, or always_allow"}`, http.StatusBadRequest) 528 return 529 } 530 // Notify Cloud and emit event BEFORE unblocking the agent. 531 // This ensures ShanClaw dismisses the approval card before seeing the agent reply. 532 _ = s.notifyApprovalResolved(ApprovalResolvedPayload{ 533 RequestID: req.RequestID, 534 Decision: req.Decision, 535 ResolvedBy: "shanclaw", 536 }) 537 538 if s.eventBus != nil { 539 payload, _ := json.Marshal(map[string]string{ 540 "request_id": req.RequestID, 541 "decision": string(req.Decision), 542 "resolved_by": "shanclaw", 543 }) 544 s.eventBus.Emit(Event{Type: EventApprovalResolved, Payload: payload}) 545 } 546 547 // Look up the per-request broker (SSE path) or fall back to server broker (WS path). 548 if b, ok := s.pendingBrokers.Load(req.RequestID); ok { 549 b.(*ApprovalBroker).Resolve(req.RequestID, req.Decision) 550 } else { 551 s.approvalBroker.Resolve(req.RequestID, req.Decision) 552 } 553 554 w.Header().Set("Content-Type", "application/json") 555 w.Write([]byte(`{"ok":true}`)) 556 } 557 558 func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { 559 flusher, ok := w.(http.Flusher) 560 if !ok { 561 http.Error(w, "streaming not supported", http.StatusInternalServerError) 562 return 563 } 564 565 w.Header().Set("Content-Type", "text/event-stream") 566 w.Header().Set("Cache-Control", "no-cache") 567 w.Header().Set("Connection", "keep-alive") 568 flusher.Flush() 569 570 // Subscribe with atomic replay if client provides a last event ID. 571 // Check both query param (custom clients) and Last-Event-ID header 572 // (standard SSE EventSource reconnection per spec). 573 var ch <-chan Event 574 lastIDStr := r.URL.Query().Get("last_event_id") 575 if lastIDStr == "" { 576 lastIDStr = r.Header.Get("Last-Event-ID") 577 } 578 if lastIDStr != "" { 579 if lastID, err := strconv.ParseUint(lastIDStr, 10, 64); err == nil { 580 var missed []Event 581 missed, ch = s.eventBus.SubscribeWithReplay(lastID) 582 for _, evt := range missed { 583 fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", evt.ID, evt.Type, string(evt.Payload)) 584 } 585 flusher.Flush() 586 } 587 } 588 if ch == nil { 589 ch = s.eventBus.Subscribe() 590 } 591 defer s.eventBus.Unsubscribe(ch) 592 593 ticker := time.NewTicker(30 * time.Second) 594 defer ticker.Stop() 595 596 for { 597 select { 598 case evt := <-ch: 599 fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", evt.ID, evt.Type, string(evt.Payload)) 600 flusher.Flush() 601 case <-ticker.C: 602 fmt.Fprintf(w, ": keepalive\n\n") 603 flusher.Flush() 604 case <-r.Context().Done(): 605 return 606 } 607 } 608 } 609 610 // EventBus returns the server's EventBus for emitting events. 611 func (s *Server) EventBus() *EventBus { 612 return s.eventBus 613 } 614 615 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 616 w.Header().Set("Content-Type", "application/json") 617 json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": s.version}) 618 } 619 620 func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 621 w.Header().Set("Content-Type", "application/json") 622 json.NewEncoder(w).Encode(map[string]interface{}{ 623 "is_connected": s.client.IsConnected(), 624 "active_agent": s.client.ActiveAgent(), 625 "uptime": int(s.client.Uptime().Seconds()), 626 "version": s.version, 627 }) 628 } 629 630 // handleAgents lists available agents with optional memory status. 631 func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { 632 w.Header().Set("Content-Type", "application/json") 633 634 if s.deps == nil { 635 json.NewEncoder(w).Encode(map[string]interface{}{"agents": []interface{}{}}) 636 return 637 } 638 639 entries, err := agents.ListAgents(s.deps.AgentsDir) 640 if err != nil { 641 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) 642 return 643 } 644 645 type agentInfo struct { 646 Name string `json:"name"` 647 Builtin bool `json:"builtin"` 648 Override bool `json:"override"` 649 HasMemory bool `json:"has_memory"` 650 HasConfig bool `json:"has_config"` 651 CommandCount int `json:"command_count"` 652 SkillCount int `json:"skill_count"` 653 } 654 result := make([]agentInfo, 0, len(entries)) 655 for _, entry := range entries { 656 // Resolve effective directory for definition files 657 dir := filepath.Join(s.deps.AgentsDir, entry.Name) 658 if entry.Builtin { 659 dir = filepath.Join(s.deps.AgentsDir, "_builtin", entry.Name) 660 } 661 // Memory is always in top-level runtime dir 662 runtimeDir := filepath.Join(s.deps.AgentsDir, entry.Name) 663 _, memErr := os.Stat(filepath.Join(runtimeDir, "MEMORY.md")) 664 _, cfgErr := os.Stat(filepath.Join(dir, "config.yaml")) 665 cmdFiles, _ := filepath.Glob(filepath.Join(dir, "commands", "*.md")) 666 skillFiles, _ := filepath.Glob(filepath.Join(dir, "skills", "*", "SKILL.md")) 667 result = append(result, agentInfo{ 668 Name: entry.Name, 669 Builtin: entry.Builtin, 670 Override: entry.Override, 671 HasMemory: memErr == nil, 672 HasConfig: cfgErr == nil, 673 CommandCount: len(cmdFiles), 674 SkillCount: len(skillFiles), 675 }) 676 } 677 json.NewEncoder(w).Encode(map[string]interface{}{"agents": result}) 678 } 679 680 // handleSessions lists sessions, optionally filtered by agent. 681 func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { 682 w.Header().Set("Content-Type", "application/json") 683 684 if s.deps == nil { 685 json.NewEncoder(w).Encode(map[string]interface{}{"sessions": []interface{}{}}) 686 return 687 } 688 689 agentName := r.URL.Query().Get("agent") 690 if agentName != "" { 691 if err := agents.ValidateAgentName(agentName); err != nil { 692 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) 693 return 694 } 695 } 696 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 697 summaries, err := mgr.List() 698 if err != nil { 699 // If the directory doesn't exist, return empty list. 700 if os.IsNotExist(err) { 701 json.NewEncoder(w).Encode(map[string]interface{}{"sessions": []interface{}{}}) 702 return 703 } 704 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) 705 return 706 } 707 // Filter out empty sessions (created but never used). 708 filtered := make([]session.SessionSummary, 0, len(summaries)) 709 for _, s := range summaries { 710 if s.MsgCount > 0 { 711 filtered = append(filtered, s) 712 } 713 } 714 json.NewEncoder(w).Encode(map[string]interface{}{"sessions": filtered}) 715 } 716 717 // handleGetSession 返回指定 session 的完整内容(包含消息列表)。 718 // 前端可通过消息数组的下标作为 message_index 传给 POST /sessions/{id}/edit。 719 func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { 720 if s.deps == nil { 721 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 722 return 723 } 724 id := r.PathValue("id") 725 if id == "" { 726 writeError(w, http.StatusBadRequest, "session id required") 727 return 728 } 729 // 防止路径穿越 730 if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) { 731 writeError(w, http.StatusBadRequest, "invalid session id") 732 return 733 } 734 agentName := r.URL.Query().Get("agent") 735 if agentName != "" { 736 if err := agents.ValidateAgentName(agentName); err != nil { 737 writeError(w, http.StatusBadRequest, err.Error()) 738 return 739 } 740 } 741 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 742 sess, err := mgr.Load(id) 743 if err != nil { 744 if os.IsNotExist(err) { 745 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 746 return 747 } 748 writeError(w, http.StatusInternalServerError, err.Error()) 749 return 750 } 751 writeJSON(w, http.StatusOK, sess) 752 } 753 754 func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { 755 if s.deps == nil { 756 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 757 return 758 } 759 id := r.PathValue("id") 760 if id == "" { 761 writeError(w, http.StatusBadRequest, "session id required") 762 return 763 } 764 // Prevent path traversal — session IDs must be safe filenames. 765 if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) { 766 writeError(w, http.StatusBadRequest, "invalid session id") 767 return 768 } 769 agentName := r.URL.Query().Get("agent") 770 if agentName != "" { 771 if err := agents.ValidateAgentName(agentName); err != nil { 772 writeError(w, http.StatusBadRequest, err.Error()) 773 return 774 } 775 } 776 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 777 if err := mgr.Delete(id); err != nil { 778 if os.IsNotExist(err) { 779 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 780 return 781 } 782 writeError(w, http.StatusInternalServerError, err.Error()) 783 return 784 } 785 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 786 } 787 788 // handleResetSession clears a named-agent session's conversation history in 789 // place while preserving ID/Title/CWD and other metadata. 790 // Query: ?agent=<name> (required) — default-agent sessions should be discarded 791 // via DELETE /sessions/{id} instead; this endpoint is only for named agents 792 // whose routing identity must survive the wipe. 793 // Active runs are cancelled before the history is cleared. 794 // 795 // Known race (matches handleEditMessage): CancelBySessionID only fires the 796 // cancel signal and does not wait for the agent loop to exit. If the loop is 797 // in a mid-turn checkpoint save, its Save() may land after Reset(), leaving 798 // InProgress set or partial history re-applied. Callers should ensure no run 799 // is active before invoking /reset; a second /reset clears any residue. A 800 // proper barrier belongs in SessionCache and is out of scope here. 801 func (s *Server) handleResetSession(w http.ResponseWriter, r *http.Request) { 802 if s.deps == nil { 803 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 804 return 805 } 806 id := r.PathValue("id") 807 if id == "" { 808 writeError(w, http.StatusBadRequest, "session id required") 809 return 810 } 811 if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) { 812 writeError(w, http.StatusBadRequest, "invalid session id") 813 return 814 } 815 agentName := r.URL.Query().Get("agent") 816 if agentName == "" { 817 writeError(w, http.StatusBadRequest, "agent query parameter is required; use DELETE /sessions/{id} to discard a default-agent session") 818 return 819 } 820 if err := agents.ValidateAgentName(agentName); err != nil { 821 writeError(w, http.StatusBadRequest, err.Error()) 822 return 823 } 824 825 s.deps.SessionCache.CancelBySessionID(id) 826 827 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 828 if err := mgr.Reset(id); err != nil { 829 if os.IsNotExist(err) { 830 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 831 return 832 } 833 writeError(w, http.StatusInternalServerError, err.Error()) 834 return 835 } 836 writeJSON(w, http.StatusOK, map[string]string{"status": "reset", "id": id}) 837 } 838 839 func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) { 840 if s.deps == nil { 841 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 842 return 843 } 844 id := r.PathValue("id") 845 if id == "" { 846 writeError(w, http.StatusBadRequest, "session id required") 847 return 848 } 849 // Prevent path traversal — session ID must be a safe filename. 850 if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) { 851 writeError(w, http.StatusBadRequest, "invalid session id") 852 return 853 } 854 var body struct { 855 Title string `json:"title"` 856 } 857 if !decodeBody(w, r, &body) { 858 return 859 } 860 title := strings.TrimSpace(body.Title) 861 if title == "" { 862 writeError(w, http.StatusBadRequest, "title cannot be empty") 863 return 864 } 865 agentName := r.URL.Query().Get("agent") 866 if agentName != "" { 867 if err := agents.ValidateAgentName(agentName); err != nil { 868 writeError(w, http.StatusBadRequest, err.Error()) 869 return 870 } 871 } 872 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 873 if err := mgr.PatchTitle(id, title); err != nil { 874 if os.IsNotExist(err) { 875 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 876 return 877 } 878 writeError(w, http.StatusInternalServerError, err.Error()) 879 return 880 } 881 writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "title": title}) 882 } 883 884 func (s *Server) handleSessionSearch(w http.ResponseWriter, r *http.Request) { 885 w.Header().Set("Content-Type", "application/json") 886 if s.deps == nil { 887 json.NewEncoder(w).Encode(map[string]interface{}{"results": []interface{}{}}) 888 return 889 } 890 891 agentName := r.URL.Query().Get("agent") 892 if agentName != "" { 893 if err := agents.ValidateAgentName(agentName); err != nil { 894 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) 895 return 896 } 897 } 898 query := r.URL.Query().Get("q") 899 if query == "" { 900 http.Error(w, `{"error":"q parameter required"}`, http.StatusBadRequest) 901 return 902 } 903 904 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 905 results, err := mgr.Search(query, 20) 906 if err != nil { 907 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) 908 return 909 } 910 if results == nil { 911 results = []session.SearchResult{} 912 } 913 json.NewEncoder(w).Encode(map[string]interface{}{"results": results}) 914 } 915 916 // handleEditMessage truncates session history and re-runs the agent with new content. 917 // Body: {"message_index": N, "new_content": "...", "content": [...], "agent": "optional"} 918 // message_index keeps the first N messages; everything after is discarded. 919 // content is an optional array of multimodal blocks (images, files, etc.), same format as POST /message. 920 func (s *Server) handleEditMessage(w http.ResponseWriter, r *http.Request) { 921 if s.deps == nil { 922 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 923 return 924 } 925 id := r.PathValue("id") 926 if id == "" { 927 writeError(w, http.StatusBadRequest, "session id required") 928 return 929 } 930 // 防止路径穿越 931 if id != filepath.Base(id) || strings.ContainsAny(id, `/\`) { 932 writeError(w, http.StatusBadRequest, "invalid session id") 933 return 934 } 935 936 var body struct { 937 MessageIndex int `json:"message_index"` 938 NewContent string `json:"new_content"` 939 Content []RequestContentBlock `json:"content,omitempty"` 940 Agent string `json:"agent,omitempty"` 941 } 942 if !decodeBody(w, r, &body) { 943 return 944 } 945 // Note: Validate() not called here — inline validation runs before truncation 946 // to avoid side-effects on bad input. 947 if strings.TrimSpace(body.NewContent) == "" && len(body.Content) == 0 { 948 writeError(w, http.StatusBadRequest, "new_content or content is required") 949 return 950 } 951 if body.Agent != "" { 952 if err := agents.ValidateAgentName(body.Agent); err != nil { 953 writeError(w, http.StatusBadRequest, err.Error()) 954 return 955 } 956 } 957 958 // Cancel any active run using this session, regardless of route key type 959 // (agent:<name>, session:<id>, default:<source>:<channel>). 960 s.deps.SessionCache.CancelBySessionID(id) 961 962 // 截断 session 历史消息 963 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(body.Agent)) 964 if err := mgr.TruncateMessages(id, body.MessageIndex); err != nil { 965 if os.IsNotExist(err) { 966 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 967 return 968 } 969 writeError(w, http.StatusBadRequest, err.Error()) 970 return 971 } 972 973 // 以新内容重新触发 agent,复用现有消息发送流程 974 runReq := RunAgentRequest{ 975 Text: body.NewContent, 976 Content: body.Content, 977 Agent: body.Agent, 978 SessionID: id, 979 Source: "shanclaw", 980 } 981 runReq.EnsureRouteKey() 982 983 if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { 984 s.handleMessageSSE(w, r, runReq) 985 return 986 } 987 988 handler := &httpEventHandler{} 989 result, err := RunAgent(r.Context(), s.deps, runReq, handler) 990 if err != nil { 991 writeError(w, http.StatusInternalServerError, err.Error()) 992 return 993 } 994 w.Header().Set("Content-Type", "application/json") 995 json.NewEncoder(w).Encode(result) 996 } 997 998 // handleSessionSummary 生成面向人类阅读的会话摘要,带缓存。 999 // 缓存失效条件:消息数量或 UpdatedAt 变化(新消息追加或编辑 truncate)。 1000 // TODO: 对同一 session 的并发请求可能触发多次 LLM 调用,低优先级优化。 1001 func (s *Server) handleSessionSummary(w http.ResponseWriter, r *http.Request) { 1002 if s.deps == nil { 1003 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 1004 return 1005 } 1006 id := r.PathValue("id") 1007 if id == "" { 1008 writeError(w, http.StatusBadRequest, "session id required") 1009 return 1010 } 1011 if id != filepath.Base(id) { 1012 writeError(w, http.StatusBadRequest, "invalid session id") 1013 return 1014 } 1015 agentName := r.URL.Query().Get("agent") 1016 if agentName != "" { 1017 if err := agents.ValidateAgentName(agentName); err != nil { 1018 writeError(w, http.StatusBadRequest, err.Error()) 1019 return 1020 } 1021 } 1022 1023 mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(agentName)) 1024 sess, err := mgr.Load(id) 1025 if err != nil { 1026 if os.IsNotExist(err) { 1027 writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id)) 1028 return 1029 } 1030 writeError(w, http.StatusInternalServerError, err.Error()) 1031 return 1032 } 1033 1034 if len(sess.Messages) == 0 { 1035 writeError(w, http.StatusBadRequest, "session has no messages") 1036 return 1037 } 1038 1039 // 缓存 key = "消息数:UpdatedAt纳秒",任何消息变更或编辑都会使之失效 1040 cacheKey := fmt.Sprintf("%d:%d", len(sess.Messages), sess.UpdatedAt.UnixNano()) 1041 1042 // 缓存命中 1043 if sess.SummaryCache != "" && sess.SummaryCacheKey == cacheKey { 1044 writeJSON(w, http.StatusOK, map[string]any{ 1045 "summary": sess.SummaryCache, 1046 "cached": true, 1047 "message_count": len(sess.Messages), 1048 }) 1049 return 1050 } 1051 1052 // 缓存未命中:调用 LLM 生成摘要 1053 summary, err := ctxwin.SummarizeForUser(r.Context(), s.deps.GW, sess.Messages) 1054 if err != nil { 1055 writeError(w, http.StatusInternalServerError, fmt.Sprintf("summarization failed: %v", err)) 1056 return 1057 } 1058 1059 // 从磁盘重新读取最新 session 后仅 patch 缓存字段,避免覆盖 agent 期间追加的新消息 1060 if saveErr := mgr.PatchSummaryCache(id, summary, cacheKey); saveErr != nil { 1061 log.Printf("daemon: failed to save summary cache for session %s: %v", id, saveErr) 1062 } 1063 1064 writeJSON(w, http.StatusOK, map[string]any{ 1065 "summary": summary, 1066 "cached": false, 1067 "message_count": len(sess.Messages), 1068 }) 1069 } 1070 1071 // handleMessage runs an agent turn via POST. Supports synchronous JSON and SSE streaming. 1072 func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { 1073 if s.deps == nil { 1074 http.Error(w, `{"error":"daemon deps not configured"}`, http.StatusInternalServerError) 1075 return 1076 } 1077 1078 var req RunAgentRequest 1079 if !decodeBody(w, r, &req) { 1080 return 1081 } 1082 if req.Source == "" { 1083 req.Source = "shanclaw" 1084 } 1085 // Normalize "default" → "" early so downstream guards are consistent. 1086 if req.Agent == "default" { 1087 req.Agent = "" 1088 } 1089 // Named agents always resume their single long-lived session. 1090 // Clear new_session so clients cannot fork a named agent's context. 1091 if req.Agent != "" { 1092 req.NewSession = false 1093 } 1094 req.EnsureRouteKey() 1095 if err := req.Validate(); err != nil { 1096 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) 1097 return 1098 } 1099 1100 // Try injecting into an in-flight run on the same route. 1101 if req.RouteKey != "" { 1102 switch s.deps.SessionCache.InjectMessage(req.RouteKey, agent.InjectedMessage{Text: req.Text, CWD: req.CWD}) { 1103 case InjectOK: 1104 if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { 1105 w.Header().Set("Content-Type", "text/event-stream") 1106 w.Header().Set("Cache-Control", "no-cache") 1107 w.Header().Set("Connection", "keep-alive") 1108 fmt.Fprintf(w, "event: injected\ndata: %s\n\n", req.RouteKey) 1109 if f, ok := w.(http.Flusher); ok { 1110 f.Flush() 1111 } 1112 return 1113 } 1114 w.Header().Set("Content-Type", "application/json") 1115 json.NewEncoder(w).Encode(map[string]string{ 1116 "status": "injected", 1117 "route": req.RouteKey, 1118 }) 1119 return 1120 case InjectQueueFull: 1121 w.Header().Set("Content-Type", "application/json") 1122 w.WriteHeader(http.StatusTooManyRequests) 1123 json.NewEncoder(w).Encode(map[string]string{ 1124 "status": "rejected", 1125 "reason": "queue_full", 1126 "route": req.RouteKey, 1127 }) 1128 return 1129 case InjectBusy: 1130 w.Header().Set("Content-Type", "application/json") 1131 w.WriteHeader(http.StatusConflict) 1132 json.NewEncoder(w).Encode(map[string]string{ 1133 "status": "rejected", 1134 "reason": "active_run_not_ready", 1135 "route": req.RouteKey, 1136 }) 1137 return 1138 case InjectCWDConflict: 1139 w.Header().Set("Content-Type", "application/json") 1140 w.WriteHeader(http.StatusConflict) 1141 json.NewEncoder(w).Encode(map[string]string{ 1142 "status": "rejected", 1143 "reason": "cwd_conflict", 1144 "route": req.RouteKey, 1145 }) 1146 return 1147 case InjectNoActiveRun: 1148 // Fall through to start a new RunAgent 1149 } 1150 } 1151 1152 if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { 1153 s.handleMessageSSE(w, r, req) 1154 return 1155 } 1156 1157 handler := &httpEventHandler{} 1158 result, err := RunAgent(r.Context(), s.deps, req, handler) 1159 if err != nil { 1160 http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) 1161 return 1162 } 1163 w.Header().Set("Content-Type", "application/json") 1164 json.NewEncoder(w).Encode(result) 1165 } 1166 1167 // handleMessageSSE streams agent events as SSE. 1168 func (s *Server) handleMessageSSE(w http.ResponseWriter, r *http.Request, req RunAgentRequest) { 1169 flusher, ok := w.(http.Flusher) 1170 if !ok { 1171 http.Error(w, `{"error":"streaming not supported"}`, http.StatusInternalServerError) 1172 return 1173 } 1174 1175 w.Header().Set("Content-Type", "text/event-stream") 1176 w.Header().Set("Cache-Control", "no-cache") 1177 w.Header().Set("Connection", "keep-alive") 1178 flusher.Flush() 1179 1180 // Create a per-request broker to avoid racing with concurrent SSE requests. 1181 // Each SSE stream gets its own broker with its own sendFn and pending map. 1182 reqBroker := NewApprovalBroker(func(areq ApprovalRequest) error { 1183 data := mustJSON(areq) 1184 _, err := fmt.Fprintf(w, "event: approval\ndata: %s\n\n", data) 1185 flusher.Flush() 1186 return err 1187 }) 1188 // Inherit onRequest callback from the server broker for EventBus emission. 1189 reqBroker.onRequest = s.approvalBroker.onRequest 1190 // Register pending requestIDs so POST /approval can find this broker. 1191 reqBroker.onRegister = func(requestID string) { s.pendingBrokers.Store(requestID, reqBroker) } 1192 reqBroker.onDeregister = func(requestID string) { s.pendingBrokers.Delete(requestID) } 1193 1194 // Cancel only this request's pending approvals when the SSE stream ends. 1195 defer reqBroker.CancelAll() 1196 1197 // Resolve auto_approve: per-agent overrides global 1198 cfg, _, _ := s.deps.Snapshot() 1199 autoApprove := cfg.Daemon.AutoApprove 1200 if req.Agent != "" { 1201 if a, err := agents.LoadAgent(s.deps.AgentsDir, req.Agent); err == nil && a.Config != nil && a.Config.AutoApprove != nil { 1202 autoApprove = *a.Config.AutoApprove 1203 } 1204 } 1205 1206 handler := &sseEventHandler{w: w, flusher: flusher, broker: reqBroker, ctx: r.Context(), autoApprove: autoApprove, deps: s.deps} 1207 result, err := RunAgent(r.Context(), s.deps, req, handler) 1208 if err != nil { 1209 fmt.Fprintf(w, "event: error\ndata: %s\n\n", mustJSON(map[string]string{"error": err.Error()})) 1210 flusher.Flush() 1211 return 1212 } 1213 1214 fmt.Fprintf(w, "event: done\ndata: %s\n\n", mustJSON(result)) 1215 flusher.Flush() 1216 } 1217 1218 // handlePermissions returns current macOS TCC permission status. 1219 func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) { 1220 result := probePermissions(r.Context()) 1221 w.Header().Set("Content-Type", "application/json") 1222 json.NewEncoder(w).Encode(result) 1223 } 1224 1225 // handlePermissionsRequest triggers macOS permission dialogs for the requested permission. 1226 func (s *Server) handlePermissionsRequest(w http.ResponseWriter, r *http.Request) { 1227 var req struct { 1228 Permission string `json:"permission"` // "screen_recording", "accessibility", or "automation" 1229 } 1230 if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { 1231 http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) 1232 return 1233 } 1234 switch req.Permission { 1235 case "screen_recording", "accessibility", "automation": 1236 // valid 1237 default: 1238 w.Header().Set("Content-Type", "application/json") 1239 w.WriteHeader(http.StatusBadRequest) 1240 json.NewEncoder(w).Encode(map[string]string{"error": "unsupported permission: " + req.Permission}) 1241 return 1242 } 1243 result := requestPermission(r.Context(), req.Permission) 1244 w.Header().Set("Content-Type", "application/json") 1245 json.NewEncoder(w).Encode(result) 1246 } 1247 1248 // httpEventHandler is an EventHandler for synchronous HTTP responses. 1249 type httpEventHandler struct { 1250 usage agent.UsageAccumulator 1251 } 1252 1253 // Usage returns the cumulative usage collected during this handler's lifetime. 1254 func (h *httpEventHandler) Usage() agent.AccumulatedUsage { return h.usage.Snapshot() } 1255 1256 func (h *httpEventHandler) OnToolCall(name string, args string) {} 1257 func (h *httpEventHandler) OnToolResult(name string, args string, result agent.ToolResult, elapsed time.Duration) { 1258 log.Printf("http: tool %s completed (%.1fs)", name, elapsed.Seconds()) 1259 } 1260 func (h *httpEventHandler) OnText(text string) {} 1261 func (h *httpEventHandler) OnStreamDelta(delta string) {} 1262 func (h *httpEventHandler) OnUsage(usage agent.TurnUsage) { h.usage.Add(usage) } 1263 1264 // OnApprovalNeeded auto-approves for local HTTP API calls. 1265 // Threat model: localhost-only, unauthenticated but local-trusted. 1266 // Permission engine (hard-blocks, denied_commands) runs before this. 1267 // If daemon ever listens on non-localhost, this MUST require auth. 1268 func (h *httpEventHandler) OnCloudAgent(agentID, status, message string) {} 1269 func (h *httpEventHandler) OnCloudProgress(completed, total int) {} 1270 func (h *httpEventHandler) OnCloudPlan(planType, content string, needsReview bool) {} 1271 1272 func (h *httpEventHandler) OnApprovalNeeded(tool string, args string) bool { 1273 return true 1274 } 1275 1276 // sseEventHandler streams agent events as SSE to an HTTP response. 1277 type sseEventHandler struct { 1278 w http.ResponseWriter 1279 flusher http.Flusher 1280 broker *ApprovalBroker 1281 ctx context.Context 1282 autoApprove bool 1283 deps *ServerDeps 1284 usage agent.UsageAccumulator 1285 } 1286 1287 // Usage returns the cumulative usage collected during this handler's lifetime. 1288 func (h *sseEventHandler) Usage() agent.AccumulatedUsage { return h.usage.Snapshot() } 1289 1290 func (h *sseEventHandler) OnToolCall(name string, args string) { 1291 // Match bus payload: redact-first, then truncate. `audit.RedactSecrets ∘ 1292 // truncate` is wrong — a secret that straddles the byte-200 boundary 1293 // gets chopped into a fragment before the redaction regex sees it, and 1294 // then leaks through SSE. See redactAndTruncate + the boundary 1295 // regression test in bus_handler_test.go. 1296 data := mustJSON(map[string]interface{}{ 1297 "tool": name, 1298 "status": "running", 1299 "args": redactAndTruncate(args, 200), 1300 }) 1301 fmt.Fprintf(h.w, "event: tool\ndata: %s\n\n", data) 1302 h.flusher.Flush() 1303 } 1304 1305 func (h *sseEventHandler) OnToolResult(name string, args string, result agent.ToolResult, elapsed time.Duration) { 1306 // SSE is request-scoped (one tool stream per HTTP request), so session_id 1307 // is intentionally omitted here; session correlation is handled at the client 1308 // session boundary. `is_error` and `preview` mirror the bus payload so the 1309 // Desktop foreground pill can render errors / a short result preview. 1310 data := mustJSON(map[string]interface{}{ 1311 "tool": name, 1312 "status": "completed", 1313 "elapsed": elapsed.Seconds(), 1314 "is_error": result.IsError, 1315 "preview": redactAndTruncate(toolResultPreview(result), 200), 1316 }) 1317 fmt.Fprintf(h.w, "event: tool\ndata: %s\n\n", data) 1318 h.flusher.Flush() 1319 } 1320 1321 func (h *sseEventHandler) OnText(text string) {} 1322 1323 func (h *sseEventHandler) OnStreamDelta(delta string) { 1324 data := mustJSON(map[string]string{"text": delta}) 1325 fmt.Fprintf(h.w, "event: delta\ndata: %s\n\n", data) 1326 h.flusher.Flush() 1327 } 1328 1329 func (h *sseEventHandler) OnUsage(usage agent.TurnUsage) { 1330 h.usage.Add(usage) 1331 // Also emit as SSE event so clients can render live cost meters. 1332 data := mustJSON(map[string]interface{}{ 1333 "input_tokens": usage.InputTokens, 1334 "output_tokens": usage.OutputTokens, 1335 "total_tokens": usage.TotalTokens, 1336 "cost_usd": usage.CostUSD, 1337 "llm_calls": usage.LLMCalls, 1338 "model": usage.Model, 1339 }) 1340 fmt.Fprintf(h.w, "event: usage\ndata: %s\n\n", data) 1341 h.flusher.Flush() 1342 } 1343 1344 func (h *sseEventHandler) OnCloudAgent(agentID, status, message string) { 1345 data, _ := json.Marshal(map[string]interface{}{ 1346 "agent_id": agentID, 1347 "status": status, 1348 "message": message, 1349 }) 1350 fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudAgent, data) 1351 h.flusher.Flush() 1352 } 1353 1354 func (h *sseEventHandler) OnCloudProgress(completed, total int) { 1355 data, _ := json.Marshal(map[string]interface{}{ 1356 "completed": completed, 1357 "total": total, 1358 }) 1359 fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudProgress, data) 1360 h.flusher.Flush() 1361 } 1362 1363 func (h *sseEventHandler) OnCloudPlan(planType, content string, needsReview bool) { 1364 data, _ := json.Marshal(map[string]interface{}{ 1365 "type": planType, 1366 "content": content, 1367 "needs_review": needsReview, 1368 }) 1369 fmt.Fprintf(h.w, "event: %s\ndata: %s\n\n", EventCloudPlan, data) 1370 h.flusher.Flush() 1371 } 1372 1373 // OnApprovalNeeded sends an approval request over SSE and blocks until the 1374 // client responds via POST /approval or the request context is cancelled. 1375 func (h *sseEventHandler) OnApprovalNeeded(tool string, args string) bool { 1376 if h.autoApprove { 1377 log.Printf("sse: auto-approving %s (auto_approve=true)", tool) 1378 return true 1379 } 1380 decision := h.broker.Request(h.ctx, "", "", "", tool, args) 1381 if decision == DecisionAlwaysAllow { 1382 if tool == "bash" { 1383 cmd := permissions.ExtractField(args, "command") 1384 if cmd != "" { 1385 if err := config.AppendAllowedCommand(h.deps.ShannonDir, cmd); err != nil { 1386 log.Printf("sse: failed to persist always-allow: %v", err) 1387 } else { 1388 h.deps.WriteLock() 1389 perms := &h.deps.Config.Permissions 1390 found := false 1391 for _, c := range perms.AllowedCommands { 1392 if c == cmd { 1393 found = true 1394 break 1395 } 1396 } 1397 if !found { 1398 perms.AllowedCommands = append(perms.AllowedCommands, cmd) 1399 } 1400 h.deps.WriteUnlock() 1401 log.Printf("sse: always-allow persisted: %s", cmd) 1402 } 1403 } 1404 } else { 1405 h.broker.SetToolAutoApprove(tool) 1406 log.Printf("sse: always-allow (session): %s", tool) 1407 } 1408 } 1409 return decision == DecisionAllow || decision == DecisionAlwaysAllow 1410 } 1411 1412 // mustJSON marshals v to JSON, returning "{}" on error. 1413 func mustJSON(v interface{}) string { 1414 b, err := json.Marshal(v) 1415 if err != nil { 1416 return "{}" 1417 } 1418 return string(b) 1419 } 1420 1421 // isJSONNull checks if a json.RawMessage represents a JSON null value. 1422 func isJSONNull(raw json.RawMessage) bool { 1423 return strings.TrimSpace(string(raw)) == "null" 1424 } 1425 1426 const ( 1427 maxBodySize = 50 << 20 // 50 MB — accommodates base64-encoded attachments (30 MB file → ~40 MB base64) 1428 maxUploadSize = 10 << 20 1429 ) 1430 1431 var skillSubresourceFileRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,255}$`) 1432 1433 // decodeBody reads a JSON request body with a size limit. Returns false and 1434 // writes an error response if decoding fails. 1435 func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool { 1436 r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) 1437 if err := json.NewDecoder(r.Body).Decode(v); err != nil { 1438 var maxErr *http.MaxBytesError 1439 if errors.As(err, &maxErr) { 1440 writeError(w, http.StatusRequestEntityTooLarge, "request body too large") 1441 } else { 1442 writeError(w, http.StatusBadRequest, "invalid request body") 1443 } 1444 return false 1445 } 1446 return true 1447 } 1448 1449 func decodeOptionalBody(w http.ResponseWriter, r *http.Request, v interface{}) (ok bool, provided bool) { 1450 r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) 1451 data, err := io.ReadAll(r.Body) 1452 if err != nil { 1453 var maxErr *http.MaxBytesError 1454 if errors.As(err, &maxErr) { 1455 writeError(w, http.StatusRequestEntityTooLarge, "request body too large") 1456 } else { 1457 writeError(w, http.StatusBadRequest, "invalid request body") 1458 } 1459 return false, false 1460 } 1461 if len(strings.TrimSpace(string(data))) == 0 { 1462 return true, false 1463 } 1464 if err := json.Unmarshal(data, v); err != nil { 1465 writeError(w, http.StatusBadRequest, "invalid request body") 1466 return false, false 1467 } 1468 return true, true 1469 } 1470 1471 func (s *Server) skillSources() ([]skills.SkillSource, error) { 1472 if s.deps == nil { 1473 return nil, fmt.Errorf("daemon deps not configured") 1474 } 1475 // Only return global (installed) skills. Builtin skills (kocoro) are 1476 // auto-installed to global by EnsureBuiltinSkills at startup. 1477 global := skills.SkillSource{ 1478 Dir: filepath.Join(s.deps.ShannonDir, "skills"), 1479 Source: skills.SourceGlobal, 1480 } 1481 return []skills.SkillSource{global}, nil 1482 } 1483 1484 // skillNamesFromRequest extracts the URL-safe identifier (Slug, falling 1485 // back to Name for legacy clients) for each skill entry. The returned 1486 // list is what gets persisted to _attached.yaml and what URL-based 1487 // attach routes also use — keeping body-based and URL-based attach in 1488 // sync so the same skill can be resolved by the loader regardless of 1489 // which API path wrote the manifest. 1490 func skillNamesFromRequest(entries []*skills.Skill) []string { 1491 names := make([]string, 0, len(entries)) 1492 for _, skill := range entries { 1493 if skill == nil { 1494 continue 1495 } 1496 ident := skill.Slug 1497 if ident == "" { 1498 ident = skill.Name 1499 } 1500 if ident != "" { 1501 names = append(names, ident) 1502 } 1503 } 1504 return names 1505 } 1506 1507 func (s *Server) validateInstalledSkills(names []string) error { 1508 if len(names) == 0 { 1509 return nil 1510 } 1511 if s.deps == nil { 1512 return fmt.Errorf("daemon deps not configured") 1513 } 1514 list, err := agents.LoadGlobalSkills(s.deps.ShannonDir) 1515 if err != nil { 1516 return fmt.Errorf("load installed skills: %w", err) 1517 } 1518 // Accept either Slug (directory / marketplace identifier) or Name 1519 // (frontmatter display label) as the identifier. Slug is the primary 1520 // key we advise clients to use; Name is kept for backward compat. 1521 installed := make(map[string]bool, len(list)*2) 1522 for _, skill := range list { 1523 installed[skill.Slug] = true 1524 installed[skill.Name] = true 1525 } 1526 var missing []string 1527 seen := make(map[string]bool, len(names)) 1528 for _, name := range names { 1529 if seen[name] { 1530 continue 1531 } 1532 seen[name] = true 1533 if !installed[name] { 1534 missing = append(missing, name) 1535 } 1536 } 1537 if len(missing) == 0 { 1538 return nil 1539 } 1540 sort.Strings(missing) 1541 if len(missing) == 1 { 1542 return fmt.Errorf("skill %q is not installed", missing[0]) 1543 } 1544 return fmt.Errorf("skills not installed: %s", strings.Join(missing, ", ")) 1545 } 1546 1547 func (s *Server) resolveSkillDir(name string) (string, string, bool, error) { 1548 if s.deps == nil { 1549 return "", "", false, fmt.Errorf("daemon deps not configured") 1550 } 1551 globalDir := filepath.Join(s.deps.ShannonDir, "skills", name) 1552 if _, err := os.Stat(filepath.Join(globalDir, "SKILL.md")); err == nil { 1553 return globalDir, skills.SourceGlobal, false, nil 1554 } 1555 return "", "", false, os.ErrNotExist 1556 } 1557 1558 func isValidSkillFileName(name string) bool { 1559 if len(name) == 0 || len(name) > 255 { 1560 return false 1561 } 1562 return filepath.Base(name) == name && skillSubresourceFileRE.MatchString(name) 1563 } 1564 1565 func modeForSubresource(subdir string) os.FileMode { 1566 switch subdir { 1567 case "scripts": 1568 return 0755 1569 default: 1570 return 0644 1571 } 1572 } 1573 1574 // configKeyAliases maps known camelCase/PascalCase JSON field names that legacy 1575 // clients may send back to their canonical snake_case YAML equivalents. 1576 var configKeyAliases = map[string]string{ 1577 "apiKey": "api_key", 1578 "APIKey": "api_key", 1579 "modelTier": "model_tier", 1580 "ModelTier": "model_tier", 1581 "autoUpdateCheck": "auto_update_check", 1582 "AutoUpdateCheck": "auto_update_check", 1583 "mcpServers": "mcp_servers", 1584 "MCPServers": "mcp_servers", 1585 } 1586 1587 // normalizePatchKeys rewrites known camelCase aliases to snake_case at the 1588 // top level of m only. All aliases in configKeyAliases are top-level config 1589 // keys; nested maps are intentionally not traversed to avoid false-positive 1590 // renames of unrelated fields that share an alias name. 1591 // When both an alias and its canonical key are present, the canonical wins 1592 // and the alias is discarded. 1593 func normalizePatchKeys(m map[string]interface{}) { 1594 if m == nil { 1595 return 1596 } 1597 for k := range m { 1598 canonical, aliased := configKeyAliases[k] 1599 if !aliased { 1600 continue 1601 } 1602 if _, canonicalExists := m[canonical]; !canonicalExists { 1603 m[canonical] = m[k] 1604 } 1605 delete(m, k) 1606 } 1607 } 1608 1609 // stripRedactedSecrets removes "***" placeholder values from the known sensitive 1610 // paths only: top-level api_key and mcp_servers.<name>.env.<var>. This prevents 1611 // a GET→PATCH round-trip from overwriting real credentials with redacted values, 1612 // without globally blocking the literal string "***" as a config value elsewhere. 1613 func stripRedactedSecrets(m map[string]interface{}) { 1614 if m == nil { 1615 return 1616 } 1617 if s, ok := m["api_key"].(string); ok && s == "***" { 1618 delete(m, "api_key") 1619 } 1620 servers, ok := m["mcp_servers"].(map[string]interface{}) 1621 if !ok { 1622 return 1623 } 1624 for _, srv := range servers { 1625 srvMap, ok := srv.(map[string]interface{}) 1626 if !ok { 1627 continue 1628 } 1629 env, ok := srvMap["env"].(map[string]interface{}) 1630 if !ok { 1631 continue 1632 } 1633 for k, v := range env { 1634 if s, ok := v.(string); ok && s == "***" { 1635 delete(env, k) 1636 } 1637 } 1638 } 1639 } 1640 1641 // redactConfigSecrets removes sensitive values from a config map before 1642 // sending it over the API. Redacts api_key at top level and env vars 1643 // inside mcp_servers entries. 1644 func redactConfigSecrets(m map[string]interface{}) { 1645 if m == nil { 1646 return 1647 } 1648 if _, ok := m["api_key"]; ok { 1649 m["api_key"] = "***" 1650 } 1651 servers, ok := m["mcp_servers"].(map[string]interface{}) 1652 if !ok { 1653 return 1654 } 1655 for _, srv := range servers { 1656 srvMap, ok := srv.(map[string]interface{}) 1657 if !ok { 1658 continue 1659 } 1660 if env, ok := srvMap["env"].(map[string]interface{}); ok { 1661 for k := range env { 1662 env[k] = "***" 1663 } 1664 } 1665 } 1666 } 1667 1668 // deepMerge merges src into dst recursively (RFC 7386 JSON Merge Patch). 1669 // null values delete keys, nested maps merge, scalars replace. 1670 func deepMerge(dst, src map[string]interface{}) { 1671 for key, srcVal := range src { 1672 if srcVal == nil { 1673 delete(dst, key) 1674 continue 1675 } 1676 srcMap, srcIsMap := srcVal.(map[string]interface{}) 1677 if srcIsMap { 1678 if dstVal, ok := dst[key]; ok { 1679 if dstMap, dstIsMap := dstVal.(map[string]interface{}); dstIsMap { 1680 deepMerge(dstMap, srcMap) 1681 continue 1682 } 1683 } 1684 } 1685 dst[key] = srcVal 1686 } 1687 } 1688 1689 func pruneEmptyMaps(m map[string]interface{}) bool { 1690 for key, val := range m { 1691 switch v := val.(type) { 1692 case nil: 1693 delete(m, key) 1694 case map[string]interface{}: 1695 if pruneEmptyMaps(v) { 1696 delete(m, key) 1697 } 1698 } 1699 } 1700 return len(m) == 0 1701 } 1702 1703 func (s *Server) patchGlobalConfig(patch map[string]interface{}) error { 1704 globalPath := filepath.Join(s.deps.ShannonDir, "config.yaml") 1705 globalData, _ := os.ReadFile(globalPath) 1706 var current map[string]interface{} 1707 if len(globalData) > 0 { 1708 if err := yaml.Unmarshal(globalData, ¤t); err != nil { 1709 return fmt.Errorf("existing config is corrupt: %v", err) 1710 } 1711 } 1712 if current == nil { 1713 current = make(map[string]interface{}) 1714 } 1715 1716 normalizePatchKeys(patch) 1717 stripRedactedSecrets(patch) 1718 deepMerge(current, patch) 1719 pruneEmptyMaps(current) 1720 1721 data, err := yaml.Marshal(current) 1722 if err != nil { 1723 return err 1724 } 1725 return agents.AtomicWrite(globalPath, data) 1726 } 1727 1728 func writeJSON(w http.ResponseWriter, status int, v interface{}) { 1729 w.Header().Set("Content-Type", "application/json") 1730 w.WriteHeader(status) 1731 json.NewEncoder(w).Encode(v) 1732 } 1733 1734 func writeError(w http.ResponseWriter, status int, msg string) { 1735 w.Header().Set("Content-Type", "application/json") 1736 w.WriteHeader(status) 1737 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 1738 } 1739 1740 // --- Agent CRUD handlers --- 1741 1742 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) { 1743 name := r.PathValue("name") 1744 if err := agents.ValidateAgentName(name); err != nil { 1745 writeError(w, http.StatusBadRequest, err.Error()) 1746 return 1747 } 1748 if !s.agentExists(w, name) { 1749 return 1750 } 1751 a, err := agents.LoadAgent(s.deps.AgentsDir, name) 1752 if err != nil { 1753 writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load agent %s: %v", name, err)) 1754 return 1755 } 1756 api := a.ToAPI() 1757 1758 // Add builtin metadata — match ListAgents semantics: 1759 // Builtin=true only when loaded from _builtin (no user override). 1760 // Overridden=true when a user override exists for a builtin. 1761 builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name) 1762 userDir := filepath.Join(s.deps.AgentsDir, name) 1763 _, builtinErr := os.Stat(filepath.Join(builtinDir, "AGENT.md")) 1764 _, userErr := os.Stat(filepath.Join(userDir, "AGENT.md")) 1765 hasBuiltin := builtinErr == nil 1766 hasUser := userErr == nil 1767 api.Builtin = hasBuiltin && !hasUser // builtin-only, no user override 1768 api.Overridden = hasBuiltin && hasUser // user override of a builtin 1769 1770 // Populate non-fatal trigger-conflict warnings (heartbeat ⊕ schedule). 1771 // Best-effort — missing schedule manager or list errors yield no warnings. 1772 if s.deps.ScheduleManager != nil { 1773 if list, err := s.deps.ScheduleManager.List(); err == nil { 1774 refs := make([]agents.ScheduleRef, 0, len(list)) 1775 for _, sc := range list { 1776 refs = append(refs, agents.ScheduleRef{ID: sc.ID, Agent: sc.Agent, Enabled: sc.Enabled}) 1777 } 1778 api.Warnings = agents.DetectTriggerConflicts(s.deps.AgentsDir, name, refs) 1779 } 1780 } 1781 1782 writeJSON(w, http.StatusOK, api) 1783 } 1784 1785 func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { 1786 var req agents.AgentCreateRequest 1787 if !decodeBody(w, r, &req) { 1788 return 1789 } 1790 if err := req.Validate(); err != nil { 1791 writeError(w, http.StatusBadRequest, err.Error()) 1792 return 1793 } 1794 if err := s.validateInstalledSkills(skillNamesFromRequest(req.Skills)); err != nil { 1795 writeError(w, http.StatusBadRequest, err.Error()) 1796 return 1797 } 1798 // Serialize creates for the same agent name to prevent concurrent rollback races. 1799 routeKey := "agent:" + req.Name 1800 s.deps.SessionCache.LockRoute(routeKey) 1801 defer s.deps.SessionCache.UnlockRoute(routeKey) 1802 1803 agentDir := filepath.Join(s.deps.AgentsDir, req.Name) 1804 if _, err := os.Stat(filepath.Join(agentDir, "AGENT.md")); err == nil { 1805 writeError(w, http.StatusConflict, fmt.Sprintf("agent %q already exists", req.Name)) 1806 return 1807 } 1808 // If name matches a builtin, materialize user override first so the 1809 // subsequent writes land in the user dir and override the builtin. 1810 if agents.IsBuiltinAgent(req.Name) { 1811 if err := agents.MaterializeBuiltin(s.deps.AgentsDir, req.Name); err != nil { 1812 writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to materialize builtin: %s", err)) 1813 return 1814 } 1815 } 1816 // Write all agent files — rollback on any failure. 1817 // Only remove files we materialized; preserve MEMORY.md and sessions/ 1818 // which are runtime state that may exist from prior builtin agent usage. 1819 rollback := func() { 1820 dir := filepath.Join(s.deps.AgentsDir, req.Name) 1821 os.Remove(filepath.Join(dir, "AGENT.md")) 1822 os.Remove(filepath.Join(dir, "config.yaml")) 1823 os.Remove(filepath.Join(dir, "_attached.yaml")) 1824 os.RemoveAll(filepath.Join(dir, "commands")) 1825 os.RemoveAll(filepath.Join(dir, "skills")) 1826 } 1827 if err := agents.WriteAgentPrompt(s.deps.AgentsDir, req.Name, req.Prompt); err != nil { 1828 rollback() 1829 writeError(w, http.StatusInternalServerError, err.Error()) 1830 return 1831 } 1832 if req.Memory != nil { 1833 if err := agents.WriteAgentMemory(s.deps.AgentsDir, req.Name, *req.Memory); err != nil { 1834 rollback() 1835 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write memory: %v", err)) 1836 return 1837 } 1838 } 1839 if req.Config != nil { 1840 if err := agents.WriteAgentConfig(s.deps.AgentsDir, req.Name, req.Config); err != nil { 1841 rollback() 1842 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write config: %v", err)) 1843 return 1844 } 1845 } 1846 for name, content := range req.Commands { 1847 if err := agents.WriteAgentCommand(s.deps.AgentsDir, req.Name, name, content); err != nil { 1848 rollback() 1849 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write command %s: %v", name, err)) 1850 return 1851 } 1852 } 1853 if len(req.Skills) > 0 { 1854 if err := agents.SetAttachedSkills(s.deps.AgentsDir, req.Name, skillNamesFromRequest(req.Skills)); err != nil { 1855 rollback() 1856 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write skill manifest: %v", err)) 1857 return 1858 } 1859 } 1860 a, err := agents.LoadAgent(s.deps.AgentsDir, req.Name) 1861 if err != nil { 1862 rollback() 1863 writeError(w, http.StatusInternalServerError, err.Error()) 1864 return 1865 } 1866 s.auditHTTPOp("POST", "/agents", "created agent "+req.Name) 1867 writeJSON(w, http.StatusCreated, a.ToAPI()) 1868 } 1869 1870 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) { 1871 name := r.PathValue("name") 1872 if err := agents.ValidateAgentName(name); err != nil { 1873 writeError(w, http.StatusBadRequest, err.Error()) 1874 return 1875 } 1876 if !s.agentExists(w, name) { 1877 return 1878 } 1879 agentDir := filepath.Join(s.deps.AgentsDir, name) 1880 var req agents.AgentUpdateRequest 1881 if !decodeBody(w, r, &req) { 1882 return 1883 } 1884 1885 // --- Pre-validate all fields before any mutations --- 1886 if req.Prompt != nil && *req.Prompt == "" { 1887 writeError(w, http.StatusBadRequest, "prompt cannot be empty") 1888 return 1889 } 1890 var parsedMemory *string 1891 if req.Memory != nil && !isJSONNull(req.Memory) { 1892 var mem string 1893 if err := json.Unmarshal(req.Memory, &mem); err != nil { 1894 writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid memory value: %v", err)) 1895 return 1896 } 1897 parsedMemory = &mem 1898 } 1899 var parsedConfig *agents.AgentConfigAPI 1900 if req.Config != nil && !isJSONNull(req.Config) { 1901 var cfg agents.AgentConfigAPI 1902 if err := json.Unmarshal(req.Config, &cfg); err != nil { 1903 writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid config value: %v", err)) 1904 return 1905 } 1906 if cfg.Tools != nil { 1907 if err := agents.ValidateToolsFilter(cfg.Tools); err != nil { 1908 writeError(w, http.StatusBadRequest, err.Error()) 1909 return 1910 } 1911 } 1912 parsedConfig = &cfg 1913 } 1914 for cmdName := range req.Commands { 1915 if err := agents.ValidateCommandName(cmdName); err != nil { 1916 writeError(w, http.StatusBadRequest, err.Error()) 1917 return 1918 } 1919 } 1920 for _, skill := range req.Skills { 1921 if skill == nil { 1922 writeError(w, http.StatusBadRequest, "skill entry cannot be null") 1923 return 1924 } 1925 // Validate the URL-safe identifier (Slug) rather than the 1926 // display Name. Legacy clients that only send Name fall through 1927 // to Name validation for backward compatibility. 1928 ident := skill.Slug 1929 if ident == "" { 1930 ident = skill.Name 1931 } 1932 if err := skills.ValidateSkillName(ident); err != nil { 1933 writeError(w, http.StatusBadRequest, err.Error()) 1934 return 1935 } 1936 } 1937 if err := s.validateInstalledSkills(skillNamesFromRequest(req.Skills)); err != nil { 1938 writeError(w, http.StatusBadRequest, err.Error()) 1939 return 1940 } 1941 1942 // Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input. 1943 if !s.materializeIfBuiltin(w, name) { 1944 return 1945 } 1946 1947 // --- Apply mutations (all inputs validated) --- 1948 if req.Prompt != nil { 1949 if err := agents.WriteAgentPrompt(s.deps.AgentsDir, name, *req.Prompt); err != nil { 1950 writeError(w, http.StatusInternalServerError, err.Error()) 1951 return 1952 } 1953 } 1954 if req.Memory != nil { 1955 if isJSONNull(req.Memory) { 1956 if err := os.Remove(filepath.Join(agentDir, "MEMORY.md")); err != nil && !os.IsNotExist(err) { 1957 writeError(w, http.StatusInternalServerError, fmt.Sprintf("delete memory: %v", err)) 1958 return 1959 } 1960 } else { 1961 if err := agents.WriteAgentMemory(s.deps.AgentsDir, name, *parsedMemory); err != nil { 1962 writeError(w, http.StatusInternalServerError, err.Error()) 1963 return 1964 } 1965 } 1966 } 1967 if req.Config != nil { 1968 if isJSONNull(req.Config) { 1969 if err := os.Remove(filepath.Join(agentDir, "config.yaml")); err != nil && !os.IsNotExist(err) { 1970 writeError(w, http.StatusInternalServerError, fmt.Sprintf("delete config: %v", err)) 1971 return 1972 } 1973 } else { 1974 if err := agents.WriteAgentConfig(s.deps.AgentsDir, name, parsedConfig); err != nil { 1975 writeError(w, http.StatusInternalServerError, err.Error()) 1976 return 1977 } 1978 } 1979 } 1980 for cmdName, content := range req.Commands { 1981 if err := agents.WriteAgentCommand(s.deps.AgentsDir, name, cmdName, content); err != nil { 1982 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write command %s: %v", cmdName, err)) 1983 return 1984 } 1985 } 1986 if req.Skills != nil { 1987 // Write attached skills manifest — agent loader resolves content from global/bundled. 1988 if err := agents.SetAttachedSkills(s.deps.AgentsDir, name, skillNamesFromRequest(req.Skills)); err != nil { 1989 writeError(w, http.StatusInternalServerError, fmt.Sprintf("write skill manifest: %v", err)) 1990 return 1991 } 1992 // Clean up any legacy agent-scoped SKILL.md files 1993 agentSkillsDir := filepath.Join(s.deps.AgentsDir, name, "skills") 1994 _ = os.RemoveAll(agentSkillsDir) 1995 } 1996 a, err := agents.LoadAgent(s.deps.AgentsDir, name) 1997 if err != nil { 1998 writeError(w, http.StatusInternalServerError, err.Error()) 1999 return 2000 } 2001 writeJSON(w, http.StatusOK, a.ToAPI()) 2002 } 2003 2004 func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) { 2005 if requireConfirm(r.URL.Query().Get("confirm")) { 2006 writeJSON(w, http.StatusBadRequest, map[string]string{ 2007 "error": "confirmation_required", 2008 "message": "This will permanently delete the agent definition. Add ?confirm=true to proceed.", 2009 }) 2010 return 2011 } 2012 name := r.PathValue("name") 2013 if err := agents.ValidateAgentName(name); err != nil { 2014 writeError(w, http.StatusBadRequest, err.Error()) 2015 return 2016 } 2017 if !s.agentExists(w, name) { 2018 return 2019 } 2020 // Cannot delete a builtin-only agent (no user override) 2021 userDir := filepath.Join(s.deps.AgentsDir, name) 2022 builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name) 2023 _, userErr := os.Stat(filepath.Join(userDir, "AGENT.md")) 2024 _, builtinErr := os.Stat(filepath.Join(builtinDir, "AGENT.md")) 2025 if userErr != nil && builtinErr == nil { 2026 writeError(w, http.StatusForbidden, "cannot delete system-managed builtin agent") 2027 return 2028 } 2029 // Evict handles its own per-route locking — do NOT wrap with Lock/Unlock 2030 // (that would self-deadlock since Evict calls evictRoute which acquires entry.mu). 2031 s.deps.SessionCache.Evict(name) 2032 // Remove only definition files — preserve runtime state (MEMORY.md, sessions/) 2033 // so the builtin can resurface with existing history intact. 2034 agentDir := filepath.Join(s.deps.AgentsDir, name) 2035 var errs []string 2036 for _, f := range []string{"AGENT.md", "config.yaml", "_attached.yaml"} { 2037 p := filepath.Join(agentDir, f) 2038 if err := os.Remove(p); err != nil && !os.IsNotExist(err) { 2039 errs = append(errs, err.Error()) 2040 } 2041 } 2042 for _, d := range []string{"commands", "skills"} { 2043 p := filepath.Join(agentDir, d) 2044 if err := os.RemoveAll(p); err != nil { 2045 errs = append(errs, err.Error()) 2046 } 2047 } 2048 if len(errs) > 0 { 2049 writeError(w, http.StatusInternalServerError, fmt.Sprintf("partial delete: %s", strings.Join(errs, "; "))) 2050 return 2051 } 2052 // Clean up empty dir if no runtime state remains 2053 if entries, err := os.ReadDir(agentDir); err == nil && len(entries) == 0 { 2054 os.Remove(agentDir) 2055 } 2056 s.auditHTTPOp("DELETE", "/agents/"+name, "deleted agent") 2057 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2058 } 2059 2060 // --- Agent sub-resource handlers --- 2061 2062 // agentExists checks that the agent directory has AGENT.md. Returns false 2063 // and writes a 404 error if the agent does not exist. 2064 func (s *Server) agentExists(w http.ResponseWriter, name string) bool { 2065 agentDir := filepath.Join(s.deps.AgentsDir, name) 2066 if _, err := os.Stat(filepath.Join(agentDir, "AGENT.md")); os.IsNotExist(err) { 2067 // Also check _builtin fallback 2068 builtinDir := filepath.Join(s.deps.AgentsDir, "_builtin", name) 2069 if _, err := os.Stat(filepath.Join(builtinDir, "AGENT.md")); os.IsNotExist(err) { 2070 writeError(w, http.StatusNotFound, fmt.Sprintf("agent %q not found", name)) 2071 return false 2072 } 2073 } 2074 return true 2075 } 2076 2077 // materializeIfBuiltin checks if the agent exists only as a builtin (no user 2078 // override) and materializes it to the user dir so writes can proceed. Returns 2079 // true if the caller should continue, false if an error was already written. 2080 func (s *Server) materializeIfBuiltin(w http.ResponseWriter, name string) bool { 2081 userDir := filepath.Join(s.deps.AgentsDir, name) 2082 if _, err := os.Stat(filepath.Join(userDir, "AGENT.md")); err != nil { 2083 if agents.IsBuiltinAgent(name) { 2084 if err := agents.MaterializeBuiltin(s.deps.AgentsDir, name); err != nil { 2085 writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to materialize builtin: %s", err)) 2086 return false 2087 } 2088 } 2089 } 2090 return true 2091 } 2092 2093 func (s *Server) handlePutAgentConfig(w http.ResponseWriter, r *http.Request) { 2094 name := r.PathValue("name") 2095 if err := agents.ValidateAgentName(name); err != nil { 2096 writeError(w, http.StatusBadRequest, err.Error()) 2097 return 2098 } 2099 if !s.agentExists(w, name) { 2100 return 2101 } 2102 var cfg agents.AgentConfigAPI 2103 if !decodeBody(w, r, &cfg) { 2104 return 2105 } 2106 if cfg.Tools != nil { 2107 if err := agents.ValidateToolsFilter(cfg.Tools); err != nil { 2108 writeError(w, http.StatusBadRequest, err.Error()) 2109 return 2110 } 2111 } 2112 // Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input. 2113 if !s.materializeIfBuiltin(w, name) { 2114 return 2115 } 2116 if err := agents.WriteAgentConfig(s.deps.AgentsDir, name, &cfg); err != nil { 2117 writeError(w, http.StatusInternalServerError, err.Error()) 2118 return 2119 } 2120 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 2121 } 2122 2123 func (s *Server) handleDeleteAgentConfig(w http.ResponseWriter, r *http.Request) { 2124 name := r.PathValue("name") 2125 if err := agents.ValidateAgentName(name); err != nil { 2126 writeError(w, http.StatusBadRequest, err.Error()) 2127 return 2128 } 2129 if !s.agentExists(w, name) { 2130 return 2131 } 2132 if !s.materializeIfBuiltin(w, name) { 2133 return 2134 } 2135 path := filepath.Join(s.deps.AgentsDir, name, "config.yaml") 2136 if err := os.Remove(path); err != nil && !os.IsNotExist(err) { 2137 writeError(w, http.StatusInternalServerError, err.Error()) 2138 return 2139 } 2140 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2141 } 2142 2143 func (s *Server) handlePutCommand(w http.ResponseWriter, r *http.Request) { 2144 agentName := r.PathValue("name") 2145 cmdName := r.PathValue("cmd") 2146 if err := agents.ValidateAgentName(agentName); err != nil { 2147 writeError(w, http.StatusBadRequest, err.Error()) 2148 return 2149 } 2150 if !s.agentExists(w, agentName) { 2151 return 2152 } 2153 if err := agents.ValidateCommandName(cmdName); err != nil { 2154 writeError(w, http.StatusBadRequest, err.Error()) 2155 return 2156 } 2157 var body struct { 2158 Content string `json:"content"` 2159 } 2160 if !decodeBody(w, r, &body) { 2161 return 2162 } 2163 if body.Content == "" { 2164 writeError(w, http.StatusBadRequest, "content is required") 2165 return 2166 } 2167 // Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input. 2168 if !s.materializeIfBuiltin(w, agentName) { 2169 return 2170 } 2171 if err := agents.WriteAgentCommand(s.deps.AgentsDir, agentName, cmdName, body.Content); err != nil { 2172 writeError(w, http.StatusInternalServerError, err.Error()) 2173 return 2174 } 2175 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 2176 } 2177 2178 func (s *Server) handleDeleteCommand(w http.ResponseWriter, r *http.Request) { 2179 agentName := r.PathValue("name") 2180 cmdName := r.PathValue("cmd") 2181 if err := agents.ValidateAgentName(agentName); err != nil { 2182 writeError(w, http.StatusBadRequest, err.Error()) 2183 return 2184 } 2185 if !s.agentExists(w, agentName) { 2186 return 2187 } 2188 if !s.materializeIfBuiltin(w, agentName) { 2189 return 2190 } 2191 if err := agents.ValidateCommandName(cmdName); err != nil { 2192 writeError(w, http.StatusBadRequest, err.Error()) 2193 return 2194 } 2195 if err := agents.DeleteAgentCommand(s.deps.AgentsDir, agentName, cmdName); err != nil && !os.IsNotExist(err) { 2196 writeError(w, http.StatusInternalServerError, err.Error()) 2197 return 2198 } 2199 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2200 } 2201 2202 func (s *Server) handlePutSkill(w http.ResponseWriter, r *http.Request) { 2203 agentName := r.PathValue("name") 2204 skillName := r.PathValue("skill") 2205 if err := agents.ValidateAgentName(agentName); err != nil { 2206 writeError(w, http.StatusBadRequest, err.Error()) 2207 return 2208 } 2209 if !s.agentExists(w, agentName) { 2210 return 2211 } 2212 if err := skills.ValidateSkillName(skillName); err != nil { 2213 writeError(w, http.StatusBadRequest, err.Error()) 2214 return 2215 } 2216 var body struct { 2217 Name string `json:"name"` 2218 } 2219 if ok, provided := decodeOptionalBody(w, r, &body); !ok { 2220 return 2221 } else if provided && body.Name != "" && body.Name != skillName { 2222 writeError(w, http.StatusBadRequest, "skill name in body must match URL") 2223 return 2224 } 2225 if err := s.validateInstalledSkills([]string{skillName}); err != nil { 2226 writeError(w, http.StatusBadRequest, err.Error()) 2227 return 2228 } 2229 // Materialize builtin AFTER validation passes — avoids orphaned override dirs on bad input. 2230 if !s.materializeIfBuiltin(w, agentName) { 2231 return 2232 } 2233 if err := agents.AttachSkill(s.deps.AgentsDir, agentName, skillName); err != nil { 2234 writeError(w, http.StatusInternalServerError, err.Error()) 2235 return 2236 } 2237 if err := agents.DeleteAgentSkill(s.deps.AgentsDir, agentName, skillName); err != nil && !os.IsNotExist(err) { 2238 writeError(w, http.StatusInternalServerError, err.Error()) 2239 return 2240 } 2241 writeJSON(w, http.StatusOK, map[string]string{"status": "attached"}) 2242 } 2243 2244 func (s *Server) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { 2245 agentName := r.PathValue("name") 2246 skillName := r.PathValue("skill") 2247 if err := agents.ValidateAgentName(agentName); err != nil { 2248 writeError(w, http.StatusBadRequest, err.Error()) 2249 return 2250 } 2251 if !s.agentExists(w, agentName) { 2252 return 2253 } 2254 if !s.materializeIfBuiltin(w, agentName) { 2255 return 2256 } 2257 if err := skills.ValidateSkillName(skillName); err != nil { 2258 writeError(w, http.StatusBadRequest, err.Error()) 2259 return 2260 } 2261 if err := agents.DetachSkill(s.deps.AgentsDir, agentName, skillName); err != nil { 2262 writeError(w, http.StatusInternalServerError, err.Error()) 2263 return 2264 } 2265 if err := agents.DeleteAgentSkill(s.deps.AgentsDir, agentName, skillName); err != nil && !os.IsNotExist(err) { 2266 writeError(w, http.StatusInternalServerError, err.Error()) 2267 return 2268 } 2269 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2270 } 2271 2272 // --- Marketplace handlers --- 2273 2274 func (s *Server) handleMarketplaceList(w http.ResponseWriter, r *http.Request) { 2275 if !s.requireDeps(w) { 2276 return 2277 } 2278 idx, err := s.marketplace.Load(r.Context()) 2279 if err != nil { 2280 writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err)) 2281 return 2282 } 2283 2284 q := r.URL.Query() 2285 page := parseIntParam(q.Get("page"), 1) 2286 size := parseIntParam(q.Get("size"), 20) 2287 sortKey := q.Get("sort") 2288 if sortKey == "" { 2289 sortKey = "downloads" 2290 } 2291 search := q.Get("q") 2292 2293 entries, total := skills.FilterSortPaginate(idx.Skills, search, sortKey, page, size) 2294 2295 // Mark `installed` flag for entries already on disk. 2296 installed := installedSkillSet(s.deps.ShannonDir) 2297 type listItem struct { 2298 skills.MarketplaceEntry 2299 Installed bool `json:"installed"` 2300 } 2301 items := make([]listItem, 0, len(entries)) 2302 for _, e := range entries { 2303 items = append(items, listItem{MarketplaceEntry: e, Installed: installed[e.Slug]}) 2304 } 2305 2306 if s.marketplace.IsStale() { 2307 w.Header().Set("X-Cache-Stale", "true") 2308 } 2309 writeJSON(w, http.StatusOK, map[string]interface{}{ 2310 "total": total, 2311 "page": page, 2312 "size": size, 2313 "skills": items, 2314 }) 2315 } 2316 2317 func (s *Server) handleSkillUsage(w http.ResponseWriter, r *http.Request) { 2318 if !s.requireDeps(w) { 2319 return 2320 } 2321 name := r.PathValue("name") 2322 if err := skills.ValidateSkillName(name); err != nil { 2323 writeError(w, http.StatusBadRequest, err.Error()) 2324 return 2325 } 2326 used, err := agents.AgentsAttachingSkill(s.deps.AgentsDir, name) 2327 if err != nil { 2328 writeError(w, http.StatusInternalServerError, fmt.Sprintf("read attached skills: %v", err)) 2329 return 2330 } 2331 writeJSON(w, http.StatusOK, map[string]interface{}{ 2332 "skill": name, 2333 "agents": used, 2334 }) 2335 } 2336 2337 func (s *Server) handleMarketplaceInstall(w http.ResponseWriter, r *http.Request) { 2338 if !s.requireDeps(w) { 2339 return 2340 } 2341 slug := r.PathValue("slug") 2342 if err := skills.ValidateSkillName(slug); err != nil { 2343 writeError(w, http.StatusBadRequest, err.Error()) 2344 return 2345 } 2346 2347 idx, err := s.marketplace.Load(r.Context()) 2348 if err != nil { 2349 writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err)) 2350 return 2351 } 2352 var entry *skills.MarketplaceEntry 2353 for i := range idx.Skills { 2354 if idx.Skills[i].Slug == slug { 2355 entry = &idx.Skills[i] 2356 break 2357 } 2358 } 2359 if entry == nil { 2360 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in marketplace", slug)) 2361 return 2362 } 2363 2364 err = skills.InstallFromMarketplace(r.Context(), s.deps.ShannonDir, *entry, s.slugLocks) 2365 switch { 2366 case err == nil: 2367 // Load the freshly installed skill so the response body reflects 2368 // on-disk truth (frontmatter name, description, source) rather 2369 // than synthesized data from the registry. Mirrors the pattern 2370 // used by handleInstallSkill for Anthropic-repo installs. 2371 sources, _ := s.skillSources() 2372 list, _ := skills.LoadSkills(sources...) 2373 for _, skill := range list { 2374 if skill.Slug == entry.Slug { 2375 writeJSON(w, http.StatusCreated, skill.ToMeta()) 2376 return 2377 } 2378 } 2379 // Fallback: install succeeded but the skill did not show up in 2380 // LoadSkills. This shouldn't happen because InstallFromMarketplace 2381 // guarantees a valid SKILL.md on success, but we return a stable 2382 // 201 with minimal info rather than misleading the client. Slug 2383 // is the primary identifier clients use for subsequent CRUD, so 2384 // populate it explicitly instead of leaving it empty. 2385 fallbackName := entry.Name 2386 if fallbackName == "" { 2387 fallbackName = entry.Slug 2388 } 2389 writeJSON(w, http.StatusCreated, skills.SkillMeta{ 2390 Name: fallbackName, 2391 Slug: entry.Slug, 2392 Description: entry.Description, 2393 Source: "global", 2394 }) 2395 case errors.Is(err, skills.ErrMaliciousSkill): 2396 writeError(w, http.StatusForbidden, err.Error()) 2397 case errors.Is(err, skills.ErrSkillAlreadyInstalled): 2398 writeError(w, http.StatusConflict, err.Error()) 2399 case errors.Is(err, skills.ErrInvalidSkillPayload): 2400 writeError(w, http.StatusUnprocessableEntity, err.Error()) 2401 case errors.Is(err, skills.ErrMarketplaceUpstreamFailure): 2402 writeError(w, http.StatusBadGateway, fmt.Sprintf("install failed: %v", err)) 2403 default: 2404 // Local disk/staging failures → 500, per spec error matrix. 2405 writeError(w, http.StatusInternalServerError, fmt.Sprintf("install failed: %v", err)) 2406 } 2407 } 2408 2409 func (s *Server) handleMarketplaceDetail(w http.ResponseWriter, r *http.Request) { 2410 if !s.requireDeps(w) { 2411 return 2412 } 2413 slug := r.PathValue("slug") 2414 if err := skills.ValidateSkillName(slug); err != nil { 2415 writeError(w, http.StatusBadRequest, err.Error()) 2416 return 2417 } 2418 idx, err := s.marketplace.Load(r.Context()) 2419 if err != nil { 2420 writeError(w, http.StatusServiceUnavailable, fmt.Sprintf("marketplace unavailable: %v", err)) 2421 return 2422 } 2423 var entry *skills.MarketplaceEntry 2424 for i := range idx.Skills { 2425 if idx.Skills[i].Slug == slug { 2426 entry = &idx.Skills[i] 2427 break 2428 } 2429 } 2430 if entry == nil { 2431 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in marketplace", slug)) 2432 return 2433 } 2434 2435 // Consistent with list + install: malicious entries are hidden. 2436 if entry.IsMalicious() { 2437 writeError(w, http.StatusForbidden, "skill blocked by security scan") 2438 return 2439 } 2440 2441 // Response wraps the registry entry plus live state. Preview holds the 2442 // installed SKILL.md body when present — empty string otherwise, so the 2443 // field is always part of the schema. NO omitempty so Desktop clients 2444 // can rely on the field's existence regardless of install state. 2445 type detailResponse struct { 2446 skills.MarketplaceEntry 2447 Installed bool `json:"installed"` 2448 Preview string `json:"preview"` 2449 } 2450 2451 resp := detailResponse{MarketplaceEntry: *entry} 2452 skillDir := filepath.Join(s.deps.ShannonDir, "skills", slug) 2453 skillFile := filepath.Join(skillDir, "SKILL.md") 2454 if body, err := os.ReadFile(skillFile); err == nil { 2455 resp.Installed = true 2456 resp.Preview = string(body) 2457 } 2458 2459 writeJSON(w, http.StatusOK, resp) 2460 } 2461 2462 // parseIntParam parses a positive int query parameter, falling back to def 2463 // on empty or invalid input. Shared by marketplace handlers. 2464 func parseIntParam(raw string, def int) int { 2465 if raw == "" { 2466 return def 2467 } 2468 n, err := strconv.Atoi(raw) 2469 if err != nil || n < 1 { 2470 return def 2471 } 2472 return n 2473 } 2474 2475 // installedSkillSet returns the set of skill slugs present in 2476 // ~/.shannon/skills/. Missing directory → empty set, no error. 2477 func installedSkillSet(shannonDir string) map[string]bool { 2478 out := make(map[string]bool) 2479 entries, err := os.ReadDir(filepath.Join(shannonDir, "skills")) 2480 if err != nil { 2481 return out 2482 } 2483 for _, e := range entries { 2484 if !e.IsDir() { 2485 continue 2486 } 2487 if _, err := os.Stat(filepath.Join(shannonDir, "skills", e.Name(), "SKILL.md")); err == nil { 2488 out[e.Name()] = true 2489 } 2490 } 2491 return out 2492 } 2493 2494 // --- Global skills handlers --- 2495 2496 func (s *Server) handleListSkills(w http.ResponseWriter, r *http.Request) { 2497 sources, err := s.skillSources() 2498 if err != nil { 2499 writeError(w, http.StatusInternalServerError, err.Error()) 2500 return 2501 } 2502 2503 list, err := skills.LoadSkills(sources...) 2504 if err != nil { 2505 writeError(w, http.StatusInternalServerError, err.Error()) 2506 return 2507 } 2508 2509 // hidden: true is display-only — the skill is still loaded and invokable 2510 // via use_skill. Admin/management UIs can pass ?include_hidden=true. 2511 includeHidden := r.URL.Query().Get("include_hidden") == "true" 2512 metas := make([]skills.SkillMeta, 0, len(list)) 2513 for _, skill := range list { 2514 if skill.Hidden && !includeHidden { 2515 continue 2516 } 2517 meta := skill.ToMeta() 2518 meta.RequiredSecrets = skill.RequiredSecrets() 2519 meta.ConfiguredSecrets = s.secretsStore.ConfiguredKeys(skill.Slug) 2520 metas = append(metas, meta) 2521 } 2522 writeJSON(w, http.StatusOK, map[string]interface{}{"skills": metas}) 2523 } 2524 2525 func (s *Server) handleListDownloadableSkills(w http.ResponseWriter, r *http.Request) { 2526 globalDir := filepath.Join(s.deps.ShannonDir, "skills") 2527 result := make([]skills.DownloadableSkill, 0, len(skills.DownloadableSkills)) 2528 for _, ds := range skills.DownloadableSkills { 2529 installed := false 2530 if _, err := os.Stat(filepath.Join(globalDir, ds.Name, "SKILL.md")); err == nil { 2531 installed = true 2532 } 2533 result = append(result, skills.DownloadableSkill{ 2534 Name: ds.Name, 2535 Description: ds.Description, 2536 Installed: installed, 2537 }) 2538 } 2539 writeJSON(w, http.StatusOK, map[string]interface{}{"skills": result}) 2540 } 2541 2542 func (s *Server) handleInstallSkill(w http.ResponseWriter, r *http.Request) { 2543 name := r.PathValue("name") 2544 if !skills.IsDownloadable(name) { 2545 writeError(w, http.StatusBadRequest, fmt.Sprintf("skill %q is not available for download", name)) 2546 return 2547 } 2548 2549 if err := skills.InstallSkillFromRepo(s.deps.ShannonDir, name); err != nil { 2550 if strings.Contains(err.Error(), "already installed") { 2551 writeError(w, http.StatusConflict, err.Error()) 2552 return 2553 } 2554 writeError(w, http.StatusInternalServerError, err.Error()) 2555 return 2556 } 2557 2558 // Load the installed skill to return its metadata 2559 sources, _ := s.skillSources() 2560 list, _ := skills.LoadSkills(sources...) 2561 for _, skill := range list { 2562 if skill.Slug == name { 2563 writeJSON(w, http.StatusCreated, skill.ToMeta()) 2564 return 2565 } 2566 } 2567 writeJSON(w, http.StatusCreated, map[string]string{"status": "installed", "name": name}) 2568 } 2569 2570 func (s *Server) handleGetSkill(w http.ResponseWriter, r *http.Request) { 2571 // Intentionally does NOT filter by skill.Hidden — single-skill lookup is 2572 // for callers that already know the slug (admin UIs, kocoro secrets 2573 // management). Hidden is a browse-list display filter, not an access 2574 // control. Do not add a hidden check here without revisiting handleListSkills. 2575 name := r.PathValue("name") 2576 if err := skills.ValidateSkillName(name); err != nil { 2577 writeError(w, http.StatusBadRequest, err.Error()) 2578 return 2579 } 2580 2581 sources, err := s.skillSources() 2582 if err != nil { 2583 writeError(w, http.StatusInternalServerError, err.Error()) 2584 return 2585 } 2586 list, err := skills.LoadSkills(sources...) 2587 if err != nil { 2588 writeError(w, http.StatusInternalServerError, err.Error()) 2589 return 2590 } 2591 for _, skill := range list { 2592 if skill.Slug == name { 2593 detail := skills.SkillDetail{ 2594 Name: skill.Name, 2595 Slug: skill.Slug, 2596 Description: skill.Description, 2597 Prompt: skill.Prompt, 2598 Source: skill.Source, 2599 InstallSource: skill.InstallSource, 2600 MarketplaceSlug: skill.MarketplaceSlug, 2601 License: skill.License, 2602 Compatibility: skill.Compatibility, 2603 Metadata: skill.Metadata, 2604 StickyInstructions: skill.StickyInstructions, 2605 Hidden: skill.Hidden, 2606 StickySnippet: skill.StickySnippetOverride, 2607 } 2608 if len(skill.AllowedTools) > 0 { 2609 detail.AllowedTools = skill.AllowedTools 2610 } 2611 detail.RequiredSecrets = skill.RequiredSecrets() 2612 detail.ConfiguredSecrets = s.secretsStore.ConfiguredKeys(skill.Slug) 2613 writeJSON(w, http.StatusOK, detail) 2614 return 2615 } 2616 } 2617 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name)) 2618 } 2619 2620 func (s *Server) handlePutGlobalSkill(w http.ResponseWriter, r *http.Request) { 2621 name := r.PathValue("name") 2622 if err := skills.ValidateSkillName(name); err != nil { 2623 writeError(w, http.StatusBadRequest, err.Error()) 2624 return 2625 } 2626 var req struct { 2627 Description string `json:"description"` 2628 Prompt string `json:"prompt"` 2629 License string `json:"license"` 2630 StickyInstructions *bool `json:"sticky_instructions,omitempty"` 2631 StickySnippet *string `json:"sticky_snippet,omitempty"` 2632 } 2633 if !decodeBody(w, r, &req) { 2634 return 2635 } 2636 if req.Description == "" { 2637 writeError(w, http.StatusBadRequest, "description is required") 2638 return 2639 } 2640 if req.Prompt == "" { 2641 writeError(w, http.StatusBadRequest, "prompt is required") 2642 return 2643 } 2644 if s.deps == nil { 2645 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 2646 return 2647 } 2648 // Load the existing skill so we can preserve fields the PUT body doesn't 2649 // carry (AllowedTools, Metadata, Compatibility). If the skill directory 2650 // already exists but load fails, refuse the write rather than silently 2651 // clobber those fields with zero values — kocoro's `allowed-tools: 2652 // http file_read` is security-critical and must not be dropped on a 2653 // transient FS error. 2654 var skillToWrite skills.Skill 2655 skillExistsOnDisk := false 2656 if s.deps != nil && s.deps.ShannonDir != "" { 2657 if _, statErr := os.Stat(filepath.Join(s.deps.ShannonDir, "skills", name, "SKILL.md")); statErr == nil { 2658 skillExistsOnDisk = true 2659 } 2660 } 2661 sources, err := s.skillSources() 2662 if err != nil { 2663 if skillExistsOnDisk { 2664 writeError(w, http.StatusServiceUnavailable, 2665 fmt.Sprintf("cannot resolve skill sources to preserve existing fields: %v", err)) 2666 return 2667 } 2668 } else { 2669 list, loadErr := skills.LoadSkills(sources...) 2670 if loadErr != nil && skillExistsOnDisk { 2671 writeError(w, http.StatusServiceUnavailable, 2672 fmt.Sprintf("cannot load existing skill %q (refusing to clobber AllowedTools/Metadata): %v", name, loadErr)) 2673 return 2674 } 2675 // URL param is the slug (directory identifier), not the 2676 // frontmatter display label. Match by Slug so a skill whose 2677 // Name differs from its Slug (e.g. "Docker" / "docker") is 2678 // found and its AllowedTools/Metadata are preserved. 2679 for _, existing := range list { 2680 if existing.Slug == name { 2681 skillToWrite = *existing 2682 break 2683 } 2684 } 2685 } 2686 skillToWrite.Slug = name 2687 // Preserve the existing Name when updating; only overwrite if the 2688 // skill is brand-new (Name was never set). The URL slug must not 2689 // replace a carefully chosen display label like "Docker". 2690 if skillToWrite.Name == "" { 2691 skillToWrite.Name = name 2692 } 2693 skillToWrite.Description = req.Description 2694 skillToWrite.Prompt = req.Prompt 2695 skillToWrite.License = req.License 2696 if req.StickyInstructions != nil { 2697 skillToWrite.StickyInstructions = *req.StickyInstructions 2698 } 2699 if req.StickySnippet != nil { 2700 skillToWrite.StickySnippetOverride = strings.TrimSpace(*req.StickySnippet) 2701 } 2702 if err := skills.WriteGlobalSkill(s.deps.ShannonDir, &skillToWrite); err != nil { 2703 writeError(w, http.StatusInternalServerError, err.Error()) 2704 return 2705 } 2706 s.auditHTTPOp("PUT", "/skills/"+name, "wrote global skill") 2707 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 2708 } 2709 2710 func (s *Server) handleDeleteGlobalSkill(w http.ResponseWriter, r *http.Request) { 2711 if requireConfirm(r.URL.Query().Get("confirm")) { 2712 writeJSON(w, http.StatusBadRequest, map[string]string{ 2713 "error": "confirmation_required", 2714 "message": "This will permanently delete the skill. Add ?confirm=true to proceed.", 2715 }) 2716 return 2717 } 2718 name := r.PathValue("name") 2719 if err := skills.ValidateSkillName(name); err != nil { 2720 writeError(w, http.StatusBadRequest, err.Error()) 2721 return 2722 } 2723 if s.deps == nil { 2724 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 2725 return 2726 } 2727 globalDir := filepath.Join(s.deps.ShannonDir, "skills", name) 2728 skillFile := filepath.Join(globalDir, "SKILL.md") 2729 if _, err := os.Stat(skillFile); err != nil { 2730 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found in global directory", name)) 2731 return 2732 } 2733 if err := skills.DeleteGlobalSkill(s.deps.ShannonDir, name); err != nil { 2734 writeError(w, http.StatusInternalServerError, err.Error()) 2735 return 2736 } 2737 s.auditHTTPOp("DELETE", "/skills/"+name, "deleted skill") 2738 s.secretsStore.Delete(name) 2739 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2740 } 2741 2742 func (s *Server) handlePutSkillSecrets(w http.ResponseWriter, r *http.Request) { 2743 name := r.PathValue("name") 2744 if err := skills.ValidateSkillName(name); err != nil { 2745 writeError(w, http.StatusBadRequest, err.Error()) 2746 return 2747 } 2748 var secrets map[string]string 2749 if !decodeBody(w, r, &secrets) { 2750 return 2751 } 2752 if len(secrets) == 0 { 2753 writeError(w, http.StatusBadRequest, "no secrets provided") 2754 return 2755 } 2756 keys := make([]string, 0, len(secrets)) 2757 for key := range secrets { 2758 if !skills.IsValidEnvKey(key) { 2759 writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid secret key %q: must match [A-Z0-9_]+", key)) 2760 return 2761 } 2762 keys = append(keys, key) 2763 } 2764 sort.Strings(keys) 2765 if err := s.secretsStore.Set(name, secrets); err != nil { 2766 writeError(w, http.StatusInternalServerError, err.Error()) 2767 return 2768 } 2769 s.auditHTTPOp("PUT", "/skills/"+name+"/secrets", "set secrets for skill: "+strings.Join(keys, ",")) 2770 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 2771 } 2772 2773 func (s *Server) handleDeleteSkillSecrets(w http.ResponseWriter, r *http.Request) { 2774 name := r.PathValue("name") 2775 if err := skills.ValidateSkillName(name); err != nil { 2776 writeError(w, http.StatusBadRequest, err.Error()) 2777 return 2778 } 2779 if err := s.secretsStore.Delete(name); err != nil { 2780 writeError(w, http.StatusInternalServerError, err.Error()) 2781 return 2782 } 2783 s.auditHTTPOp("DELETE", "/skills/"+name+"/secrets", "cleared all secrets for skill") 2784 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2785 } 2786 2787 func (s *Server) handleDeleteSkillSecretKey(w http.ResponseWriter, r *http.Request) { 2788 name := r.PathValue("name") 2789 key := r.PathValue("key") 2790 if err := skills.ValidateSkillName(name); err != nil { 2791 writeError(w, http.StatusBadRequest, err.Error()) 2792 return 2793 } 2794 if key == "" { 2795 writeError(w, http.StatusBadRequest, "key is required") 2796 return 2797 } 2798 if err := s.secretsStore.DeleteKey(name, key); err != nil { 2799 writeError(w, http.StatusInternalServerError, err.Error()) 2800 return 2801 } 2802 s.auditHTTPOp("DELETE", "/skills/"+name+"/secrets/"+key, "removed secret key") 2803 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2804 } 2805 2806 func (s *Server) handleListSkillScripts(w http.ResponseWriter, r *http.Request) { 2807 s.handleListSkillSubresource(w, r, "scripts") 2808 } 2809 2810 func (s *Server) handlePutSkillScripts(w http.ResponseWriter, r *http.Request) { 2811 s.handlePutSkillSubresource(w, r, "scripts") 2812 } 2813 2814 func (s *Server) handleDeleteSkillScripts(w http.ResponseWriter, r *http.Request) { 2815 s.handleDeleteSkillSubresource(w, r, "scripts") 2816 } 2817 2818 func (s *Server) handleListSkillReferences(w http.ResponseWriter, r *http.Request) { 2819 s.handleListSkillSubresource(w, r, "references") 2820 } 2821 2822 func (s *Server) handlePutSkillReferences(w http.ResponseWriter, r *http.Request) { 2823 s.handlePutSkillSubresource(w, r, "references") 2824 } 2825 2826 func (s *Server) handleDeleteSkillReferences(w http.ResponseWriter, r *http.Request) { 2827 s.handleDeleteSkillSubresource(w, r, "references") 2828 } 2829 2830 func (s *Server) handleListSkillAssets(w http.ResponseWriter, r *http.Request) { 2831 s.handleListSkillSubresource(w, r, "assets") 2832 } 2833 2834 func (s *Server) handlePutSkillAssets(w http.ResponseWriter, r *http.Request) { 2835 s.handlePutSkillSubresource(w, r, "assets") 2836 } 2837 2838 func (s *Server) handleDeleteSkillAssets(w http.ResponseWriter, r *http.Request) { 2839 s.handleDeleteSkillSubresource(w, r, "assets") 2840 } 2841 2842 func (s *Server) handleListSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) { 2843 name := r.PathValue("name") 2844 if err := skills.ValidateSkillName(name); err != nil { 2845 writeError(w, http.StatusBadRequest, err.Error()) 2846 return 2847 } 2848 dir, _, _, err := s.resolveSkillDir(name) 2849 if err != nil { 2850 if os.IsNotExist(err) { 2851 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name)) 2852 return 2853 } 2854 writeError(w, http.StatusInternalServerError, err.Error()) 2855 return 2856 } 2857 target := filepath.Join(dir, subdir) 2858 entries, err := os.ReadDir(target) 2859 if err != nil { 2860 if os.IsNotExist(err) { 2861 writeJSON(w, http.StatusOK, map[string][]string{"files": []string{}}) 2862 return 2863 } 2864 writeError(w, http.StatusInternalServerError, err.Error()) 2865 return 2866 } 2867 files := make([]string, 0, len(entries)) 2868 for _, entry := range entries { 2869 if entry.IsDir() { 2870 continue 2871 } 2872 files = append(files, entry.Name()) 2873 } 2874 sort.Strings(files) 2875 writeJSON(w, http.StatusOK, map[string][]string{"files": files}) 2876 } 2877 2878 func (s *Server) handlePutSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) { 2879 name := r.PathValue("name") 2880 if err := skills.ValidateSkillName(name); err != nil { 2881 writeError(w, http.StatusBadRequest, err.Error()) 2882 return 2883 } 2884 dir, _, readOnly, err := s.resolveSkillDir(name) 2885 if err != nil { 2886 if os.IsNotExist(err) { 2887 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name)) 2888 return 2889 } 2890 writeError(w, http.StatusInternalServerError, err.Error()) 2891 return 2892 } 2893 if readOnly { 2894 writeError(w, http.StatusBadRequest, "bundled skill is read-only; create a global override first via PUT /skills/{name}") 2895 return 2896 } 2897 filename := r.PathValue("filename") 2898 if !isValidSkillFileName(filename) { 2899 writeError(w, http.StatusBadRequest, "invalid filename") 2900 return 2901 } 2902 r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) 2903 data, err := io.ReadAll(r.Body) 2904 if err != nil { 2905 var maxErr *http.MaxBytesError 2906 if errors.As(err, &maxErr) { 2907 writeError(w, http.StatusRequestEntityTooLarge, "request body too large") 2908 return 2909 } 2910 writeError(w, http.StatusInternalServerError, err.Error()) 2911 return 2912 } 2913 targetDir := filepath.Join(dir, subdir) 2914 if err := os.MkdirAll(targetDir, 0700); err != nil { 2915 writeError(w, http.StatusInternalServerError, err.Error()) 2916 return 2917 } 2918 fileMode := modeForSubresource(subdir) 2919 tmp, err := os.CreateTemp(targetDir, ".skill-file-*") 2920 if err != nil { 2921 writeError(w, http.StatusInternalServerError, err.Error()) 2922 return 2923 } 2924 tmpPath := tmp.Name() 2925 if _, err := tmp.Write(data); err != nil { 2926 tmp.Close() 2927 os.Remove(tmpPath) 2928 writeError(w, http.StatusInternalServerError, err.Error()) 2929 return 2930 } 2931 if err := tmp.Close(); err != nil { 2932 os.Remove(tmpPath) 2933 writeError(w, http.StatusInternalServerError, err.Error()) 2934 return 2935 } 2936 if err := os.Chmod(tmpPath, fileMode); err != nil { 2937 os.Remove(tmpPath) 2938 writeError(w, http.StatusInternalServerError, err.Error()) 2939 return 2940 } 2941 dest := filepath.Join(targetDir, filename) 2942 if err := os.Rename(tmpPath, dest); err != nil { 2943 os.Remove(tmpPath) 2944 writeError(w, http.StatusInternalServerError, err.Error()) 2945 return 2946 } 2947 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 2948 } 2949 2950 func (s *Server) handleDeleteSkillSubresource(w http.ResponseWriter, r *http.Request, subdir string) { 2951 name := r.PathValue("name") 2952 if err := skills.ValidateSkillName(name); err != nil { 2953 writeError(w, http.StatusBadRequest, err.Error()) 2954 return 2955 } 2956 dir, _, readOnly, err := s.resolveSkillDir(name) 2957 if err != nil { 2958 if os.IsNotExist(err) { 2959 writeError(w, http.StatusNotFound, fmt.Sprintf("skill %q not found", name)) 2960 return 2961 } 2962 writeError(w, http.StatusInternalServerError, err.Error()) 2963 return 2964 } 2965 if readOnly { 2966 writeError(w, http.StatusBadRequest, "bundled skill is read-only; create a global override first via PUT /skills/{name}") 2967 return 2968 } 2969 filename := r.PathValue("filename") 2970 if !isValidSkillFileName(filename) { 2971 writeError(w, http.StatusBadRequest, "invalid filename") 2972 return 2973 } 2974 if err := os.Remove(filepath.Join(dir, subdir, filename)); err != nil && !os.IsNotExist(err) { 2975 writeError(w, http.StatusInternalServerError, err.Error()) 2976 return 2977 } 2978 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 2979 } 2980 2981 // --- Schedule handlers --- 2982 2983 func (s *Server) handleListSchedules(w http.ResponseWriter, r *http.Request) { 2984 if s.deps == nil || s.deps.ScheduleManager == nil { 2985 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 2986 return 2987 } 2988 list, err := s.deps.ScheduleManager.List() 2989 if err != nil { 2990 writeError(w, http.StatusInternalServerError, err.Error()) 2991 return 2992 } 2993 if list == nil { 2994 list = []schedule.Schedule{} 2995 } 2996 writeJSON(w, http.StatusOK, map[string]interface{}{"schedules": list}) 2997 } 2998 2999 func (s *Server) handleGetSchedule(w http.ResponseWriter, r *http.Request) { 3000 if s.deps == nil || s.deps.ScheduleManager == nil { 3001 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 3002 return 3003 } 3004 id := r.PathValue("id") 3005 sched, err := s.deps.ScheduleManager.Get(id) 3006 if err != nil { 3007 if strings.Contains(err.Error(), "not found") { 3008 writeError(w, http.StatusNotFound, err.Error()) 3009 return 3010 } 3011 writeError(w, http.StatusInternalServerError, err.Error()) 3012 return 3013 } 3014 writeJSON(w, http.StatusOK, sched) 3015 } 3016 3017 func (s *Server) handleCreateSchedule(w http.ResponseWriter, r *http.Request) { 3018 if s.deps == nil || s.deps.ScheduleManager == nil { 3019 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 3020 return 3021 } 3022 var req struct { 3023 Agent string `json:"agent"` 3024 Cron string `json:"cron"` 3025 Prompt string `json:"prompt"` 3026 } 3027 if !decodeBody(w, r, &req) { 3028 return 3029 } 3030 id, err := s.deps.ScheduleManager.Create(req.Agent, req.Cron, req.Prompt) 3031 if err != nil { 3032 if strings.Contains(err.Error(), "not found") { 3033 writeError(w, http.StatusNotFound, err.Error()) 3034 } else if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "prompt cannot be empty") { 3035 writeError(w, http.StatusBadRequest, err.Error()) 3036 } else { 3037 writeError(w, http.StatusInternalServerError, err.Error()) 3038 } 3039 return 3040 } 3041 sched, err := s.deps.ScheduleManager.Get(id) 3042 if err != nil { 3043 writeError(w, http.StatusInternalServerError, err.Error()) 3044 return 3045 } 3046 writeJSON(w, http.StatusCreated, sched) 3047 } 3048 3049 func (s *Server) handlePatchSchedule(w http.ResponseWriter, r *http.Request) { 3050 if s.deps == nil || s.deps.ScheduleManager == nil { 3051 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 3052 return 3053 } 3054 id := r.PathValue("id") 3055 var patch struct { 3056 Cron *string `json:"cron"` 3057 Prompt *string `json:"prompt"` 3058 Enabled *bool `json:"enabled"` 3059 } 3060 if !decodeBody(w, r, &patch) { 3061 return 3062 } 3063 if patch.Cron == nil && patch.Prompt == nil && patch.Enabled == nil { 3064 writeError(w, http.StatusBadRequest, "no fields to update") 3065 return 3066 } 3067 update := &schedule.UpdateOpts{ 3068 Cron: patch.Cron, 3069 Prompt: patch.Prompt, 3070 Enabled: patch.Enabled, 3071 } 3072 if err := s.deps.ScheduleManager.Update(id, update); err != nil { 3073 if strings.Contains(err.Error(), "not found") { 3074 writeError(w, http.StatusNotFound, err.Error()) 3075 return 3076 } 3077 if strings.Contains(err.Error(), "no fields to update") || 3078 strings.Contains(err.Error(), "invalid") || 3079 strings.Contains(err.Error(), "prompt cannot be empty") { 3080 writeError(w, http.StatusBadRequest, err.Error()) 3081 return 3082 } 3083 writeError(w, http.StatusInternalServerError, err.Error()) 3084 return 3085 } 3086 sched, err := s.deps.ScheduleManager.Get(id) 3087 if err != nil { 3088 writeError(w, http.StatusInternalServerError, err.Error()) 3089 return 3090 } 3091 writeJSON(w, http.StatusOK, sched) 3092 } 3093 3094 func (s *Server) handleDeleteSchedule(w http.ResponseWriter, r *http.Request) { 3095 if requireConfirm(r.URL.Query().Get("confirm")) { 3096 writeJSON(w, http.StatusBadRequest, map[string]string{ 3097 "error": "confirmation_required", 3098 "message": "This will permanently delete the schedule. Add ?confirm=true to proceed.", 3099 }) 3100 return 3101 } 3102 if s.deps == nil || s.deps.ScheduleManager == nil { 3103 writeError(w, http.StatusInternalServerError, "daemon deps not configured") 3104 return 3105 } 3106 id := r.PathValue("id") 3107 if err := s.deps.ScheduleManager.Remove(id); err != nil { 3108 if strings.Contains(err.Error(), "not found") { 3109 writeError(w, http.StatusNotFound, err.Error()) 3110 return 3111 } 3112 writeError(w, http.StatusInternalServerError, err.Error()) 3113 return 3114 } 3115 s.auditHTTPOp("DELETE", "/schedules/"+id, "deleted schedule") 3116 writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) 3117 } 3118 3119 // --- Global config + instructions handlers --- 3120 3121 func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { 3122 globalPath := filepath.Join(s.deps.ShannonDir, "config.yaml") 3123 globalData, err := os.ReadFile(globalPath) 3124 var globalMap map[string]interface{} 3125 if err == nil { 3126 if yamlErr := yaml.Unmarshal(globalData, &globalMap); yamlErr != nil { 3127 log.Printf("daemon: GET /config: global config parse error: %v", yamlErr) 3128 } 3129 } 3130 3131 cfg, _, _ := s.deps.Snapshot() 3132 effectiveJSON, _ := json.Marshal(cfg) 3133 var effectiveMap map[string]interface{} 3134 json.Unmarshal(effectiveJSON, &effectiveMap) 3135 3136 // Redact secrets from both maps before responding 3137 redactConfigSecrets(globalMap) 3138 redactConfigSecrets(effectiveMap) 3139 3140 // Collect unique source files from config merge 3141 var sources []string 3142 if cfg != nil && cfg.Sources != nil { 3143 seen := make(map[string]bool) 3144 for _, src := range cfg.Sources { 3145 if src.File != "" && !seen[src.File] { 3146 seen[src.File] = true 3147 sources = append(sources, src.File) 3148 } 3149 } 3150 } 3151 3152 writeJSON(w, http.StatusOK, map[string]interface{}{ 3153 "global": globalMap, 3154 "effective": effectiveMap, 3155 "sources": sources, 3156 }) 3157 } 3158 3159 func (s *Server) handlePatchConfig(w http.ResponseWriter, r *http.Request) { 3160 var patch map[string]interface{} 3161 if !decodeBody(w, r, &patch) { 3162 return 3163 } 3164 3165 // Normalize aliases FIRST so security checks see canonical keys. 3166 // e.g. "apiKey" → "api_key", "mcpServers" → "mcp_servers". 3167 normalizePatchKeys(patch) 3168 3169 // Check protected fields — hard block, no bypass header. 3170 // These fields must be edited directly in ~/.shannon/config.yaml. 3171 if reason, isProtected := checkProtectedFields(patch); isProtected { 3172 writeJSON(w, http.StatusConflict, map[string]string{ 3173 "error": "protected_field", 3174 "message": reason + " — edit ~/.shannon/config.yaml directly", 3175 }) 3176 return 3177 } 3178 3179 // Validate MCP server commands 3180 if servers, ok := patch["mcp_servers"].(map[string]interface{}); ok { 3181 confirmed := r.Header.Get("X-Confirm") != "" 3182 if err := validateMCPCommands(servers, confirmed); err != nil { 3183 writeJSON(w, http.StatusConflict, map[string]string{ 3184 "error": "mcp_command_validation", 3185 "message": err.Error(), 3186 }) 3187 return 3188 } 3189 } 3190 3191 if err := s.patchGlobalConfig(patch); err != nil { 3192 writeError(w, http.StatusInternalServerError, err.Error()) 3193 return 3194 } 3195 s.auditHTTPOp("PATCH", "/config", "updated config") 3196 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 3197 } 3198 3199 // handleConfigStatus returns current MCP server status without restarting processes. 3200 func (s *Server) handleConfigStatus(w http.ResponseWriter, r *http.Request) { 3201 cfg, _, _ := s.deps.Snapshot() 3202 resp := make(map[string]interface{}) 3203 3204 if cfg != nil && len(cfg.MCPServers) > 0 { 3205 // Build set of live-connected server names from MCPManager. 3206 s.deps.mu.RLock() 3207 mgr := s.deps.MCPManager 3208 s.deps.mu.RUnlock() 3209 connected := make(map[string]bool) 3210 if mgr != nil { 3211 for _, name := range mgr.ConnectedServers() { 3212 connected[name] = true 3213 } 3214 } 3215 3216 mcpStatus := make(map[string]string, len(cfg.MCPServers)) 3217 for name, srv := range cfg.MCPServers { 3218 if srv.Disabled { 3219 mcpStatus[name] = "disabled" 3220 } else if connected[name] { 3221 mcpStatus[name] = "connected" 3222 } else { 3223 mcpStatus[name] = "enabled" 3224 } 3225 } 3226 resp["mcp_servers"] = mcpStatus 3227 } 3228 3229 _, _, sup := s.deps.Snapshot() 3230 if sup != nil { 3231 healthData := make(map[string]interface{}) 3232 for name, h := range sup.HealthStates() { 3233 entry := map[string]interface{}{ 3234 "state": h.State.String(), 3235 "since": h.Since.Format(time.RFC3339), 3236 "consecutive_failures": h.ConsecutiveFailures, 3237 } 3238 if !h.LastTransportOK.IsZero() { 3239 entry["last_transport_ok"] = h.LastTransportOK.Format(time.RFC3339) 3240 } 3241 if !h.LastCapabilityOK.IsZero() { 3242 entry["last_capability_ok"] = h.LastCapabilityOK.Format(time.RFC3339) 3243 } 3244 if h.LastTransportError != "" { 3245 entry["last_transport_error"] = h.LastTransportError 3246 } 3247 if h.LastCapabilityError != "" { 3248 entry["last_capability_error"] = h.LastCapabilityError 3249 } 3250 healthData[name] = entry 3251 } 3252 if len(healthData) > 0 { 3253 resp["mcp_health"] = healthData 3254 } 3255 } 3256 3257 writeJSON(w, http.StatusOK, resp) 3258 } 3259 3260 // mcpConfigChanged returns true if MCP server configuration differs between old and new config. 3261 func mcpConfigChanged(oldCfg, newCfg *config.Config) bool { 3262 if oldCfg == nil { 3263 return len(newCfg.MCPServers) > 0 3264 } 3265 if len(oldCfg.MCPServers) != len(newCfg.MCPServers) { 3266 return true 3267 } 3268 for name, oldSrv := range oldCfg.MCPServers { 3269 newSrv, ok := newCfg.MCPServers[name] 3270 if !ok { 3271 return true 3272 } 3273 if oldSrv.Command != newSrv.Command || oldSrv.Type != newSrv.Type || 3274 oldSrv.URL != newSrv.URL || oldSrv.Disabled != newSrv.Disabled || 3275 oldSrv.Context != newSrv.Context || !slices.Equal(oldSrv.Args, newSrv.Args) || 3276 !maps.Equal(oldSrv.Env, newSrv.Env) { 3277 return true 3278 } 3279 } 3280 return false 3281 } 3282 3283 func (s *Server) handleConfigReload(w http.ResponseWriter, r *http.Request) { 3284 oldCfg, _, _ := s.deps.Snapshot() 3285 3286 newCfg, err := config.Load() 3287 if err != nil { 3288 writeError(w, http.StatusInternalServerError, fmt.Sprintf("config load failed: %v", err)) 3289 return 3290 } 3291 3292 mcpChanged := mcpConfigChanged(oldCfg, newCfg) 3293 mcp.SetCDPChromeProfile(newCfg.Daemon.ChromeProfile) 3294 3295 var regErr error 3296 if mcpChanged { 3297 var newReg *agent.ToolRegistry 3298 var newMCPMgr *mcp.ClientManager 3299 var newCleanup func() 3300 var newBaseline *agent.ToolRegistry 3301 newBaseline, newReg, _, newMCPMgr, newCleanup, regErr = tools.RegisterAllWithBaseline(s.deps.GW, newCfg) 3302 if regErr != nil { 3303 log.Printf("daemon: reload warning: %v", regErr) 3304 } 3305 tools.RegisterCloudDelegate(newReg, s.deps.GW, newCfg, nil, "", "") 3306 3307 newGatewayOverlay := tools.ExtractGatewayTools(newReg) 3308 newPostOverlays := tools.ExtractPostOverlays(newReg, newBaseline) 3309 3310 newSupervisor := mcp.NewSupervisor(newMCPMgr) 3311 newSupervisor.RegisterCapabilityProbe("playwright", &mcp.PlaywrightProbe{}) 3312 newSupervisor.SetOnReconnect(func(ctx context.Context, serverName string) { 3313 if serverName == "playwright" { 3314 tools.CleanupPlaywrightReconnect(ctx, newMCPMgr) 3315 } 3316 }) 3317 newSupervisor.SetOnChange(func(server string, oldState, newState mcp.HealthState) { 3318 _, _, depsSup := s.deps.Snapshot() 3319 if depsSup != newSupervisor { 3320 return 3321 } 3322 bl, gwOv, po, mgr := s.deps.RebuildLayers() 3323 rebuilt := tools.RebuildRegistryForHealth(bl, gwOv, po, newSupervisor.HealthStates(), mgr, newSupervisor) 3324 s.deps.WriteLock() 3325 s.deps.Registry = rebuilt 3326 s.deps.WriteUnlock() 3327 log.Printf("MCP registry rebuilt (reload): %d tools", len(rebuilt.All())) 3328 }) 3329 3330 s.deps.mu.Lock() 3331 oldCleanup := s.deps.Cleanup 3332 oldSupervisor := s.deps.Supervisor 3333 s.deps.Config = newCfg 3334 s.deps.Registry = newReg 3335 s.deps.MCPManager = newMCPMgr 3336 s.deps.Supervisor = newSupervisor 3337 s.deps.Cleanup = newCleanup 3338 s.deps.BaselineReg = newBaseline 3339 s.deps.GatewayOverlay = newGatewayOverlay 3340 s.deps.PostOverlays = newPostOverlays 3341 s.deps.mu.Unlock() 3342 3343 if oldSupervisor != nil { 3344 oldSupervisor.Stop() 3345 } 3346 if oldCleanup != nil { 3347 oldCleanup() 3348 } 3349 3350 newSupervisor.Start(s.ctx) 3351 3352 // Force registry rebuild to attach supervisor to MCPTools (same 3353 // reason as initial startup — CompleteRegistration creates tools 3354 // before the supervisor exists). 3355 { 3356 bl, gwOv, po, mgr := s.deps.RebuildLayers() 3357 initReg := tools.RebuildRegistryForHealth(bl, gwOv, po, newSupervisor.HealthStates(), mgr, newSupervisor) 3358 s.deps.WriteLock() 3359 s.deps.Registry = initReg 3360 s.deps.WriteUnlock() 3361 log.Printf("MCP registry initialized with supervisor (reload): %d tools", len(initReg.All())) 3362 } 3363 } else { 3364 // Config changed but MCP servers didn't — update config and refresh 3365 // cached rebuild layers so health-driven rebuilds use current settings. 3366 newBaseline, _, newBaseCleanup := tools.RegisterLocalTools(newCfg, s.secretsStore) 3367 // Re-register gateway tools on top of fresh baseline clone. 3368 // Use a short timeout — if the gateway is unavailable, keep existing overlay. 3369 freshReg := newBaseline.Clone() 3370 gwCtx, gwCancel := context.WithTimeout(r.Context(), 5*time.Second) 3371 gwErr := tools.RegisterServerTools(gwCtx, s.deps.GW, freshReg) 3372 gwCancel() 3373 tools.RegisterCloudDelegate(freshReg, s.deps.GW, newCfg, nil, "", "") 3374 var newGatewayOverlay []agent.Tool 3375 if gwErr != nil { 3376 log.Printf("daemon: reload: gateway refresh failed, keeping existing overlay: %v", gwErr) 3377 s.deps.mu.RLock() 3378 newGatewayOverlay = s.deps.GatewayOverlay 3379 s.deps.mu.RUnlock() 3380 } else { 3381 newGatewayOverlay = tools.ExtractGatewayTools(freshReg) 3382 } 3383 newPostOverlays := tools.ExtractPostOverlays(freshReg, newBaseline) 3384 3385 s.deps.mu.Lock() 3386 oldCleanup := s.deps.Cleanup 3387 s.deps.Config = newCfg 3388 s.deps.BaselineReg = newBaseline 3389 s.deps.GatewayOverlay = newGatewayOverlay 3390 s.deps.PostOverlays = newPostOverlays 3391 s.deps.Cleanup = func() { newBaseCleanup(); oldCleanup() } 3392 s.deps.mu.Unlock() 3393 } 3394 3395 if s.onReload != nil { 3396 go s.onReload() 3397 } 3398 3399 resp := map[string]interface{}{"status": "reloaded"} 3400 if oldCfg != nil && (oldCfg.Endpoint != newCfg.Endpoint || oldCfg.APIKey != newCfg.APIKey) { 3401 resp["restart_required"] = true 3402 resp["restart_reason"] = "endpoint or api_key changed — restart daemon to apply" 3403 } 3404 3405 // MCP server status for UI indicators 3406 if len(newCfg.MCPServers) > 0 { 3407 // Build set of live-connected server names from MCPManager. 3408 s.deps.mu.RLock() 3409 mgr := s.deps.MCPManager 3410 s.deps.mu.RUnlock() 3411 connected := make(map[string]bool) 3412 if mgr != nil { 3413 for _, name := range mgr.ConnectedServers() { 3414 connected[name] = true 3415 } 3416 } 3417 3418 mcpStatus := make(map[string]string, len(newCfg.MCPServers)) 3419 for name, srv := range newCfg.MCPServers { 3420 if srv.Disabled { 3421 mcpStatus[name] = "disabled" 3422 } else if connected[name] { 3423 mcpStatus[name] = "connected" 3424 } else { 3425 mcpStatus[name] = "enabled" 3426 } 3427 } 3428 // Mark failed servers from registration error 3429 if regErr != nil { 3430 errMsg := regErr.Error() 3431 for name := range newCfg.MCPServers { 3432 if newCfg.MCPServers[name].Disabled { 3433 continue 3434 } 3435 if strings.Contains(errMsg, name+":") { 3436 mcpStatus[name] = "error" 3437 } 3438 } 3439 } 3440 resp["mcp_servers"] = mcpStatus 3441 } 3442 3443 writeJSON(w, http.StatusOK, resp) 3444 } 3445 3446 func (s *Server) handleGetInstructions(w http.ResponseWriter, r *http.Request) { 3447 path := filepath.Join(s.deps.ShannonDir, "instructions.md") 3448 data, err := os.ReadFile(path) 3449 if os.IsNotExist(err) { 3450 writeJSON(w, http.StatusOK, map[string]interface{}{"content": nil}) 3451 return 3452 } 3453 if err != nil { 3454 writeError(w, http.StatusInternalServerError, err.Error()) 3455 return 3456 } 3457 writeJSON(w, http.StatusOK, map[string]string{"content": string(data)}) 3458 } 3459 3460 func (s *Server) handlePutInstructions(w http.ResponseWriter, r *http.Request) { 3461 var body struct { 3462 Content *string `json:"content"` 3463 } 3464 if !decodeBody(w, r, &body) { 3465 return 3466 } 3467 path := filepath.Join(s.deps.ShannonDir, "instructions.md") 3468 if body.Content == nil { 3469 if err := os.Remove(path); err != nil && !os.IsNotExist(err) { 3470 writeError(w, http.StatusInternalServerError, err.Error()) 3471 return 3472 } 3473 } else { 3474 if err := agents.AtomicWrite(path, []byte(*body.Content)); err != nil { 3475 writeError(w, http.StatusInternalServerError, err.Error()) 3476 return 3477 } 3478 } 3479 writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) 3480 } 3481 3482 // syncAuditAdapter bridges the daemon's *audit.AuditLogger (which writes 3483 // AuditEntry rows) to the sync.AuditLogger interface (which emits 3484 // event-name + structured fields). The sync package only ever calls Log; 3485 // we project the fields into AuditEntry.InputSummary so they land in 3486 // audit.log alongside tool-call entries. 3487 type syncAuditAdapter struct { 3488 logger *audit.AuditLogger 3489 } 3490 3491 func (a syncAuditAdapter) Log(event string, fields map[string]any) { 3492 if a.logger == nil { 3493 return 3494 } 3495 // Render fields as a stable, compact string. JSON gives us deterministic 3496 // formatting and is already what the rest of audit.log uses. 3497 var summary string 3498 if data, err := json.Marshal(fields); err == nil { 3499 summary = string(data) 3500 } else { 3501 summary = fmt.Sprintf("%v", fields) 3502 } 3503 a.logger.Log(audit.AuditEntry{ 3504 Timestamp: time.Now(), 3505 ToolName: event, 3506 InputSummary: summary, 3507 Decision: "logged", 3508 Approved: true, 3509 }) 3510 } 3511 3512 // runSyncLoop runs sync.Run on a startup-delayed ticker until ctx is canceled. 3513 // Reads config + rebuilds deps on each tick so config changes (enable/disable, 3514 // fixed missing endpoint/api_key) take effect on the next iteration. 3515 // 3516 // Note: the goroutine stays alive even when sync is initially disabled, so 3517 // enabling sync via config edit without restarting the daemon is picked up 3518 // on the next tick. The ticker cadence itself (DaemonInterval) is still read 3519 // once at startup — changing it requires a daemon restart. 3520 func (s *Server) runSyncLoop(ctx context.Context) { 3521 initialCfg := syncpkg.LoadConfig(viper.GetViper()) 3522 if initialCfg.DaemonInterval <= 0 { 3523 return // misconfigured: nothing to do 3524 } 3525 3526 // Wait for startup delay, but respect ctx. 3527 if initialCfg.DaemonStartupDelay > 0 { 3528 select { 3529 case <-ctx.Done(): 3530 return 3531 case <-time.After(initialCfg.DaemonStartupDelay): 3532 } 3533 } 3534 3535 tick := func() { 3536 cfg := syncpkg.LoadConfig(viper.GetViper()) 3537 if !cfg.Enabled { 3538 return // disabled now; cheap to re-check next tick 3539 } 3540 deps, ok := s.buildSyncDeps(cfg) 3541 if !ok { 3542 return // config incomplete; buildSyncDeps already logged the reason 3543 } 3544 if err := syncpkg.Run(ctx, deps); err != nil { 3545 log.Printf("daemon sync: run error: %v", err) 3546 } 3547 } 3548 3549 if initialCfg.Enabled { 3550 tick() // catch-up only if currently enabled 3551 } 3552 3553 t := time.NewTicker(initialCfg.DaemonInterval) 3554 defer t.Stop() 3555 for { 3556 select { 3557 case <-ctx.Done(): 3558 return 3559 case <-t.C: 3560 tick() 3561 } 3562 } 3563 } 3564 3565 // buildSyncDeps returns ok=false if the config is incomplete (missing 3566 // endpoint or api_key in non-dry-run mode). Caller must skip this iteration 3567 // if !ok. This is re-evaluated every tick so a deferred secret load takes 3568 // effect on the next iteration without restarting the daemon. 3569 func (s *Server) buildSyncDeps(cfg syncpkg.Config) (syncpkg.Deps, bool) { 3570 home, _ := os.UserHomeDir() 3571 shannonHome := filepath.Join(home, ".shannon") 3572 3573 var uploader syncpkg.Uploader 3574 if cfg.DryRun { 3575 uploader = &syncpkg.DryRunUploader{ 3576 OutboxDir: filepath.Join(shannonHome, "sync_outbox"), 3577 Now: time.Now, 3578 } 3579 } else { 3580 endpoint := syncpkg.ResolveEndpoint(cfg, viper.GetViper()) 3581 apiKey := viper.GetString("cloud.api_key") 3582 if endpoint == "" || apiKey == "" { 3583 log.Printf("daemon sync: missing endpoint (sync.endpoint or cloud.endpoint) or cloud.api_key; skipping until configured") 3584 return syncpkg.Deps{}, false 3585 } 3586 gw := client.NewGatewayClient(endpoint, apiKey) 3587 uploader = &syncpkg.CloudUploader{Client: gw} 3588 } 3589 3590 loader := func(dir, id string) ([]byte, error) { 3591 return os.ReadFile(filepath.Join(dir, id+".json")) 3592 } 3593 3594 var auditSink syncpkg.AuditLogger 3595 if s.deps != nil && s.deps.Auditor != nil { 3596 auditSink = syncAuditAdapter{logger: s.deps.Auditor} 3597 } 3598 3599 return syncpkg.Deps{ 3600 Cfg: cfg, 3601 HomeDir: shannonHome, 3602 ClientVer: "shanclaw/daemon", 3603 Uploader: uploader, 3604 Loader: loader, 3605 Audit: auditSink, 3606 Now: time.Now, 3607 }, true 3608 }