package core import ( "context" "crypto/subtle" "encoding/json" "fmt" "log/slog" "net/http" "os" "os/exec" "strings" "sync" "time" ) // WebhookServer exposes an HTTP endpoint for external systems // (git hooks, CI/CD, file watchers, etc.) to trigger agent or shell actions. type WebhookServer struct { port int token string path string server *http.Server engines map[string]*Engine mu sync.RWMutex } // WebhookRequest is the JSON body for POST /hook. type WebhookRequest struct { Event string `json:"event,omitempty"` // event name for logging (e.g. "git:commit") Project string `json:"project,omitempty"` // target project; optional if single project SessionKey string `json:"session_key"` // target session key (required) Prompt string `json:"prompt,omitempty"` // agent prompt (mutually exclusive with exec) Exec string `json:"exec,omitempty"` // shell command (mutually exclusive with prompt) WorkDir string `json:"work_dir,omitempty"` // working dir for exec Silent bool `json:"silent,omitempty"` // suppress notification Payload any `json:"payload,omitempty"` // arbitrary extra data; appended to prompt context } func NewWebhookServer(port int, token, path string) *WebhookServer { if port <= 0 { port = 9111 } if path == "" { path = "/hook" } if !strings.HasPrefix(path, "/") { path = "/" + path } return &WebhookServer{ port: port, token: token, path: path, engines: make(map[string]*Engine), } } func (ws *WebhookServer) RegisterEngine(name string, e *Engine) { ws.mu.Lock() defer ws.mu.Unlock() ws.engines[name] = e } func (ws *WebhookServer) Start() { mux := http.NewServeMux() mux.HandleFunc(ws.path, ws.handleHook) addr := fmt.Sprintf(":%d", ws.port) ws.server = &http.Server{Addr: addr, Handler: mux} go func() { slog.Info("webhook: server started", "addr", addr, "path", ws.path) if err := ws.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("webhook: server error", "error", err) } }() } func (ws *WebhookServer) Stop() { if ws.server != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = ws.server.Shutdown(ctx) } } func (ws *WebhookServer) handleHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if !ws.authenticate(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req WebhookRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } if req.SessionKey == "" { http.Error(w, "session_key is required", http.StatusBadRequest) return } if req.Prompt == "" && req.Exec == "" { http.Error(w, "either prompt or exec is required", http.StatusBadRequest) return } if req.Prompt != "" && req.Exec != "" { http.Error(w, "prompt and exec are mutually exclusive", http.StatusBadRequest) return } engine, err := ws.resolveEngine(req.Project) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } eventName := req.Event if eventName == "" { eventName = "webhook" } slog.Info("webhook: received", "event", eventName, "project", req.Project, "session_key", req.SessionKey, "has_prompt", req.Prompt != "", "has_exec", req.Exec != "", ) if req.Exec != "" { go ws.executeShell(engine, req, eventName) } else { prompt := req.Prompt if req.Payload != nil { if payloadJSON, err := json.Marshal(req.Payload); err == nil { prompt += "\n\nContext:\n```json\n" + string(payloadJSON) + "\n```" } } go ws.executePrompt(engine, req.SessionKey, prompt, req.Silent, eventName) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "status": "accepted", "event": eventName, }) } func (ws *WebhookServer) authenticate(r *http.Request) bool { if ws.token == "" { return true } // Check Authorization: Bearer if auth := r.Header.Get("Authorization"); auth != "" { if strings.HasPrefix(auth, "Bearer ") { got := auth[7:] return subtle.ConstantTimeCompare([]byte(got), []byte(ws.token)) == 1 } } // Check X-Webhook-Token header if tok := r.Header.Get("X-Webhook-Token"); tok != "" { return subtle.ConstantTimeCompare([]byte(tok), []byte(ws.token)) == 1 } // Check query parameter as fallback if tok := r.URL.Query().Get("token"); tok != "" { return subtle.ConstantTimeCompare([]byte(tok), []byte(ws.token)) == 1 } return false } func (ws *WebhookServer) resolveEngine(project string) (*Engine, error) { ws.mu.RLock() defer ws.mu.RUnlock() if project != "" { e, ok := ws.engines[project] if !ok { return nil, fmt.Errorf("project %q not found", project) } return e, nil } if len(ws.engines) == 1 { for _, e := range ws.engines { return e, nil } } return nil, fmt.Errorf("project is required (multiple projects configured)") } func (ws *WebhookServer) executePrompt(engine *Engine, sessionKey, prompt string, silent bool, event string) { platformName := "" if idx := strings.Index(sessionKey, ":"); idx > 0 { platformName = sessionKey[:idx] } var targetPlatform Platform for _, p := range engine.platforms { if p.Name() == platformName { targetPlatform = p break } } if targetPlatform == nil { slog.Error("webhook: platform not found", "event", event, "platform", platformName) return } rc, ok := targetPlatform.(ReplyContextReconstructor) if !ok { slog.Error("webhook: platform does not support proactive messaging", "event", event, "platform", platformName) return } replyCtx, err := rc.ReconstructReplyCtx(sessionKey) if err != nil { slog.Error("webhook: reconstruct reply context failed", "event", event, "error", err) return } if !silent { engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 %s", event)) } msg := &Message{ SessionKey: sessionKey, Platform: platformName, UserID: "webhook", UserName: "webhook", Content: prompt, ReplyCtx: replyCtx, } session := engine.sessions.GetOrCreateActive(sessionKey) if !session.TryLock() { slog.Warn("webhook: session busy, queued prompt dropped", "event", event, "session_key", sessionKey) if !silent { engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ⚠️ session busy, skipped: %s", event)) } return } engine.processInteractiveMessage(targetPlatform, msg, session) slog.Info("webhook: prompt executed", "event", event, "session_key", sessionKey) } const webhookShellTimeout = 5 * time.Minute func (ws *WebhookServer) executeShell(engine *Engine, req WebhookRequest, event string) { sessionKey := req.SessionKey platformName := "" if idx := strings.Index(sessionKey, ":"); idx > 0 { platformName = sessionKey[:idx] } var targetPlatform Platform for _, p := range engine.platforms { if p.Name() == platformName { targetPlatform = p break } } if targetPlatform == nil { slog.Error("webhook: platform not found for shell exec", "event", event, "platform", platformName) return } rc, ok := targetPlatform.(ReplyContextReconstructor) if !ok { slog.Error("webhook: platform does not support proactive messaging", "event", event, "platform", platformName) return } replyCtx, err := rc.ReconstructReplyCtx(sessionKey) if err != nil { slog.Error("webhook: reconstruct reply context failed", "event", event, "error", err) return } if !req.Silent { engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 %s: `%s`", event, truncateStr(req.Exec, 60))) } workDir := req.WorkDir if workDir == "" { if wd, ok := engine.agent.(interface{ GetWorkDir() string }); ok { workDir = wd.GetWorkDir() } } if workDir == "" { workDir, _ = os.Getwd() } ctx, cancel := context.WithTimeout(context.Background(), webhookShellTimeout) defer cancel() cmd := exec.CommandContext(ctx, "sh", "-c", req.Exec) cmd.Dir = workDir output, execErr := cmd.CombinedOutput() result := strings.TrimSpace(string(output)) if ctx.Err() == context.DeadlineExceeded { engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ⚠️ timeout: `%s`", truncateStr(req.Exec, 60))) return } if execErr != nil { msg := fmt.Sprintf("🪝 ❌ `%s`", truncateStr(req.Exec, 60)) if result != "" { msg += "\n\n" + truncateStr(result, 3000) } msg += "\n\nerror: " + execErr.Error() engine.send(targetPlatform, replyCtx, msg) } else { if result == "" { result = "(no output)" } engine.send(targetPlatform, replyCtx, fmt.Sprintf("🪝 ✅ `%s`\n\n%s", truncateStr(req.Exec, 60), truncateStr(result, 3000))) } slog.Info("webhook: shell executed", "event", event, "session_key", sessionKey, "success", execErr == nil) }