From 57fcf3ac1db16a9fef10e38534ba11169c3c7aef Mon Sep 17 00:00:00 2001 From: Andrew Lee Rubinger Date: Wed, 6 May 2026 15:10:31 -0700 Subject: [PATCH 1/2] feat(comms): move comms + listeners to daemon ownership (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final ADR-0012 cleanup: per-launch CommsServer (~530 lines of unix socket IPC) is replaced by four HTTP long-poll endpoints on the daemon, and Slack/Discord listener startup moves to a vault-unlock callback that fires from POST /v1/vault/unlock. - Drop notifications: from per-project policy schema; types live in internal/config (via #481). - Move NotifyQueue + listener startup helpers from internal/launch to internal/comms; daemon owns the queue and the registry of active listeners across the lifetime of the process. - Add four OpenAPI endpoints under /v1/sessions/{id}/comms/ — messages (GET), send / draft / http (POST). Send-shaped endpoints register on the daemon's existing ActionApprovalQueue and long-poll for the user verdict, mirroring the 9A shell-approval pattern from #479. - Wire app.Config.OnVaultUnlock; UnlockLocalVault fires it synchronously after swapping the unlocked inner vault into the LockableVault wrapper. The daemon registers a callback that resolves Slack/Discord tokens and starts listeners. - Switch aileron-mcp's four comms tools from the unix socket dial to HTTP. AILERON_COMMS_SOCKET is gone; AILERON_COMMS_URL + AILERON_SESSION_ID replace it. - Strip launch-side comms: delete commsserver.go, the bridge / startup helpers in launcher.go, and the per-session unix socket binding. Launch's only remaining comms job is to set AILERON_COMMS_URL=daemonURL on the agent's environment. - Update Slack/Discord guides to point at ~/.aileron/config.yaml. - showStatusNotifications now reads from the user-scoped config. Closes the comms half of #454. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/aileron-mcp/main.go | 233 ++- cmd/aileron-mcp/main_test.go | 282 ++-- cmd/aileron/main.go | 58 +- cmd/aileron/main_test.go | 10 +- .../getting-started/discord-integration.md | 17 +- .../getting-started/slack-integration.md | 9 +- internal/api/gen/server.gen.go | 1405 ++++++++++------- internal/api/openapi.yaml | 295 ++++ internal/app/app.go | 45 + internal/app/handlers.go | 7 + internal/app/handlers_comms.go | 421 +++++ internal/app/handlers_comms_test.go | 687 ++++++++ internal/app/handlers_local_vault.go | 19 + internal/app/handlers_local_vault_test.go | 64 + internal/app/handlers_sessions_test.go | 1 - internal/comms/listeners.go | 332 ++++ internal/comms/listeners_internal_test.go | 209 +++ internal/comms/listeners_test.go | 222 +++ internal/{launch => comms}/notifyqueue.go | 12 +- .../{launch => comms}/notifyqueue_test.go | 196 +-- internal/{launch => comms}/quiethours_test.go | 34 +- internal/launch/commsserver.go | 533 ------- internal/launch/commsserver_branches_test.go | 455 ------ internal/launch/commsserver_queue_test.go | 363 ----- internal/launch/commsserver_test.go | 185 --- internal/launch/export_test.go | 48 - internal/launch/launcher.go | 316 +--- internal/launch/launcher_test.go | 522 ------ internal/policy/launch/loader.go | 94 -- internal/policy/launch/schema.go | 62 +- internal/policy/launch/schema_test.go | 55 - internal/policy/launch/usersettings_test.go | 347 ---- internal/server/main.go | 67 + 33 files changed, 3706 insertions(+), 3899 deletions(-) create mode 100644 internal/app/handlers_comms.go create mode 100644 internal/app/handlers_comms_test.go create mode 100644 internal/comms/listeners.go create mode 100644 internal/comms/listeners_internal_test.go create mode 100644 internal/comms/listeners_test.go rename internal/{launch => comms}/notifyqueue.go (96%) rename internal/{launch => comms}/notifyqueue_test.go (70%) rename internal/{launch => comms}/quiethours_test.go (81%) delete mode 100644 internal/launch/commsserver.go delete mode 100644 internal/launch/commsserver_branches_test.go delete mode 100644 internal/launch/commsserver_queue_test.go delete mode 100644 internal/launch/commsserver_test.go diff --git a/cmd/aileron-mcp/main.go b/cmd/aileron-mcp/main.go index 98f07899..7ad554d3 100644 --- a/cmd/aileron-mcp/main.go +++ b/cmd/aileron-mcp/main.go @@ -8,11 +8,12 @@ // execution. This is the "MCP canonical" path for action exposure // ratified during the working session on 2026-05-03. // -// When AILERON_COMMS_SOCKET is set (e.g. when launched by `aileron -// launch`), the server additionally exposes comms tools — read_messages, -// send_message, draft_reply, http_request — that talk to the launch -// product's CommsServer over a Unix socket for Slack/Discord inbound -// push handling. +// When AILERON_COMMS_URL + AILERON_SESSION_ID are set (e.g. when +// launched by `aileron launch`), the server additionally exposes comms +// tools — read_messages, send_message, draft_reply, http_request — that +// reach the daemon-owned comms surface via HTTP. Pre-9B these talked to +// a per-session unix socket; ADR-0012 step 9B-2 moved comms ownership +// to the daemon and switched the wire to HTTP long-poll. // // The binary communicates over stdio using JSON-RPC 2.0, per the MCP // specification. @@ -23,8 +24,11 @@ // When set, action tools are discovered and exposed. // AILERON_TOKEN - Optional bearer token for authenticating with // the Aileron API. -// AILERON_COMMS_SOCKET - Path to the launch product's comms Unix -// socket. When set, comms tools are exposed. +// AILERON_COMMS_URL - URL of the daemon's comms surface (typically +// the same as AILERON_URL). Pair with +// AILERON_SESSION_ID to enable comms tools. +// AILERON_SESSION_ID - The launch session id the daemon stamps on +// comms approval entries. package main import ( @@ -35,12 +39,13 @@ import ( "fmt" "io" "log/slog" - "net" "net/http" + "net/url" "os" "os/signal" "strings" "syscall" + "time" "github.com/ALRubinger/aileron/internal/config" "github.com/ALRubinger/aileron/internal/observability" @@ -166,8 +171,14 @@ type actionRunResponse struct { type server struct { aileronURL string aileronToken string - commsSocket string + commsURL string + sessionID string httpClient *http.Client + // commsHTTPClient is a long-poll-tolerant client for the comms + // endpoints — daemon caps its waits at the action-approval TTL + // (5 min default). A dedicated client lets the action-discovery + // path use a tighter timeout without affecting comms. + commsHTTPClient *http.Client // Discovered actions, populated at startup when AILERON_URL is set. // Keys of actionNameMap are snake_case (LLM-facing) tool names; @@ -176,6 +187,14 @@ type server struct { actionNameMap map[string]string } +// commsAvailable reports whether the env carries enough context to +// reach the daemon's comms endpoints. Both env vars must be set; a +// missing session id yields a 404 from the daemon, so fail-loud with +// "comms not available" beats a confusing 404. +func (s *server) commsAvailable() bool { + return s.commsURL != "" && s.sessionID != "" +} + var readMessagesTool = toolDef{ Name: "read_messages", Description: "Read pending messages from communication channels (Slack, Discord). Returns unread messages from the notification queue. Messages with draft_request=true need a reply drafted — call draft_reply with the message ID and your suggested reply.", @@ -249,8 +268,13 @@ func main() { s := &server{ aileronURL: os.Getenv("AILERON_URL"), aileronToken: os.Getenv("AILERON_TOKEN"), - commsSocket: os.Getenv("AILERON_COMMS_SOCKET"), - httpClient: &http.Client{}, + commsURL: os.Getenv("AILERON_COMMS_URL"), + sessionID: os.Getenv("AILERON_SESSION_ID"), + httpClient: &http.Client{Timeout: 30 * time.Second}, + // 6-minute deadline matches the daemon's 5-minute approval TTL + // + a small buffer so the daemon's bounded response always + // wins over a transport timeout. + commsHTTPClient: &http.Client{Timeout: 6 * time.Minute}, } // Discover installed actions from the Aileron daemon. Best-effort: @@ -354,7 +378,7 @@ func (s *server) handle(req jsonrpcRequest) *jsonrpcResponse { func (s *server) availableTools() []toolDef { var tools []toolDef - if s.commsSocket != "" { + if s.commsAvailable() { tools = append(tools, readMessagesTool, draftReplyTool, sendMessageTool, httpRequestTool) } // Dynamically discovered Aileron actions from the daemon's @@ -386,26 +410,34 @@ func (s *server) dispatchTool(ctx context.Context, name string, args map[string] } func (s *server) readMessages(args map[string]any) toolResult { - if s.commsSocket == "" { + if !s.commsAvailable() { return errorResult("comms not available (not launched via aileron)") } service, _ := args["service"].(string) channel, _ := args["channel"].(string) - resp := requestComms(s.commsSocket, commsRequest{ - Method: "read_messages", - Service: service, - Channel: channel, - }) - if resp.Error != "" { - return errorResult(resp.Error) + endpoint := s.commsEndpoint("messages") + q := url.Values{} + if service != "" { + q.Set("service", service) + } + if channel != "" { + q.Set("channel", channel) + } + if encoded := q.Encode(); encoded != "" { + endpoint += "?" + encoded + } + + var resp readMessagesResponse + if err := s.commsGET(endpoint, &resp); err != nil { + return errorResult(err.Error()) } return jsonResult(resp.Messages) } func (s *server) draftReply(args map[string]any) toolResult { - if s.commsSocket == "" { + if !s.commsAvailable() { return errorResult("comms not available (not launched via aileron)") } @@ -416,12 +448,14 @@ func (s *server) draftReply(args map[string]any) toolResult { return errorResult("message_id and body are required") } - resp := requestComms(s.commsSocket, commsRequest{ - Method: "draft_reply", - ReplyTo: messageID, - Body: body, + resp, err := s.commsPOST(s.commsEndpoint("draft"), map[string]string{ + "reply_to": messageID, + "body": body, }) - if resp.Error != "" { + if err != nil { + return errorResult(err.Error()) + } + if !resp.OK { return errorResult(resp.Error) } return toolResult{ @@ -430,7 +464,7 @@ func (s *server) draftReply(args map[string]any) toolResult { } func (s *server) sendMessage(args map[string]any) toolResult { - if s.commsSocket == "" { + if !s.commsAvailable() { return errorResult("comms not available (not launched via aileron)") } @@ -442,13 +476,15 @@ func (s *server) sendMessage(args map[string]any) toolResult { return errorResult("service, channel, and body are required") } - resp := requestComms(s.commsSocket, commsRequest{ - Method: "send_message", - Service: service, - Channel: channel, - Body: body, + resp, err := s.commsPOST(s.commsEndpoint("send"), map[string]string{ + "service": service, + "channel": channel, + "body": body, }) - if resp.Error != "" { + if err != nil { + return errorResult(err.Error()) + } + if !resp.OK { return errorResult(resp.Error) } return toolResult{ @@ -457,7 +493,7 @@ func (s *server) sendMessage(args map[string]any) toolResult { } func (s *server) httpRequest(args map[string]any) toolResult { - if s.commsSocket == "" { + if !s.commsAvailable() { return errorResult("comms not available (not launched via aileron)") } @@ -470,17 +506,26 @@ func (s *server) httpRequest(args map[string]any) toolResult { return errorResult("method and url are required") } - resp := requestComms(s.commsSocket, commsRequest{ - Method: "http_request", - Service: method, // repurpose Service field for HTTP method - Channel: url, // repurpose Channel field for URL - Body: body, - ReplyTo: headers, // repurpose ReplyTo field for headers JSON - }) - if resp.Error != "" { + payload := map[string]string{ + "method": method, + "url": url, + } + if body != "" { + payload["body"] = body + } + if headers != "" { + payload["headers"] = headers + } + + resp, err := s.commsPOST(s.commsEndpoint("http"), payload) + if err != nil { + return errorResult(err.Error()) + } + if !resp.OK { return errorResult(resp.Error) } - // Response body is in the first message's Body field. + // Response body is in the first message's Body field; the daemon + // stamps the upstream HTTP status code into Id. if len(resp.Messages) > 0 { return toolResult{ Content: []toolContent{{Type: "text", Text: resp.Messages[0].Body}}, @@ -491,47 +536,93 @@ func (s *server) httpRequest(args map[string]any) toolResult { } } -// --- Comms IPC types (mirrors core/launch/commsserver.go) --- - -type commsRequest struct { - Method string `json:"method"` - Service string `json:"service,omitempty"` - Channel string `json:"channel,omitempty"` - Body string `json:"body,omitempty"` - ReplyTo string `json:"reply_to,omitempty"` -} +// --- Comms wire shapes (mirrors internal/api/openapi.yaml CommsToolResponse) --- -type commsResponse struct { +type commsToolResponse struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Messages []commsMessage `json:"messages,omitempty"` } -type commsMessage struct { - ID string `json:"id"` - Service string `json:"service"` - Channel string `json:"channel"` - Author string `json:"author"` - Body string `json:"body"` - Timestamp string `json:"timestamp"` +type readMessagesResponse struct { + Messages []commsMessage `json:"messages"` } -func requestComms(socketPath string, req commsRequest) commsResponse { - conn, err := net.Dial("unix", socketPath) +type commsMessage struct { + ID string `json:"id"` + Service string `json:"service"` + Channel string `json:"channel"` + Author string `json:"author"` + Body string `json:"body"` + Timestamp string `json:"timestamp"` + DraftRequest bool `json:"draft_request,omitempty"` +} + +// commsEndpoint composes the daemon's per-session comms URL for the +// given suffix ("messages", "send", "draft", "http"). The daemon +// expects `/v1/sessions/{sessionID}/comms/`. +func (s *server) commsEndpoint(suffix string) string { + return strings.TrimRight(s.commsURL, "/") + "/v1/sessions/" + s.sessionID + "/comms/" + suffix +} + +// commsGET issues a GET against the daemon's comms surface and decodes +// the JSON body into out. Any non-200 status, transport error, or +// decode failure surfaces as an error so the agent sees the failure +// rather than silently dropping the call. +func (s *server) commsGET(endpoint string, out any) error { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { - return commsResponse{Error: "connection failed: " + err.Error()} + return fmt.Errorf("comms request: %w", err) } - defer conn.Close() - - if err := json.NewEncoder(conn).Encode(req); err != nil { - return commsResponse{Error: "encode failed: " + err.Error()} + if s.aileronToken != "" { + req.Header.Set("Authorization", "Bearer "+s.aileronToken) + } + resp, err := s.commsHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("daemon unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("daemon returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decoding response: %w", err) } + return nil +} - var resp commsResponse - if err := json.NewDecoder(conn).Decode(&resp); err != nil { - return commsResponse{Error: "decode failed: " + err.Error()} +// commsPOST issues a POST with the JSON-encoded body and returns the +// parsed CommsToolResponse. The daemon's send-shaped endpoints always +// return 200 — the verdict rides in `ok` + `error` — so a non-200 +// here means the call never reached the queue. +func (s *server) commsPOST(endpoint string, body any) (commsToolResponse, error) { + data, err := json.Marshal(body) + if err != nil { + return commsToolResponse{}, fmt.Errorf("encoding request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return commsToolResponse{}, fmt.Errorf("comms request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if s.aileronToken != "" { + req.Header.Set("Authorization", "Bearer "+s.aileronToken) + } + resp, err := s.commsHTTPClient.Do(req) + if err != nil { + return commsToolResponse{}, fmt.Errorf("daemon unreachable: %w", err) + } + defer resp.Body.Close() + rawBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return commsToolResponse{}, fmt.Errorf("daemon returned %s: %s", resp.Status, strings.TrimSpace(string(rawBody))) + } + var out commsToolResponse + if err := json.Unmarshal(rawBody, &out); err != nil { + return commsToolResponse{}, fmt.Errorf("decoding response: %w", err) } - return resp + return out, nil } // --- Action discovery / execution against the Aileron daemon --- diff --git a/cmd/aileron-mcp/main_test.go b/cmd/aileron-mcp/main_test.go index 22c3f85d..110439c3 100644 --- a/cmd/aileron-mcp/main_test.go +++ b/cmd/aileron-mcp/main_test.go @@ -3,10 +3,8 @@ package main import ( "context" "encoding/json" - "net" "net/http" "net/http/httptest" - "os" "reflect" "strings" "testing" @@ -86,7 +84,7 @@ func TestDispatchTool_UnknownTool(t *testing.T) { // --- Tool listing --- func TestAvailableTools_CommsOnly(t *testing.T) { - s := &server{commsSocket: "/tmp/test.sock", httpClient: &http.Client{}} + s := &server{commsURL: "http://x", sessionID: "sess-x", httpClient: &http.Client{}} tools := s.availableTools() if len(tools) != 4 { t.Fatalf("expected 4 comms tools, got %d", len(tools)) @@ -120,7 +118,7 @@ func TestAvailableTools_ActionsOnly(t *testing.T) { func TestAvailableTools_CommsAndActions(t *testing.T) { s := &server{ - commsSocket: "/tmp/test.sock", + commsURL: "http://x", sessionID: "sess-x", httpClient: &http.Client{}, actionTools: []toolDef{ {Name: "ship_update", Description: "x", InputSchema: schema{Type: "object"}}, @@ -494,65 +492,56 @@ func TestSendMessage_NoSocket(t *testing.T) { } func TestSendMessage_MissingFields(t *testing.T) { - s := &server{commsSocket: "/tmp/x.sock", httpClient: &http.Client{}} + s := commsServerWithFakeDaemon(t, nil) if !s.sendMessage(map[string]any{}).IsError { t.Fatal("expected error for missing fields") } } -func TestDraftReply_NoSocket(t *testing.T) { - s := &server{httpClient: &http.Client{}} +func TestDraftReply_NoCommsURL(t *testing.T) { + s := &server{httpClient: &http.Client{}, commsHTTPClient: &http.Client{}} if !s.draftReply(map[string]any{"message_id": "1", "body": "hi"}).IsError { - t.Fatal("expected error without comms socket") + t.Fatal("expected error without comms URL + session ID") } } func TestDraftReply_MissingFields(t *testing.T) { - s := &server{commsSocket: "/tmp/x.sock", httpClient: &http.Client{}} + s := commsServerWithFakeDaemon(t, nil) if !s.draftReply(map[string]any{}).IsError { t.Fatal("expected error for missing fields") } } -func TestHttpRequest_NoSocket(t *testing.T) { - s := &server{httpClient: &http.Client{}} +func TestHttpRequest_NoCommsURL(t *testing.T) { + s := &server{httpClient: &http.Client{}, commsHTTPClient: &http.Client{}} if !s.httpRequest(map[string]any{"method": "GET", "url": "https://x"}).IsError { - t.Fatal("expected error without comms socket") + t.Fatal("expected error without comms URL + session ID") } } func TestHttpRequest_MissingFields(t *testing.T) { - s := &server{commsSocket: "/tmp/x.sock", httpClient: &http.Client{}} + s := commsServerWithFakeDaemon(t, nil) if !s.httpRequest(map[string]any{}).IsError { t.Fatal("expected error for missing fields") } } -func TestRequestComms_NoSocket(t *testing.T) { - resp := requestComms("/tmp/no-such.sock", commsRequest{Method: "read_messages"}) - if resp.Error == "" { - t.Fatal("expected error for missing socket") - } -} - -// --- Comms tools — integration with a Unix socket --- +// --- Comms tools — integration against a fake daemon HTTP server --- -func TestReadMessages_WithServer(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - if req.Method != "read_messages" { - t.Errorf("method = %q", req.Method) +func TestReadMessages_WithDaemon(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) } - return commsResponse{ - OK: true, + if !strings.HasSuffix(r.URL.Path, "/comms/messages") { + t.Errorf("path = %q", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(readMessagesResponse{ Messages: []commsMessage{ {ID: "1", Service: "slack", Channel: "#dev", Author: "alice", Body: "hello"}, }, - } - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} + }) + })) got := s.readMessages(map[string]any{}) if got.IsError { t.Fatalf("unexpected error: %s", got.Content[0].Text) @@ -562,84 +551,85 @@ func TestReadMessages_WithServer(t *testing.T) { } } -func TestSendMessage_WithServer(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - if req.Method != "send_message" { - t.Errorf("method = %q", req.Method) - } - return commsResponse{OK: true} - }) - defer stop() +func TestReadMessages_PassesQueryFilters(t *testing.T) { + got := make(chan string, 1) + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r.URL.RawQuery + _ = json.NewEncoder(w).Encode(readMessagesResponse{Messages: []commsMessage{}}) + })) + _ = s.readMessages(map[string]any{"service": "slack", "channel": "#dev"}) + q := <-got + if !strings.Contains(q, "service=slack") || !strings.Contains(q, "channel=") { + t.Errorf("query = %q, want service=slack and channel filter", q) + } +} - s := &server{commsSocket: socket, httpClient: &http.Client{}} +func TestSendMessage_WithDaemon(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/comms/send") { + t.Errorf("path = %q", r.URL.Path) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["service"] != "slack" || body["channel"] != "#x" || body["body"] != "hi" { + t.Errorf("body = %v", body) + } + _ = json.NewEncoder(w).Encode(commsToolResponse{OK: true}) + })) got := s.sendMessage(map[string]any{"service": "slack", "channel": "#x", "body": "hi"}) if got.IsError { t.Fatalf("unexpected error: %s", got.Content[0].Text) } } -func TestSendMessage_ServerError(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - return commsResponse{Error: "policy denied"} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} +func TestSendMessage_DaemonDenies(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(commsToolResponse{OK: false, Error: "user denied"}) + })) got := s.sendMessage(map[string]any{"service": "slack", "channel": "#x", "body": "hi"}) if !got.IsError { - t.Fatal("expected error when comms server returns error") + t.Fatal("expected error when daemon denies send") + } + if !strings.Contains(got.Content[0].Text, "user denied") { + t.Errorf("expected daemon's error text in result, got %q", got.Content[0].Text) } } -func TestDraftReply_WithServer(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - if req.Method != "draft_reply" { - t.Errorf("method = %q", req.Method) +func TestDraftReply_WithDaemon(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/comms/draft") { + t.Errorf("path = %q", r.URL.Path) } - return commsResponse{OK: true} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} + _ = json.NewEncoder(w).Encode(commsToolResponse{OK: true}) + })) got := s.draftReply(map[string]any{"message_id": "1", "body": "hi"}) if got.IsError { t.Fatalf("unexpected error: %s", got.Content[0].Text) } } -func TestDraftReply_ServerError(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - return commsResponse{Error: "draft denied"} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} +func TestDraftReply_DaemonDenies(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(commsToolResponse{OK: false, Error: "discarded"}) + })) got := s.draftReply(map[string]any{"message_id": "1", "body": "hi"}) if !got.IsError { - t.Fatal("expected error when server denies draft") + t.Fatal("expected error when daemon discards draft") } } -func TestHttpRequest_WithServer(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - if req.Method != "http_request" { - t.Errorf("method = %q", req.Method) +func TestHttpRequest_WithDaemon(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/comms/http") { + t.Errorf("path = %q", r.URL.Path) } - return commsResponse{ + _ = json.NewEncoder(w).Encode(commsToolResponse{ OK: true, Messages: []commsMessage{ - {ID: "r", Body: "200 OK\n{\"ok\":true}"}, + {ID: "200", Body: "{\"ok\":true}"}, }, - } - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} + }) + })) got := s.httpRequest(map[string]any{"method": "GET", "url": "https://example.com"}) if got.IsError { t.Fatalf("unexpected error: %s", got.Content[0].Text) @@ -649,28 +639,28 @@ func TestHttpRequest_WithServer(t *testing.T) { } } -func TestHttpRequest_ServerError(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - return commsResponse{Error: "url not in allowlist"} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} +func TestHttpRequest_DaemonDenies(t *testing.T) { + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(commsToolResponse{OK: false, Error: "url not in allowlist"}) + })) got := s.httpRequest(map[string]any{"method": "GET", "url": "https://blocked"}) if !got.IsError { - t.Fatal("expected error when comms server denies") + t.Fatal("expected error when daemon denies") } } func TestDispatchTool_RoutesAllCommsTools(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - return commsResponse{OK: true, Messages: []commsMessage{{ID: "x", Body: "ok"}}} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /comms/messages is GET; the rest are POST. + if strings.HasSuffix(r.URL.Path, "/comms/messages") { + _ = json.NewEncoder(w).Encode(readMessagesResponse{Messages: []commsMessage{}}) + return + } + _ = json.NewEncoder(w).Encode(commsToolResponse{ + OK: true, + Messages: []commsMessage{{ID: "x", Body: "ok"}}, + }) + })) cases := []struct { name string args map[string]any @@ -690,8 +680,10 @@ func TestDispatchTool_RoutesAllCommsTools(t *testing.T) { func TestHandle_ToolsList_ReturnsTools(t *testing.T) { s := &server{ - commsSocket: "/tmp/x.sock", - httpClient: &http.Client{}, + commsURL: "http://127.0.0.1:1", + sessionID: "sess-x", + httpClient: &http.Client{}, + commsHTTPClient: &http.Client{}, } resp := s.handle(jsonrpcRequest{ JSONRPC: "2.0", @@ -709,13 +701,9 @@ func TestHandle_ToolsList_ReturnsTools(t *testing.T) { } func TestHandle_ToolsCall_RoutesToTool(t *testing.T) { - socket := tempSocket(t) - stop := serveComms(t, socket, func(req commsRequest) commsResponse { - return commsResponse{OK: true, Messages: []commsMessage{}} - }) - defer stop() - - s := &server{commsSocket: socket, httpClient: &http.Client{}} + s := commsServerWithFakeDaemon(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(readMessagesResponse{Messages: []commsMessage{}}) + })) resp := s.handle(jsonrpcRequest{ JSONRPC: "2.0", ID: json.RawMessage(`1`), @@ -727,6 +715,24 @@ func TestHandle_ToolsCall_RoutesToTool(t *testing.T) { } } +func TestCommsAvailable_RequiresBothEnvVars(t *testing.T) { + cases := []struct { + name string + s *server + want bool + }{ + {"both set", &server{commsURL: "http://x", sessionID: "s"}, true}, + {"missing url", &server{sessionID: "s"}, false}, + {"missing session", &server{commsURL: "http://x"}, false}, + {"both missing", &server{}, false}, + } + for _, tc := range cases { + if got := tc.s.commsAvailable(); got != tc.want { + t.Errorf("%s: commsAvailable() = %v, want %v", tc.name, got, tc.want) + } + } +} + // --- Helpers --- func TestErrorResult(t *testing.T) { @@ -752,50 +758,22 @@ func TestJsonResult(t *testing.T) { // --- Test infra --- -func tempSocket(t *testing.T) string { - t.Helper() - // macOS limits Unix socket paths to ~104 bytes; t.TempDir() with - // long test names can exceed that. Use a short random name in - // /tmp instead. - f, err := os.CreateTemp("/tmp", "ai-mcp-*.sock") - if err != nil { - t.Fatalf("CreateTemp: %v", err) - } - path := f.Name() - f.Close() - _ = os.Remove(path) // socket can't exist before listen() - t.Cleanup(func() { _ = os.Remove(path) }) - return path -} - -func serveComms(t *testing.T, socket string, handler func(commsRequest) commsResponse) func() { +// commsServerWithFakeDaemon returns a *server pointed at an httptest +// server that runs the supplied handler. nil handler yields a server +// configured with comms env but no upstream — useful for missing-field +// tests where the request never reaches the wire. +func commsServerWithFakeDaemon(t *testing.T, handler http.Handler) *server { t.Helper() - l, err := net.Listen("unix", socket) - if err != nil { - t.Fatalf("listen: %v", err) - } - done := make(chan struct{}) - go func() { - defer close(done) - for { - conn, err := l.Accept() - if err != nil { - return - } - go func() { - defer conn.Close() - var req commsRequest - if err := json.NewDecoder(conn).Decode(&req); err != nil { - return - } - resp := handler(req) - _ = json.NewEncoder(conn).Encode(resp) - }() - } - }() - return func() { - _ = l.Close() - <-done - _ = os.Remove(socket) + url := "http://127.0.0.1:1" // unreachable; sentinel for "env set, no daemon" + if handler != nil { + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + url = ts.URL + } + return &server{ + commsURL: url, + sessionID: "sess-x", + httpClient: &http.Client{}, + commsHTTPClient: &http.Client{}, } } diff --git a/cmd/aileron/main.go b/cmd/aileron/main.go index 4e962877..802f4382 100644 --- a/cmd/aileron/main.go +++ b/cmd/aileron/main.go @@ -18,6 +18,8 @@ import ( "time" "github.com/ALRubinger/aileron/internal/audit" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/config" "github.com/ALRubinger/aileron/internal/daemon/spawn" "github.com/ALRubinger/aileron/internal/launch" "github.com/ALRubinger/aileron/internal/launch/agents" @@ -1298,32 +1300,38 @@ func showStatusEnv(dir string, w io.Writer) { } } -func showStatusNotifications(dir string, w io.Writer) { +// showStatusNotifications surfaces the user-scoped notification config +// (Slack / Discord / quiet hours) the daemon reads at startup. Lives in +// `~/.aileron/config.yaml` per ADR-0012 step 9B-2 — moved out of the +// per-project `aileron.yaml` along with listener ownership. +// +// The `dir` argument is unused now (kept for the call-site signature +// alignment with the other showStatus* helpers); future enhancements +// might re-introduce per-project overrides. +func showStatusNotifications(_ string, w io.Writer) { fmt.Fprintln(w, "\033[1mNotifications\033[0m") - policyPath := launch.FindPolicyFile(dir) - var merged *launchpolicy.PolicyFile - if policyPath != "" { - var err error - merged, err = launchpolicy.LoadWithProfiles(policyPath) - if err != nil { - fmt.Fprintf(w, " error: %v\n", err) - return - } - } else { - merged = launchpolicy.DefaultPolicy() + configPath := config.DefaultAileronConfigPath() + cfg, err := config.LoadAileronConfig(configPath) + if err != nil { + fmt.Fprintf(w, " error: %v\n", err) + return } - if merged.Notifications == nil { + fmt.Fprintf(w, " config file: %s\n", configPath) + if cfg.Notifications == nil { fmt.Fprintln(w, " No notifications configured.") return } - if cfg := merged.Notifications.Slack; cfg != nil { + if slack := cfg.Notifications.Slack; slack != nil { fmt.Fprintln(w, " Slack:") - fmt.Fprintf(w, " app_token: %s\n", tokenStatus(cfg.AppToken)) - fmt.Fprintf(w, " bot_token: %s\n", tokenStatus(cfg.BotToken)) - for _, ch := range cfg.Channels { + fmt.Fprintf(w, " app_token: %s\n", tokenStatus(slack.AppToken)) + fmt.Fprintf(w, " bot_token: %s\n", tokenStatus(slack.BotToken)) + if slack.UserToken != "" { + fmt.Fprintf(w, " user_token: %s\n", tokenStatus(slack.UserToken)) + } + for _, ch := range slack.Channels { draft := "" if ch.AutoDraft { draft = " (auto-draft)" @@ -1332,13 +1340,21 @@ func showStatusNotifications(dir string, w io.Writer) { } } - if cfg := merged.Notifications.Discord; cfg != nil { + if discord := cfg.Notifications.Discord; discord != nil { fmt.Fprintln(w, " Discord:") - fmt.Fprintf(w, " bot_token: %s\n", tokenStatus(cfg.BotToken)) - for _, ch := range cfg.Channels { + fmt.Fprintf(w, " bot_token: %s\n", tokenStatus(discord.BotToken)) + for _, ch := range discord.Channels { fmt.Fprintf(w, " channel: %s [show=%s]\n", ch.Name, ch.Show) } } + + if qh := cfg.Notifications.QuietHours; qh != nil { + tz := qh.Timezone + if tz == "" { + tz = "(local)" + } + fmt.Fprintf(w, " Quiet hours: %s–%s %s\n", qh.Start, qh.End, tz) + } } func showStatusVault(dir string, w io.Writer) { @@ -1369,7 +1385,7 @@ func tokenStatus(value string) string { if value == "" { return "(not set)" } - if launch.IsVaultRef(value) { + if comms.IsVaultRef(value) { return value } return "(plaintext — use vault: reference)" diff --git a/cmd/aileron/main_test.go b/cmd/aileron/main_test.go index 421b5e8e..f0bdcb28 100644 --- a/cmd/aileron/main_test.go +++ b/cmd/aileron/main_test.go @@ -473,8 +473,9 @@ env: func TestRunStatus_Notifications(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 + configDir := filepath.Join(dir, ".aileron") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(` notifications: slack: app_token: vault:slack_app @@ -568,8 +569,9 @@ func TestRunStatus_InHelp(t *testing.T) { func TestRunStatus_NotificationsWithDiscord(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 + configDir := filepath.Join(dir, ".aileron") + os.MkdirAll(configDir, 0o755) + os.WriteFile(filepath.Join(configDir, "config.yaml"), []byte(` notifications: discord: bot_token: vault:discord_bot diff --git a/docs/archive/getting-started/discord-integration.md b/docs/archive/getting-started/discord-integration.md index 393165f5..2c672c07 100644 --- a/docs/archive/getting-started/discord-integration.md +++ b/docs/archive/getting-started/discord-integration.md @@ -19,12 +19,20 @@ In the [Discord Developer Portal](https://discord.com/developers/applications): In Discord, enable Developer Mode (User Settings > Advanced > Developer Mode). Right-click a channel and **Copy Channel ID**. -## 3. Add the config to aileron.yaml +## 3. Add the config to `~/.aileron/config.yaml` + +The notifications block lives in the user-scoped `~/.aileron/config.yaml` per [ADR-0012](/adr/0012-local-daemon-architecture) — the daemon owns the Discord listener across launches. + +Store the bot token in the vault and reference it; plaintext tokens are rejected. + +```sh +aileron secret set discord_bot_token +``` ```yaml notifications: discord: - bot_token: "your-bot-token" + bot_token: vault:discord_bot_token channels: - name: "1234567890123456789" # channel ID show: all @@ -36,10 +44,11 @@ notifications: - "1111111111111111111" # channel ID to ignore ``` -## 4. Launch as usual +## 4. Unlock the vault, launch as usual ```sh +aileron daemon start # if not already running aileron launch claude ``` -Discord messages appear alongside Slack messages in the same notification bar and overlay. +Listener startup is deferred until the vault is unlocked. Discord messages then appear alongside Slack messages in the same notification surface. diff --git a/docs/archive/getting-started/slack-integration.md b/docs/archive/getting-started/slack-integration.md index dd287137..27febfb9 100644 --- a/docs/archive/getting-started/slack-integration.md +++ b/docs/archive/getting-started/slack-integration.md @@ -30,7 +30,9 @@ aileron secret set slack_user_token # optional, for sending as yourself aileron secret list # shows stored names ``` -## 3. Reference them in aileron.yaml +## 3. Reference them in `~/.aileron/config.yaml` + +The notifications block lives in the user-scoped `~/.aileron/config.yaml` — the daemon owns Slack/Discord listeners across launches per [ADR-0012](/adr/0012-local-daemon-architecture). It is not a per-project setting. ```yaml notifications: @@ -51,13 +53,14 @@ notifications: Token fields **must** use `vault:` references. Aileron rejects plaintext tokens to prevent secrets from being committed to version control. -## 4. Launch as usual +## 4. Unlock the vault, launch as usual ```sh +aileron daemon start # if not already running aileron launch claude ``` -Aileron prompts for the vault passphrase, then starts the Slack listener. +Listener startup is deferred until the vault is unlocked — open the daemon URL printed in the launch banner and enter your passphrase, or unlock from a separate `aileron` CLI flow. The daemon resolves the `vault:` references and connects to Slack only after that point. While listeners are not yet up, the agent's `read_messages` MCP tool returns an empty list rather than failing. ## Using the notification overlay diff --git a/internal/api/gen/server.gen.go b/internal/api/gen/server.gen.go index d575cc77..b912247b 100644 --- a/internal/api/gen/server.gen.go +++ b/internal/api/gen/server.gen.go @@ -2245,6 +2245,49 @@ type CloudAction struct { // CloudActionProvider defines model for CloudAction.Provider. type CloudActionProvider string +// CommsMessage A single message read from the daemon's notify queue. Mirrors +// the wire shape `aileron-mcp`'s `read_messages` tool surfaces +// to the agent so the agent can decide whether to draft a reply. +type CommsMessage struct { + // Author Sender's display name. + Author string `json:"author"` + + // Body Full message text. + Body string `json:"body"` + + // Channel Channel name or ID the message arrived on. + Channel string `json:"channel"` + + // DraftRequest True when the message arrived on a channel configured for + // auto-draft and no reply has been drafted yet — the agent + // should call `draft_reply` with this message's id. + DraftRequest *bool `json:"draft_request,omitempty"` + + // Id Stable per-message identifier (set by the inbound listener). + Id string `json:"id"` + + // Service Source service ("slack", "discord", ...). + Service string `json:"service"` + + // Timestamp When the listener received the message (RFC3339). + Timestamp time.Time `json:"timestamp"` +} + +// CommsToolResponse Generic wire shape for the send-shaped comms endpoints +// (`/comms/send`, `/comms/draft`, `/comms/http`). `ok=true` means +// the request was approved and dispatched successfully; `ok=false` +// means denied / timed out / dispatch failed, with `error` +// carrying the agent-facing reason. +type CommsToolResponse struct { + // Error Agent-facing failure detail when ok=false. + Error *string `json:"error,omitempty"` + + // Messages Optional response payload — used by `/comms/http` to carry + // the upstream HTTP response (status code in id, body in body). + Messages *[]CommsMessage `json:"messages,omitempty"` + Ok bool `json:"ok"` +} + // ConnectedAccount defines model for ConnectedAccount. type ConnectedAccount struct { CreatedAt *time.Time `json:"created_at,omitempty"` @@ -2565,6 +2608,18 @@ type DomainAction struct { Procurement *ProcurementAction `json:"procurement,omitempty"` } +// DraftCommsReplyRequest Request body for `POST /v1/sessions/{id}/comms/draft`. The +// daemon looks up the original message in the notify queue by +// `reply_to` and surfaces it alongside the draft body for the +// user to approve / edit / discard. +type DraftCommsReplyRequest struct { + // Body Suggested reply text the agent drafted. + Body string `json:"body"` + + // ReplyTo ID of the original incoming message (from `read_messages`). + ReplyTo string `json:"reply_to"` +} + // EmailAction defines model for EmailAction. type EmailAction struct { Attachments *[]AttachmentRef `json:"attachments,omitempty"` @@ -3359,6 +3414,12 @@ type ProcurementAction struct { // ProcurementActionRequestType defines model for ProcurementAction.RequestType. type ProcurementActionRequestType string +// ReadCommsMessagesResponse Snapshot of unread messages from the daemon's notify queue. +// Calling this endpoint marks the surfaced messages as read. +type ReadCommsMessagesResponse struct { + Messages []CommsMessage `json:"messages"` +} + // RebindRequest defines model for RebindRequest. type RebindRequest struct { // Source Per-binding credential source. v1 supports only `api_key`. Setting @@ -3372,9 +3433,41 @@ type Recipient struct { Name *string `json:"name,omitempty"` } +// RequestCommsHTTPRequest Request body for `POST /v1/sessions/{id}/comms/http`. The +// daemon matches `url` against api_key vault entries and injects +// the matched secret as a Bearer token after the user approves. +type RequestCommsHTTPRequest struct { + // Body Request body string. Optional. + Body *string `json:"body,omitempty"` + + // Headers Additional request headers as a JSON object string, e.g. + // `{"X-Foo":"bar"}`. Optional. + Headers *string `json:"headers,omitempty"` + + // Method HTTP method (GET, POST, PUT, DELETE, PATCH). + Method string `json:"method"` + + // Url Target URL. + Url string `json:"url"` +} + // RiskLevel defines model for RiskLevel. type RiskLevel string +// SendCommsMessageRequest Request body for `POST /v1/sessions/{id}/comms/send`. session_id +// rides in the URL path; the daemon stamps it on the +// action-approval entry. +type SendCommsMessageRequest struct { + // Body Message text to send. + Body string `json:"body"` + + // Channel Channel name or ID to send to. + Channel string `json:"channel"` + + // Service Target service ("slack", "discord"). + Service string `json:"service"` +} + // Session One record of an `aileron launch` agent invocation, owned by the // daemon under ADR-0012. (started_at, ended_at, exit_code) encode // three states: @@ -3917,6 +4010,15 @@ type ListSessionsParams struct { Limit *int `form:"limit,omitempty" json:"limit,omitempty"` } +// ReadCommsMessagesParams defines parameters for ReadCommsMessages. +type ReadCommsMessagesParams struct { + // Service Filter by service ("slack", "discord", or empty for all). + Service *string `form:"service,omitempty" json:"service,omitempty"` + + // Channel Filter by channel name, or empty for all channels. + Channel *string `form:"channel,omitempty" json:"channel,omitempty"` +} + // ListTracesParams defines parameters for ListTraces. type ListTracesParams struct { PageSize *PageSize `form:"page_size,omitempty" json:"page_size,omitempty"` @@ -4011,6 +4113,15 @@ type CreateSessionJSONRequestBody = CreateSessionRequest // RequestShellApprovalJSONRequestBody defines body for RequestShellApproval for application/json ContentType. type RequestShellApprovalJSONRequestBody = ShellApprovalRequest +// DraftCommsReplyJSONRequestBody defines body for DraftCommsReply for application/json ContentType. +type DraftCommsReplyJSONRequestBody = DraftCommsReplyRequest + +// RequestCommsHTTPJSONRequestBody defines body for RequestCommsHTTP for application/json ContentType. +type RequestCommsHTTPJSONRequestBody = RequestCommsHTTPRequest + +// SendCommsMessageJSONRequestBody defines body for SendCommsMessage for application/json ContentType. +type SendCommsMessageJSONRequestBody = SendCommsMessageRequest + // EndSessionJSONRequestBody defines body for EndSession for application/json ContentType. type EndSessionJSONRequestBody = EndSessionRequest @@ -5596,6 +5707,18 @@ type ServerInterface interface { // Request user approval for a shell command (long-poll) // (POST /v1/sessions/{session_id}/approvals/shell) RequestShellApproval(w http.ResponseWriter, r *http.Request, sessionId string) + // Submit a draft reply for user review (long-poll) + // (POST /v1/sessions/{session_id}/comms/draft) + DraftCommsReply(w http.ResponseWriter, r *http.Request, sessionId string) + // Issue an authenticated HTTP request (long-poll approval) + // (POST /v1/sessions/{session_id}/comms/http) + RequestCommsHTTP(w http.ResponseWriter, r *http.Request, sessionId string) + // Read pending messages from communication channels + // (GET /v1/sessions/{session_id}/comms/messages) + ReadCommsMessages(w http.ResponseWriter, r *http.Request, sessionId string, params ReadCommsMessagesParams) + // Request user approval to send a message (long-poll) + // (POST /v1/sessions/{session_id}/comms/send) + SendCommsMessage(w http.ResponseWriter, r *http.Request, sessionId string) // Mark a launch session ended // (POST /v1/sessions/{session_id}/end) EndSession(w http.ResponseWriter, r *http.Request, sessionId string) @@ -7560,6 +7683,125 @@ func (siw *ServerInterfaceWrapper) RequestShellApproval(w http.ResponseWriter, r handler.ServeHTTP(w, r) } +// DraftCommsReply operation middleware +func (siw *ServerInterfaceWrapper) DraftCommsReply(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "session_id" ------------- + var sessionId string + + err = runtime.BindStyledParameterWithOptions("simple", "session_id", r.PathValue("session_id"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "session_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DraftCommsReply(w, r, sessionId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RequestCommsHTTP operation middleware +func (siw *ServerInterfaceWrapper) RequestCommsHTTP(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "session_id" ------------- + var sessionId string + + err = runtime.BindStyledParameterWithOptions("simple", "session_id", r.PathValue("session_id"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "session_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RequestCommsHTTP(w, r, sessionId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReadCommsMessages operation middleware +func (siw *ServerInterfaceWrapper) ReadCommsMessages(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "session_id" ------------- + var sessionId string + + err = runtime.BindStyledParameterWithOptions("simple", "session_id", r.PathValue("session_id"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "session_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ReadCommsMessagesParams + + // ------------- Optional query parameter "service" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "service", r.URL.Query(), ¶ms.Service, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "service", Err: err}) + return + } + + // ------------- Optional query parameter "channel" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "channel", r.URL.Query(), ¶ms.Channel, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "channel", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReadCommsMessages(w, r, sessionId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SendCommsMessage operation middleware +func (siw *ServerInterfaceWrapper) SendCommsMessage(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "session_id" ------------- + var sessionId string + + err = runtime.BindStyledParameterWithOptions("simple", "session_id", r.PathValue("session_id"), &sessionId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "session_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SendCommsMessage(w, r, sessionId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // EndSession operation middleware func (siw *ServerInterfaceWrapper) EndSession(w http.ResponseWriter, r *http.Request) { @@ -8143,6 +8385,10 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("POST "+options.BaseURL+"/v1/sessions", wrapper.CreateSession) m.HandleFunc("GET "+options.BaseURL+"/v1/sessions/{session_id}", wrapper.GetSession) m.HandleFunc("POST "+options.BaseURL+"/v1/sessions/{session_id}/approvals/shell", wrapper.RequestShellApproval) + m.HandleFunc("POST "+options.BaseURL+"/v1/sessions/{session_id}/comms/draft", wrapper.DraftCommsReply) + m.HandleFunc("POST "+options.BaseURL+"/v1/sessions/{session_id}/comms/http", wrapper.RequestCommsHTTP) + m.HandleFunc("GET "+options.BaseURL+"/v1/sessions/{session_id}/comms/messages", wrapper.ReadCommsMessages) + m.HandleFunc("POST "+options.BaseURL+"/v1/sessions/{session_id}/comms/send", wrapper.SendCommsMessage) m.HandleFunc("POST "+options.BaseURL+"/v1/sessions/{session_id}/end", wrapper.EndSession) m.HandleFunc("GET "+options.BaseURL+"/v1/status", wrapper.GetStatus) m.HandleFunc("POST "+options.BaseURL+"/v1/sync", wrapper.Sync) @@ -8169,570 +8415,601 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "35rakNH754c//38AAAD//1s6DnGMVQIA", + "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", + "dgIA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 8e58a3e3..45388929 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -829,6 +829,175 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/sessions/{session_id}/comms/messages: + get: + tags: [Sessions] + summary: Read pending messages from communication channels + description: | + Called by `aileron-mcp`'s `read_messages` tool. Returns the + messages currently in the daemon's notify queue (Slack, Discord), + optionally filtered by `service` and `channel`. Marks every + surfaced message as read, so consecutive calls don't return the + same message twice. + operationId: readCommsMessages + security: [] + parameters: + - name: session_id + in: path + required: true + schema: + type: string + - name: service + in: query + required: false + schema: + type: string + description: Filter by service ("slack", "discord", or empty for all). + - name: channel + in: query + required: false + schema: + type: string + description: Filter by channel name, or empty for all channels. + responses: + "200": + description: Messages snapshot + content: + application/json: + schema: + $ref: "#/components/schemas/ReadCommsMessagesResponse" + "503": + description: Comms surface is not configured on this daemon. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sessions/{session_id}/comms/send: + post: + tags: [Sessions] + summary: Request user approval to send a message (long-poll) + description: | + Called by `aileron-mcp`'s `send_message` tool. The daemon + registers a [comms_send] entry on the action-approval queue, + long-polls until the user decides via the webapp, and on + approve dispatches the message via the matching listener. + Returns 200 + `{ok:true}` on approve, `{ok:false,error:...}` + on deny / timeout / dispatch failure. + operationId: sendCommsMessage + security: [] + parameters: + - name: session_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendCommsMessageRequest" + responses: + "200": + description: User decided (approved → dispatched, denied / timeout / dispatch error → ok=false). + content: + application/json: + schema: + $ref: "#/components/schemas/CommsToolResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + description: Comms surface is not configured on this daemon. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sessions/{session_id}/comms/draft: + post: + tags: [Sessions] + summary: Submit a draft reply for user review (long-poll) + description: | + Called by `aileron-mcp`'s `draft_reply` tool. Looks up the + original incoming message in the notify queue by `reply_to`, + registers a [comms_draft] entry on the action-approval queue + with the editable draft body, long-polls, and on approve + dispatches the (possibly user-edited) reply through the + matching listener. + operationId: draftCommsReply + security: [] + parameters: + - name: session_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DraftCommsReplyRequest" + responses: + "200": + description: User decided (approved → dispatched, discarded / timeout → ok=false). + content: + application/json: + schema: + $ref: "#/components/schemas/CommsToolResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + description: Comms surface is not configured on this daemon. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sessions/{session_id}/comms/http: + post: + tags: [Sessions] + summary: Issue an authenticated HTTP request (long-poll approval) + description: | + Called by `aileron-mcp`'s `http_request` tool. The daemon + matches the URL against api_key vault entries (where + `metadata.type=api_key` and `metadata.labels[url-pattern]` + matches), registers a [http_request] approval entry, long-polls, + and on approve issues the HTTP call with the matched secret + injected as a Bearer token. The response body is returned in + `messages[0].body` with the upstream status code in + `messages[0].id`. + operationId: requestCommsHTTP + security: [] + parameters: + - name: session_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RequestCommsHTTPRequest" + responses: + "200": + description: User decided (approved → dispatched, denied / timeout / dispatch error → ok=false). + content: + application/json: + schema: + $ref: "#/components/schemas/CommsToolResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + description: Comms surface is not configured on this daemon. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/intents: post: tags: [Intents] @@ -6157,3 +6326,129 @@ components: decision: type: string enum: [allow_once, deny, allow_project, allow_user] + + CommsMessage: + type: object + required: [id, service, channel, author, body, timestamp] + description: | + A single message read from the daemon's notify queue. Mirrors + the wire shape `aileron-mcp`'s `read_messages` tool surfaces + to the agent so the agent can decide whether to draft a reply. + properties: + id: + type: string + description: Stable per-message identifier (set by the inbound listener). + service: + type: string + description: Source service ("slack", "discord", ...). + channel: + type: string + description: Channel name or ID the message arrived on. + author: + type: string + description: Sender's display name. + body: + type: string + description: Full message text. + timestamp: + type: string + format: date-time + description: When the listener received the message (RFC3339). + draft_request: + type: boolean + description: | + True when the message arrived on a channel configured for + auto-draft and no reply has been drafted yet — the agent + should call `draft_reply` with this message's id. + + ReadCommsMessagesResponse: + type: object + required: [messages] + description: | + Snapshot of unread messages from the daemon's notify queue. + Calling this endpoint marks the surfaced messages as read. + properties: + messages: + type: array + items: + $ref: "#/components/schemas/CommsMessage" + + SendCommsMessageRequest: + type: object + required: [service, channel, body] + description: | + Request body for `POST /v1/sessions/{id}/comms/send`. session_id + rides in the URL path; the daemon stamps it on the + action-approval entry. + properties: + service: + type: string + description: Target service ("slack", "discord"). + channel: + type: string + description: Channel name or ID to send to. + body: + type: string + description: Message text to send. + + DraftCommsReplyRequest: + type: object + required: [reply_to, body] + description: | + Request body for `POST /v1/sessions/{id}/comms/draft`. The + daemon looks up the original message in the notify queue by + `reply_to` and surfaces it alongside the draft body for the + user to approve / edit / discard. + properties: + reply_to: + type: string + description: ID of the original incoming message (from `read_messages`). + body: + type: string + description: Suggested reply text the agent drafted. + + RequestCommsHTTPRequest: + type: object + required: [method, url] + description: | + Request body for `POST /v1/sessions/{id}/comms/http`. The + daemon matches `url` against api_key vault entries and injects + the matched secret as a Bearer token after the user approves. + properties: + method: + type: string + description: HTTP method (GET, POST, PUT, DELETE, PATCH). + url: + type: string + description: Target URL. + body: + type: string + description: Request body string. Optional. + headers: + type: string + description: | + Additional request headers as a JSON object string, e.g. + `{"X-Foo":"bar"}`. Optional. + + CommsToolResponse: + type: object + required: [ok] + description: | + Generic wire shape for the send-shaped comms endpoints + (`/comms/send`, `/comms/draft`, `/comms/http`). `ok=true` means + the request was approved and dispatched successfully; `ok=false` + means denied / timed out / dispatch failed, with `error` + carrying the agent-facing reason. + properties: + ok: + type: boolean + error: + type: string + description: Agent-facing failure detail when ok=false. + messages: + type: array + description: | + Optional response payload — used by `/comms/http` to carry + the upstream HTTP response (status code in id, body in body). + items: + $ref: "#/components/schemas/CommsMessage" diff --git a/internal/app/app.go b/internal/app/app.go index 43c901d4..1749e57b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,6 +29,7 @@ import ( "github.com/ALRubinger/aileron/internal/connector/payments/stripe" "github.com/ALRubinger/aileron/internal/audit" "github.com/ALRubinger/aileron/internal/binding" + "github.com/ALRubinger/aileron/internal/comms" "github.com/ALRubinger/aileron/internal/draft" "github.com/ALRubinger/aileron/internal/enclave" "github.com/ALRubinger/aileron/internal/intercept" @@ -130,6 +131,41 @@ type Config struct { // is the right behavior for cloud-shaped deployments and tests // that don't exercise the launch session surface. Sessions sessions.Store + + // NotifyQueue is the daemon-wide queue of incoming Slack/Discord + // messages (ADR-0012, step 9B-2). Populated by listener goroutines + // after [OnVaultUnlock] resolves credentials; consumed by the + // `/v1/sessions/{id}/comms/messages` endpoint that powers + // `aileron-mcp`'s `read_messages` tool. Nil disables every comms + // endpoint — the right behavior for cloud-shaped daemons that + // don't bridge personal messaging. + NotifyQueue *comms.NotifyQueue + + // Listeners is the registry of active Slack/Discord listeners + // keyed by service name. Populated by the vault-unlock callback; + // consumed by `/comms/send` and `/comms/draft` to dispatch + // outbound messages after the user approves. Pair with + // [NotifyQueue] — both nil or both set. + Listeners *comms.ListenerRegistry + + // OnVaultUnlock fires after a successful POST /v1/vault/unlock, + // stamped with the freshly-unlocked vault. The daemon registers + // a callback here that resolves Slack/Discord tokens and starts + // the matching listeners — the canonical signal that "user just + // authorized us to read their personal messaging." Nil is fine + // for tests / cloud daemons that have nothing to do on unlock. + // + // Synchronous from the perspective of the unlock handler — the + // HTTP response holds open until the callback returns. Listener + // startup itself is fire-and-forget inside the callback so the + // handler never blocks on Slack's WebSocket handshake. + OnVaultUnlock func(vault.Vault) + + // AuditStateDir scopes the daily-rotated `audit-YYYY-MM-DD.jsonl` + // files that `message_received` events land in. Empty disables + // audit emission for inbound messages. Production passes + // ~/.aileron; tests pass t.TempDir(). + AuditStateDir string } // NewHandler creates a fully-wired Aileron control plane HTTP handler @@ -405,6 +441,15 @@ func NewHandlerWithConfig(log *slog.Logger, cfg Config) (http.Handler, error) { // in-memory implementation or omit entirely. server.sessions = cfg.Sessions server.actionApprovalTTL = 5 * time.Minute + + // Comms wiring (ADR-0012 step 9B-2). All four fields are + // optional and travel together: nil queue or nil registry + // makes every /comms/* endpoint return 503. The daemon + // (cmd/server) constructs them; tests opt in selectively. + server.notifyQueue = cfg.NotifyQueue + server.listeners = cfg.Listeners + server.onVaultUnlock = cfg.OnVaultUnlock + server.auditStateDir = cfg.AuditStateDir // Wire the audit recorder so approval.requested / approval.approved // / approval.denied land in the audit log. Before this, the // approval flow left no audit trail — the user could grant or diff --git a/internal/app/handlers.go b/internal/app/handlers.go index ba3cc6c0..98582412 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -119,6 +119,13 @@ type apiServer struct { bindings binding.Store // capability bindings (ADR-0006); nil when no vault is wired oauth2Sessions *oauth2Sessions // ADR-0006 server-driven OAuth dance state; lazy-initialized on first use oauth2HTTPClient *http.Client // for OAuth token exchanges; nil → http.DefaultClient + + // --- Comms (ADR-0012 step 9B-2) --- + notifyQueue *comms.NotifyQueue // daemon-wide inbound messages; nil disables /comms/* endpoints + listeners *comms.ListenerRegistry // registered Slack/Discord listeners; populated by the vault-unlock callback + onVaultUnlock func(vault.Vault) // fires after POST /v1/vault/unlock so listener startup can resolve tokens + auditStateDir string // scopes message-event audit log writes; "" disables audit emission for /comms/* + commsHTTPClient *http.Client // outbound client for /comms/http; nil falls back to http.DefaultClient } // --- JSON helpers --- diff --git a/internal/app/handlers_comms.go b/internal/app/handlers_comms.go new file mode 100644 index 00000000..a29f810e --- /dev/null +++ b/internal/app/handlers_comms.go @@ -0,0 +1,421 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + api "github.com/ALRubinger/aileron/internal/api/gen" + "github.com/ALRubinger/aileron/internal/approval" + "github.com/ALRubinger/aileron/internal/audit" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/vault" +) + +// commsResponseLimit caps how much of an upstream HTTP response body +// /comms/http surfaces back to the agent. Matches the pre-9B per-launch +// CommsServer's limit so behaviour is unchanged for callers. +const commsResponseLimit = 1 << 20 + +// ReadCommsMessages handles `GET /v1/sessions/{id}/comms/messages`. +// Replaces the per-launch unix-socket `read_messages` IPC method that +// the launch product's CommsServer used to field — under ADR-0012 +// step 9B-2 the daemon owns the notify queue and surfaces it via HTTP. +// +// Filters by service / channel when provided, marks every surfaced +// message as read on success, and returns the snapshot. +func (s *apiServer) ReadCommsMessages(w http.ResponseWriter, r *http.Request, sessionID string, params api.ReadCommsMessagesParams) { + if s.notifyQueue == nil { + writeError(w, http.StatusServiceUnavailable, "comms_disabled", + "daemon has no comms surface configured") + return + } + + service := "" + if params.Service != nil { + service = *params.Service + } + channel := "" + if params.Channel != nil { + channel = *params.Channel + } + + out := make([]api.CommsMessage, 0, s.notifyQueue.Len()) + for _, m := range s.notifyQueue.Messages() { + if service != "" && m.Source != service { + continue + } + if channel != "" && m.Channel != channel { + continue + } + dr := m.AutoDraft && m.Draft == "" + dto := api.CommsMessage{ + Id: m.ID, + Service: m.Source, + Channel: m.Channel, + Author: m.Author, + Body: m.Body, + Timestamp: m.Timestamp, + } + if dr { + t := true + dto.DraftRequest = &t + } + out = append(out, dto) + } + s.notifyQueue.MarkAllRead() + writeJSON(w, http.StatusOK, api.ReadCommsMessagesResponse{Messages: out}) +} + +// SendCommsMessage handles `POST /v1/sessions/{id}/comms/send`. +// Mirrors the 9A shell-approval pattern: register an entry on the +// daemon's action-approval queue, long-poll for the user's verdict, +// dispatch on approve. +func (s *apiServer) SendCommsMessage(w http.ResponseWriter, r *http.Request, sessionID string) { + if !s.commsConfigured() { + writeError(w, http.StatusServiceUnavailable, "comms_disabled", + "daemon has no comms surface configured") + return + } + if s.actionApprovals == nil { + writeError(w, http.StatusServiceUnavailable, "action_approvals_disabled", + "action-approval queue is not configured") + return + } + + var req api.SendCommsMessageRequest + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Service == "" || req.Channel == "" || req.Body == "" { + writeError(w, http.StatusBadRequest, "missing_fields", + "service, channel, and body are required") + return + } + + sender, ok := s.listeners.Get(req.Service) + if !ok { + s.logCommsEvent("message_denied_no_listener", sessionID, req.Service, req.Channel, "", req.Body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("send_message: no listener for service: " + req.Service)}) + return + } + + entry := s.actionApprovals.RegisterCommsSend(req.Service, req.Channel, req.Body, sessionID) + decision, waitErr := entry.Wait(r.Context(), s.actionApprovalTimeout()) + if errors.Is(waitErr, approval.ErrActionApprovalTimeout) { + s.logCommsEvent("message_denied_timeout", sessionID, req.Service, req.Channel, "", req.Body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("send_message: user did not respond before timeout")}) + return + } + if waitErr != nil { + s.logCommsEvent("message_denied_error", sessionID, req.Service, req.Channel, "", req.Body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("send_message: " + waitErr.Error())}) + return + } + if !decision.Approved { + s.logCommsEvent("message_denied", sessionID, req.Service, req.Channel, "", req.Body, "") + msg := "send_message: user denied" + if decision.Reason != "" { + msg += ": " + decision.Reason + } + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr(msg)}) + return + } + + if err := sender.Send(context.Background(), comms.OutgoingMessage{ + Channel: req.Channel, + Body: req.Body, + }); err != nil { + s.logCommsEvent("message_send_failed", sessionID, req.Service, req.Channel, "", req.Body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("send_message: dispatch failed: " + err.Error())}) + return + } + s.logCommsEvent("message_sent", sessionID, req.Service, req.Channel, "", req.Body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: true}) +} + +// DraftCommsReply handles `POST /v1/sessions/{id}/comms/draft`. +// Looks up the original incoming message in the notify queue by +// `reply_to`, registers a [comms_draft] approval entry, blocks on the +// user's verdict, and on approve dispatches the (possibly user-edited) +// reply through the matching listener. +func (s *apiServer) DraftCommsReply(w http.ResponseWriter, r *http.Request, sessionID string) { + if !s.commsConfigured() { + writeError(w, http.StatusServiceUnavailable, "comms_disabled", + "daemon has no comms surface configured") + return + } + if s.actionApprovals == nil { + writeError(w, http.StatusServiceUnavailable, "action_approvals_disabled", + "action-approval queue is not configured") + return + } + + var req api.DraftCommsReplyRequest + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.ReplyTo == "" || req.Body == "" { + writeError(w, http.StatusBadRequest, "missing_fields", + "reply_to and body are required") + return + } + + original, found := s.notifyQueue.FindByID(req.ReplyTo) + service := original.Source + channel := original.Channel + originalAuthor := original.Author + originalBody := original.Body + if !found { + service = "" + channel = "" + originalAuthor = "" + originalBody = "" + } + + entry := s.actionApprovals.RegisterCommsDraft(service, channel, originalAuthor, originalBody, req.Body, req.ReplyTo, sessionID) + decision, waitErr := entry.Wait(r.Context(), s.actionApprovalTimeout()) + if errors.Is(waitErr, approval.ErrActionApprovalTimeout) { + s.logCommsEvent("draft_denied_timeout", sessionID, service, channel, "", req.Body, req.ReplyTo) + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("draft_reply: user did not respond before timeout")}) + return + } + if waitErr != nil { + s.logCommsEvent("draft_denied_error", sessionID, service, channel, "", req.Body, req.ReplyTo) + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("draft_reply: " + waitErr.Error())}) + return + } + if !decision.Approved { + s.logCommsEvent("draft_discarded", sessionID, service, channel, "", req.Body, req.ReplyTo) + msg := "draft_reply: user discarded" + if decision.Reason != "" { + msg += ": " + decision.Reason + } + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr(msg)}) + return + } + + body := req.Body + if edited, ok := decision.EditedPayload["body"].(string); ok && edited != "" { + body = edited + s.logCommsEvent("draft_edited", sessionID, service, channel, "", body, req.ReplyTo) + } + if !found || service == "" || channel == "" { + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("draft_reply: original message is no longer available; cannot dispatch")}) + return + } + sender, ok := s.listeners.Get(service) + if !ok { + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("draft_reply: no listener for service: " + service)}) + return + } + if err := sender.Send(context.Background(), comms.OutgoingMessage{ + Channel: channel, + Body: body, + }); err != nil { + s.logCommsEvent("draft_send_failed", sessionID, service, channel, "", body, req.ReplyTo) + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("draft_reply: dispatch failed: " + err.Error())}) + return + } + s.logCommsEvent("reply_sent", sessionID, service, channel, "", body, req.ReplyTo) + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: true}) +} + +// RequestCommsHTTP handles `POST /v1/sessions/{id}/comms/http`. Matches +// the URL against api_key vault entries (where `metadata.type=api_key` +// and `metadata.labels[url-pattern]` matches), registers a +// [http_request] approval entry, long-polls, and on approve issues the +// HTTP call with the matched secret injected as a Bearer token. +// +// `commsConfigured()` is intentionally NOT checked here: `http_request` +// has no listener dependency. It only needs the vault for credential +// injection (and even that is optional). 503 here would surprise an +// agent that uses `http_request` in a launch where the user hasn't +// configured Slack/Discord. +func (s *apiServer) RequestCommsHTTP(w http.ResponseWriter, r *http.Request, sessionID string) { + if s.actionApprovals == nil { + writeError(w, http.StatusServiceUnavailable, "action_approvals_disabled", + "action-approval queue is not configured") + return + } + + var req api.RequestCommsHTTPRequest + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Method == "" || req.Url == "" { + writeError(w, http.StatusBadRequest, "missing_fields", + "method and url are required") + return + } + + body := "" + if req.Body != nil { + body = *req.Body + } + headersJSON := "" + if req.Headers != nil { + headersJSON = *req.Headers + } + + secretName, _ := s.matchAPIKeyForURL(r.Context(), req.Url) + + entry := s.actionApprovals.RegisterHTTPRequest(req.Method, req.Url, body, secretName, sessionID) + decision, waitErr := entry.Wait(r.Context(), s.actionApprovalTimeout()) + if errors.Is(waitErr, approval.ErrActionApprovalTimeout) { + s.logCommsEvent("http_request_denied_timeout", sessionID, req.Method, req.Url, "", body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("http_request: user did not respond before timeout")}) + return + } + if waitErr != nil { + s.logCommsEvent("http_request_denied_error", sessionID, req.Method, req.Url, "", body, "") + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("http_request: " + waitErr.Error())}) + return + } + if !decision.Approved { + s.logCommsEvent("http_request_denied", sessionID, req.Method, req.Url, "", body, "") + msg := "http_request: user denied" + if decision.Reason != "" { + msg += ": " + decision.Reason + } + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr(msg)}) + return + } + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + httpReq, err := http.NewRequestWithContext(r.Context(), req.Method, req.Url, bodyReader) + if err != nil { + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("http_request: invalid request: " + err.Error())}) + return + } + if headersJSON != "" { + var headers map[string]string + if err := json.Unmarshal([]byte(headersJSON), &headers); err == nil { + for k, v := range headers { + httpReq.Header.Set(k, v) + } + } + } + if secretName != "" && s.vault != nil { + secret, err := s.vault.Get(r.Context(), secretName) + if err == nil { + httpReq.Header.Set("Authorization", "Bearer "+string(secret.Value)) + } + } + + client := s.commsHTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(httpReq) + if err != nil { + writeJSON(w, http.StatusOK, api.CommsToolResponse{Ok: false, Error: ptr("http_request: dispatch failed: " + err.Error())}) + return + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, commsResponseLimit)) + s.logCommsEvent("http_request_sent", sessionID, req.Method, req.Url, "", string(respBody), "") + out := api.CommsToolResponse{ + Ok: true, + Messages: &[]api.CommsMessage{{ + Id: fmt.Sprintf("%d", resp.StatusCode), + Service: "", + Channel: "", + Author: "", + Body: string(respBody), + Timestamp: time.Now(), + }}, + } + writeJSON(w, http.StatusOK, out) +} + +// commsConfigured reports whether the daemon has a usable comms +// surface — both the queue and the listener registry must be wired. +// /comms/http stands apart since it doesn't need listeners. +func (s *apiServer) commsConfigured() bool { + return s.notifyQueue != nil && s.listeners != nil +} + +// matchAPIKeyForURL scans the vault for api_key entries whose +// `url-pattern` label matches url, returning the first match's name. +// The match is intentionally simple — substring or trailing-wildcard, +// matching the pre-9B per-launch CommsServer's URLMatchesPattern so +// the agent surface stays consistent. +func (s *apiServer) matchAPIKeyForURL(ctx context.Context, url string) (string, bool) { + if s.vault == nil { + return "", false + } + entries, err := s.vault.List(ctx) + if err != nil { + return "", false + } + for _, e := range entries { + if e.Metadata.Type != "api_key" { + continue + } + pattern := e.Metadata.Labels["url-pattern"] + if pattern == "" { + continue + } + if urlMatchesPattern(url, pattern) { + return e.Path, true + } + } + return "", false +} + +// urlMatchesPattern reports whether url contains pattern, with an +// optional trailing wildcard. Examples: +// +// "slack.com/api/*" matches "https://slack.com/api/chat.postMessage". +// "api.github.com" matches "https://api.github.com/repos/foo/bar". +// +// Exact regex / glob expressiveness is deliberately out of scope; the +// pre-9B per-launch CommsServer shipped this exact matcher. +func urlMatchesPattern(url, pattern string) bool { + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + return strings.Contains(url, prefix) + } + return strings.Contains(url, pattern) +} + +// logCommsEvent writes a single message audit entry. Best-effort: a +// failed write is logged but does not abort the request. +func (s *apiServer) logCommsEvent(event, sessionID, service, channel, author, body, inReplyTo string) { + if s.auditStateDir == "" { + return + } + if err := audit.AppendMessageEntry(audit.DailyPath(s.auditStateDir), audit.MessageEntry{ + Timestamp: time.Now(), + SessionID: sessionID, + Event: event, + Service: service, + Channel: channel, + Author: author, + Body: body, + InReplyTo: inReplyTo, + }); err != nil { + s.log.Warn("comms audit write failed", "event", event, "error", err) + } +} + +// Compile-time assertion that the vault.Vault interface still has +// List — kept here so a refactor of the SPI surfaces an error in the +// comms layer rather than a runtime panic. +var _ = vault.Vault.List + +// ptr returns a pointer to v. Local helper to keep handler bodies +// concise when constructing pointer-typed schema fields. +func ptr[T any](v T) *T { return &v } \ No newline at end of file diff --git a/internal/app/handlers_comms_test.go b/internal/app/handlers_comms_test.go new file mode 100644 index 00000000..12812b23 --- /dev/null +++ b/internal/app/handlers_comms_test.go @@ -0,0 +1,687 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + api "github.com/ALRubinger/aileron/internal/api/gen" + "github.com/ALRubinger/aileron/internal/approval" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/vault" +) + +// /v1/sessions/{id}/comms/* contract: +// +// - GET /comms/messages — 200 with the queue snapshot, marks messages +// read on success. 503 when the daemon was not configured with a +// notify queue. +// - POST /comms/send / /comms/draft / /comms/http — 200 with +// `{ok:true}` on approve, `{ok:false,error:...}` on deny / timeout +// / dispatch failure. 400 on missing fields. 503 when no queue or +// no approval queue is configured. + +// newCommsServer builds an apiServer wired with a fresh notify queue, +// listener registry, and approval queue. ttl scopes the per-entry +// approval wait so timeout tests run quickly. Tests drive the queue +// directly via Decide() to simulate user verdicts; the listener +// registry stays empty unless the test populates it. +func newCommsServer(t *testing.T, ttl time.Duration) *apiServer { + t.Helper() + return &apiServer{ + log: slog.Default(), + notifyQueue: comms.NewNotifyQueue(100, nil), + listeners: comms.NewListenerRegistry(), + actionApprovals: approval.NewActionApprovalQueue(nil, nil), + actionApprovalTTL: ttl, + } +} + +// approveNextOf decides the first matching pending approval as soon as +// it appears. Mirrors the helper used by the shell-approval tests. +func approveNextOf(s *apiServer, kind approval.ApprovalKind, approved bool, edited map[string]any) { + go func() { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + pending := s.actionApprovals.List() + for _, p := range pending { + if p.Kind == kind { + _ = s.actionApprovals.Decide(p.ID, approved, "", edited) + return + } + } + time.Sleep(10 * time.Millisecond) + } + }() +} + +// fakeListener captures Send calls so tests can assert on dispatch +// without round-tripping through the real Slack/Discord SDKs. +type fakeListener struct { + service string + sent []comms.OutgoingMessage + sendErr error +} + +func (f *fakeListener) Service() string { return f.service } +func (f *fakeListener) Connect(context.Context) error { return nil } +func (f *fakeListener) Listen(context.Context) (<-chan comms.IncomingMessage, error) { + return make(chan comms.IncomingMessage), nil +} +func (f *fakeListener) Send(_ context.Context, msg comms.OutgoingMessage) error { + if f.sendErr != nil { + return f.sendErr + } + f.sent = append(f.sent, msg) + return nil +} +func (f *fakeListener) Close() error { return nil } + +// --- ReadCommsMessages --- + +func TestReadCommsMessages_HappyPath(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.notifyQueue.Push(comms.Message{ID: "1", Source: "slack", Channel: "#dev", Author: "alice", Body: "hello", Timestamp: time.Now()}) + s.notifyQueue.Push(comms.Message{ID: "2", Source: "discord", Channel: "general", Author: "bob", Body: "hi", Timestamp: time.Now()}) + + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/x/comms/messages", nil) + w := httptest.NewRecorder() + s.ReadCommsMessages(w, req, "x", api.ReadCommsMessagesParams{}) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var resp api.ReadCommsMessagesResponse + mustDecode(t, w.Body, &resp) + if len(resp.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(resp.Messages)) + } + // All surfaced messages must be marked read after the call. + if got := s.notifyQueue.UnreadCount(); got != 0 { + t.Errorf("UnreadCount after read = %d, want 0", got) + } +} + +func TestReadCommsMessages_FilterByService(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.notifyQueue.Push(comms.Message{ID: "1", Source: "slack", Channel: "#dev"}) + s.notifyQueue.Push(comms.Message{ID: "2", Source: "discord", Channel: "general"}) + + svc := "slack" + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/x/comms/messages", nil) + w := httptest.NewRecorder() + s.ReadCommsMessages(w, req, "x", api.ReadCommsMessagesParams{Service: &svc}) + var resp api.ReadCommsMessagesResponse + mustDecode(t, w.Body, &resp) + if len(resp.Messages) != 1 || resp.Messages[0].Service != "slack" { + t.Errorf("filter by service failed: %+v", resp.Messages) + } +} + +func TestReadCommsMessages_DraftRequestFlag(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.notifyQueue.Push(comms.Message{ID: "1", Source: "slack", AutoDraft: true}) + s.notifyQueue.Push(comms.Message{ID: "2", Source: "slack", AutoDraft: true, Draft: "already drafted"}) + + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/x/comms/messages", nil) + w := httptest.NewRecorder() + s.ReadCommsMessages(w, req, "x", api.ReadCommsMessagesParams{}) + var resp api.ReadCommsMessagesResponse + mustDecode(t, w.Body, &resp) + if resp.Messages[0].DraftRequest == nil || !*resp.Messages[0].DraftRequest { + t.Error("expected draft_request=true on auto-draft without existing draft") + } + if resp.Messages[1].DraftRequest != nil && *resp.Messages[1].DraftRequest { + t.Error("expected draft_request=false on already-drafted message") + } +} + +func TestReadCommsMessages_NoQueue503(t *testing.T) { + s := &apiServer{log: slog.Default()} // no notifyQueue + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/x/comms/messages", nil) + w := httptest.NewRecorder() + s.ReadCommsMessages(w, req, "x", api.ReadCommsMessagesParams{}) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +// --- SendCommsMessage --- + +func TestSendCommsMessage_ApprovedDispatches(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + listener := &fakeListener{service: "slack"} + s.listeners.Set("slack", listener) + + approveNextOf(s, approval.ApprovalKindCommsSend, true, nil) + + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "ship it"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "sess-1") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if !resp.Ok { + t.Fatalf("ok=false, error=%v", deref(resp.Error)) + } + if len(listener.sent) != 1 || listener.sent[0].Body != "ship it" { + t.Errorf("listener sent = %+v, want one message body=ship it", listener.sent) + } +} + +func TestSendCommsMessage_DeniedReturnsError(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.listeners.Set("slack", &fakeListener{service: "slack"}) + + approveNextOf(s, approval.ApprovalKindCommsSend, false, nil) + + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "ship it"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "sess-1") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false on deny") + } +} + +func TestSendCommsMessage_TimeoutCollapsesToError(t *testing.T) { + // 50ms TTL, no decision — entry times out → ok=false. + s := newCommsServer(t, 50*time.Millisecond) + s.listeners.Set("slack", &fakeListener{service: "slack"}) + + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "ship it"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "sess-1") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false on timeout") + } + if !strings.Contains(deref(resp.Error), "timeout") { + t.Errorf("error = %q, want a timeout reference", deref(resp.Error)) + } +} + +func TestSendCommsMessage_MissingFields400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.listeners.Set("slack", &fakeListener{service: "slack"}) + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack"}) // missing channel, body + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestSendCommsMessage_NoListenerForService(t *testing.T) { + // Listener registry empty → /comms/send returns ok=false rather + // than registering an approval that could never be dispatched. + s := newCommsServer(t, 5*time.Second) + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false when no listener registered") + } + if !strings.Contains(deref(resp.Error), "no listener for service") { + t.Errorf("error = %q, want 'no listener for service' detail", deref(resp.Error)) + } +} + +func TestSendCommsMessage_NoQueue503(t *testing.T) { + // Comms queue + listener registry both nil → 503. + s := &apiServer{log: slog.Default(), actionApprovals: approval.NewActionApprovalQueue(nil, nil)} + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +// --- DraftCommsReply --- + +func TestDraftCommsReply_ApprovedDispatches(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + listener := &fakeListener{service: "slack"} + s.listeners.Set("slack", listener) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev", Author: "alice", Body: "is the deploy blocked?"}) + + approveNextOf(s, approval.ApprovalKindCommsDraft, true, nil) + + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "no, all clear"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "sess-1") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if !resp.Ok { + t.Fatalf("ok=false, error=%v", deref(resp.Error)) + } + if len(listener.sent) != 1 || listener.sent[0].Body != "no, all clear" { + t.Errorf("listener sent = %+v", listener.sent) + } +} + +func TestDraftCommsReply_EditedBodyWins(t *testing.T) { + // User edits the draft via EditedPayload["body"] — the dispatcher + // must send the edited bytes, not the agent's original draft. + s := newCommsServer(t, 5*time.Second) + listener := &fakeListener{service: "slack"} + s.listeners.Set("slack", listener) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) + + approveNextOf(s, approval.ApprovalKindCommsDraft, true, map[string]any{"body": "edited reply"}) + + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "agent's draft"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "sess-1") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if !resp.Ok { + t.Fatalf("ok=false, error=%v", deref(resp.Error)) + } + if len(listener.sent) != 1 || listener.sent[0].Body != "edited reply" { + t.Errorf("listener sent = %+v, want edited body", listener.sent) + } +} + +func TestDraftCommsReply_MissingFields400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "x"}) // missing body + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestDraftCommsReply_OriginalEvictedFromQueue(t *testing.T) { + // User approves a draft whose original message is no longer in the + // queue — the daemon can't route the reply, so ok=false with a + // descriptive error. + s := newCommsServer(t, 5*time.Second) + approveNextOf(s, approval.ApprovalKindCommsDraft, true, nil) + + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "evicted", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false when original message is no longer available") + } +} + +// --- RequestCommsHTTP --- + +func TestRequestCommsHTTP_ApprovedDispatches(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "" { + w.Header().Set("X-Saw-Auth", got) + } + _, _ = io.WriteString(w, `{"status":"ok"}`) + })) + defer upstream.Close() + + s := newCommsServer(t, 5*time.Second) + approveNextOf(s, approval.ApprovalKindHTTPRequest, true, nil) + + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: upstream.URL}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if !resp.Ok { + t.Fatalf("ok=false, error=%v", deref(resp.Error)) + } + if resp.Messages == nil || len(*resp.Messages) != 1 { + t.Fatalf("expected 1 response message, got %+v", resp.Messages) + } + if !strings.Contains((*resp.Messages)[0].Body, "ok") { + t.Errorf("response body = %q", (*resp.Messages)[0].Body) + } +} + +func TestRequestCommsHTTP_BearerInjectedFromVault(t *testing.T) { + // vault entry: type=api_key, label url-pattern matches the upstream + // URL → handler injects "Bearer " on the upstream call. + gotAuth := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth <- r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{}`) + })) + defer upstream.Close() + + v := vault.NewMemVault() + _ = v.Put(context.Background(), "api_key/example/work", []byte("super-secret"), vault.Metadata{ + Type: "api_key", + Labels: map[string]string{"url-pattern": "127.0.0.1"}, + }) + + s := newCommsServer(t, 5*time.Second) + s.vault = v + approveNextOf(s, approval.ApprovalKindHTTPRequest, true, nil) + + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: upstream.URL}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + + auth := <-gotAuth + if auth != "Bearer super-secret" { + t.Errorf("Authorization header = %q, want 'Bearer super-secret'", auth) + } +} + +func TestRequestCommsHTTP_DeniedReturnsError(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + approveNextOf(s, approval.ApprovalKindHTTPRequest, false, nil) + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: "https://example.com"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false on deny") + } +} + +func TestRequestCommsHTTP_MissingFields400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET"}) // missing url + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestRequestCommsHTTP_NoApprovalQueue503(t *testing.T) { + // Even without listeners, /comms/http needs the action-approval + // queue. nil queue → 503 (matching the shell-approval pattern). + s := &apiServer{log: slog.Default()} + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: "https://x"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +// --- url-pattern matching --- + +func TestMatchAPIKeyForURL_MatchesByLabel(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "api_key/foo/work", []byte("k"), vault.Metadata{ + Type: "api_key", + Labels: map[string]string{"url-pattern": "api.example.com"}, + }) + _ = v.Put(context.Background(), "api_key/bar/work", []byte("k"), vault.Metadata{ + Type: "api_key", + Labels: map[string]string{"url-pattern": "other.example.com"}, + }) + s := &apiServer{vault: v} + name, ok := s.matchAPIKeyForURL(context.Background(), "https://api.example.com/v1/x") + if !ok || name != "api_key/foo/work" { + t.Errorf("got (%q, %v), want api_key/foo/work, true", name, ok) + } +} + +func TestMatchAPIKeyForURL_SkipsNonAPIKey(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "oauth/foo", []byte("k"), vault.Metadata{ + Type: "oauth_refresh_token", + Labels: map[string]string{"url-pattern": "api.example.com"}, + }) + s := &apiServer{vault: v} + if _, ok := s.matchAPIKeyForURL(context.Background(), "https://api.example.com/v1"); ok { + t.Error("expected no match for non-api_key entry") + } +} + +// --- Additional dispatch error coverage --- + +func TestSendCommsMessage_DispatchErrorReturnsError(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.listeners.Set("slack", &fakeListener{service: "slack", sendErr: errSendFailed{}}) + approveNextOf(s, approval.ApprovalKindCommsSend, true, nil) + + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#x", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "dispatch failed") { + t.Errorf("expected ok=false with 'dispatch failed', got %+v", resp) + } +} + +func TestDraftCommsReply_DispatchErrorReturnsError(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.listeners.Set("slack", &fakeListener{service: "slack", sendErr: errSendFailed{}}) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) + approveNextOf(s, approval.ApprovalKindCommsDraft, true, nil) + + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "dispatch failed") { + t.Errorf("expected ok=false with 'dispatch failed', got %+v", resp) + } +} + +func TestDraftCommsReply_NoListenerForService(t *testing.T) { + // Original message was on a service that's no longer registered — + // e.g. the listener died during the user's deliberation. + s := newCommsServer(t, 5*time.Second) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) + approveNextOf(s, approval.ApprovalKindCommsDraft, true, nil) + + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "no listener for service") { + t.Errorf("expected 'no listener for service' error, got %+v", resp) + } +} + +func TestDraftCommsReply_TimeoutCollapsesToError(t *testing.T) { + s := newCommsServer(t, 50*time.Millisecond) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "timeout") { + t.Errorf("expected timeout error, got %+v", resp) + } +} + +func TestRequestCommsHTTP_TimeoutCollapsesToError(t *testing.T) { + s := newCommsServer(t, 50*time.Millisecond) + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: "https://example.com"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "timeout") { + t.Errorf("expected timeout error, got %+v", resp) + } +} + +func TestRequestCommsHTTP_DispatchFailure(t *testing.T) { + // Approve, then point at an unreachable URL — daemon's HTTP client + // returns an error which surfaces as ok=false. + s := newCommsServer(t, 5*time.Second) + approveNextOf(s, approval.ApprovalKindHTTPRequest, true, nil) + + body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET", Url: "http://127.0.0.1:1"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok || !strings.Contains(deref(resp.Error), "dispatch failed") { + t.Errorf("expected dispatch failed error, got %+v", resp) + } +} + +func TestRequestCommsHTTP_HeadersInjected(t *testing.T) { + got := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r.Header.Get("X-Custom") + _, _ = io.WriteString(w, `{}`) + })) + defer upstream.Close() + + s := newCommsServer(t, 5*time.Second) + approveNextOf(s, approval.ApprovalKindHTTPRequest, true, nil) + + body, _ := json.Marshal(api.RequestCommsHTTPRequest{ + Method: "GET", + Url: upstream.URL, + Headers: ptr(`{"X-Custom":"hello"}`), + }) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + + if v := <-got; v != "hello" { + t.Errorf("X-Custom = %q, want hello", v) + } +} + +func TestSendCommsMessage_AuditWriteHappens(t *testing.T) { + dir := t.TempDir() + s := newCommsServer(t, 5*time.Second) + s.auditStateDir = dir + listener := &fakeListener{service: "slack"} + s.listeners.Set("slack", listener) + approveNextOf(s, approval.ApprovalKindCommsSend, true, nil) + + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#dev", Body: "ship it"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/sess-1/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "sess-1") + + // audit/audit-YYYY-MM-DD.jsonl should now have a `message_sent` entry. + entries := readAuditMessages(t, dir) + if len(entries) == 0 { + t.Fatal("no audit entries written") + } + gotEvents := make(map[string]bool) + for _, e := range entries { + gotEvents[e["event"].(string)] = true + } + if !gotEvents["message_sent"] { + t.Errorf("expected message_sent in audit; got %+v", gotEvents) + } +} + +func TestUrlMatchesPattern(t *testing.T) { + cases := []struct { + url, pattern string + want bool + }{ + {"https://api.example.com/v1", "api.example.com", true}, + {"https://api.example.com/v1", "slack.com/api/*", false}, + {"https://slack.com/api/chat.postMessage", "slack.com/api/*", true}, + {"https://example.com/x", "missing", false}, + } + for _, tc := range cases { + if got := urlMatchesPattern(tc.url, tc.pattern); got != tc.want { + t.Errorf("urlMatchesPattern(%q, %q) = %v, want %v", tc.url, tc.pattern, got, tc.want) + } + } +} + +// errSendFailed is a sentinel used by the dispatch-error tests above. +type errSendFailed struct{} + +func (errSendFailed) Error() string { return "slack returned 500" } + +// readAuditMessages parses every `audit-*.jsonl` line under +// /audit/ as a generic map. Used by audit-shape assertions +// where pulling in the audit package's reader would couple the test +// to its struct field names — comms audit is loose enough that map +// access is friendlier. +func readAuditMessages(t *testing.T, dir string) []map[string]any { + t.Helper() + auditDir := dir + "/audit" + entries, err := os.ReadDir(auditDir) + if err != nil { + t.Fatalf("read audit dir: %v", err) + } + var out []map[string]any + for _, e := range entries { + data, err := os.ReadFile(auditDir + "/" + e.Name()) + if err != nil { + t.Fatalf("read audit file: %v", err) + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var entry map[string]any + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("decode audit line: %v", err) + } + out = append(out, entry) + } + } + return out +} + +// --- helpers --- + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// (mustDecode is defined alongside other handler tests in this package.) \ No newline at end of file diff --git a/internal/app/handlers_local_vault.go b/internal/app/handlers_local_vault.go index 44a8985c..3666724e 100644 --- a/internal/app/handlers_local_vault.go +++ b/internal/app/handlers_local_vault.go @@ -87,6 +87,25 @@ func (s *apiServer) UnlockLocalVault(w http.ResponseWriter, r *http.Request) { s.vaultLocked = false s.log.Info("local vault unlocked via webapp") + // Fire the vault-unlock callback so daemon-wide subsystems that + // were waiting on credentials (Slack/Discord listener startup, + // per ADR-0012 step 9B-2) can resolve tokens and come online. + // The callback runs synchronously here; listener startup itself + // is fire-and-forget inside the callback so this handler never + // blocks on Slack's websocket handshake. A panicking callback + // must not poison the unlock — caller might still want the + // vault open. + if s.onVaultUnlock != nil { + func() { + defer func() { + if r := recover(); r != nil { + s.log.Warn("vault-unlock callback panicked", "panic", r) + } + }() + s.onVaultUnlock(v) + }() + } + writeJSON(w, http.StatusOK, s.localVaultStatusResponseLocked()) } diff --git a/internal/app/handlers_local_vault_test.go b/internal/app/handlers_local_vault_test.go index ac322803..41838fef 100644 --- a/internal/app/handlers_local_vault_test.go +++ b/internal/app/handlers_local_vault_test.go @@ -225,6 +225,70 @@ func TestUnlockLocalVault_NoLocalVaultConfigured(t *testing.T) { assertErrorCode(t, w, "no_local_vault") } +func TestUnlockLocalVault_FiresOnVaultUnlockCallback(t *testing.T) { + // ADR-0012 step 9B-2: when the local vault is unlocked via the + // webapp, the daemon must call OnVaultUnlock so listener startup + // can resolve Slack/Discord tokens against the freshly-unlocked + // inner vault. The callback receives the unlocked vault handle — + // without it the listener-startup helper has no way to resolve + // `vault:slack-app-token` references. + path := filepath.Join(t.TempDir(), "secrets.json") + if _, err := vault.Init(path, "open sesame"); err != nil { + t.Fatalf("Init: %v", err) + } + s := newLocalVaultServer(t, path) + got := make(chan vault.Vault, 1) + s.onVaultUnlock = func(v vault.Vault) { got <- v } + + w := httptest.NewRecorder() + r := unlockRequest(`{"passphrase":"open sesame"}`) + s.UnlockLocalVault(w, r) + assertStatus(t, w, http.StatusOK) + + select { + case v := <-got: + if v == nil { + t.Fatal("callback fired with nil vault") + } + default: + t.Fatal("OnVaultUnlock callback did not fire after successful unlock") + } +} + +func TestUnlockLocalVault_CallbackPanicDoesNotPoisonUnlock(t *testing.T) { + // A misbehaving callback must not break the unlock — the user's + // passphrase was right, the vault is open, the rest of the system + // should keep working. + path := filepath.Join(t.TempDir(), "secrets.json") + if _, err := vault.Init(path, "open sesame"); err != nil { + t.Fatalf("Init: %v", err) + } + s := newLocalVaultServer(t, path) + s.onVaultUnlock = func(vault.Vault) { panic("boom") } + + w := httptest.NewRecorder() + r := unlockRequest(`{"passphrase":"open sesame"}`) + s.UnlockLocalVault(w, r) + assertStatus(t, w, http.StatusOK) + if s.vaultLocked { + t.Error("vaultLocked still true after panic in callback") + } +} + +func TestUnlockLocalVault_NilCallbackIsFine(t *testing.T) { + // Callback is optional; nil must not panic. + path := filepath.Join(t.TempDir(), "secrets.json") + if _, err := vault.Init(path, "open sesame"); err != nil { + t.Fatalf("Init: %v", err) + } + s := newLocalVaultServer(t, path) + // onVaultUnlock left nil. + w := httptest.NewRecorder() + r := unlockRequest(`{"passphrase":"open sesame"}`) + s.UnlockLocalVault(w, r) + assertStatus(t, w, http.StatusOK) +} + // --- helpers --- func unlockRequest(body string) *http.Request { diff --git a/internal/app/handlers_sessions_test.go b/internal/app/handlers_sessions_test.go index 2019630e..3b464fd5 100644 --- a/internal/app/handlers_sessions_test.go +++ b/internal/app/handlers_sessions_test.go @@ -305,4 +305,3 @@ func mustJSON(t *testing.T, v any) []byte { return b } -func ptr[T any](v T) *T { return &v } diff --git a/internal/comms/listeners.go b/internal/comms/listeners.go new file mode 100644 index 00000000..a00d4c4c --- /dev/null +++ b/internal/comms/listeners.go @@ -0,0 +1,332 @@ +package comms + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/ALRubinger/aileron/internal/audit" + "github.com/ALRubinger/aileron/internal/config" + "github.com/ALRubinger/aileron/internal/vault" +) + +// vaultPrefix marks a config value as a reference to a vault entry +// (e.g. `vault:slack-app-token`). Tokens stored as plaintext are +// rejected at config-load time. +const vaultPrefix = "vault:" + +// IsVaultRef reports whether v is a vault reference. +func IsVaultRef(v string) bool { + return strings.HasPrefix(v, vaultPrefix) +} + +// ResolveVaultRef returns the underlying value for a vault reference, +// or v itself if it is not a reference. +func ResolveVaultRef(ctx context.Context, v string, vlt vault.Vault) (string, error) { + if !IsVaultRef(v) { + return v, nil + } + if vlt == nil { + return "", fmt.Errorf("vault reference %q requires a vault", v) + } + name := strings.TrimPrefix(v, vaultPrefix) + secret, err := vlt.Get(ctx, name) + if err != nil { + return "", fmt.Errorf("resolving %s: %w", v, err) + } + return string(secret.Value), nil +} + +// ListenerRegistry is a thread-safe map of active listeners keyed by +// their service name (e.g. "slack", "discord"). The daemon's HTTP comms +// handlers (`send_message`, `draft_reply`) consult it to dispatch to +// the right backend after the user approves; `StartListeners` populates +// it once the vault is unlocked. +// +// Empty registry is a valid steady state — when the user has not +// configured any listeners, send-shaped tools fail with 503 rather +// than registering an approval the daemon could never dispatch. +type ListenerRegistry struct { + mu sync.RWMutex + listeners map[string]Listener +} + +// NewListenerRegistry creates an empty registry. +func NewListenerRegistry() *ListenerRegistry { + return &ListenerRegistry{listeners: make(map[string]Listener)} +} + +// Set registers (or replaces) the listener for a service. +func (r *ListenerRegistry) Set(service string, l Listener) { + r.mu.Lock() + defer r.mu.Unlock() + r.listeners[service] = l +} + +// Get returns the listener for the given service. +func (r *ListenerRegistry) Get(service string) (Listener, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + l, ok := r.listeners[service] + return l, ok +} + +// Len returns the number of registered listeners. Useful for "listeners +// ready?" checks in handlers and tests. +func (r *ListenerRegistry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.listeners) +} + +// Services returns the names of all registered services. Sort order +// is unspecified. +func (r *ListenerRegistry) Services() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.listeners)) + for s := range r.listeners { + out = append(out, s) + } + return out +} + +// CloseAll shuts down every registered listener and clears the +// registry. Best-effort; per-listener errors are logged but do not +// abort the loop so a hung Slack websocket can't block Discord teardown. +func (r *ListenerRegistry) CloseAll(log *slog.Logger) { + r.mu.Lock() + defer r.mu.Unlock() + for service, l := range r.listeners { + if err := l.Close(); err != nil && log != nil { + log.Warn("listener close failed", "service", service, "error", err) + } + } + r.listeners = make(map[string]Listener) +} + +// StartOptions bundles the inputs StartListeners needs. Extracted so +// callers don't grow a long positional argument list as the +// daemon-owned listener layer accretes responsibilities. +type StartOptions struct { + // Notifications is the user-scoped Slack/Discord configuration. + // Nil or with no Slack/Discord block means "no listeners to + // start"; StartListeners returns immediately with an empty + // registry contribution. + Notifications *config.NotifyConfig + + // Vault holds the resolved tokens. Required when Notifications + // references vault paths (the production case); the constructor + // resolves each `vault:` ref at startup. + Vault vault.Vault + + // Queue is where every incoming message lands. A bridge goroutine + // is spawned per started listener that funnels [IncomingMessage] + // into [Queue.Push] with the channel's auto-draft + priority + // flags applied. + Queue *NotifyQueue + + // AuditStateDir is the directory under which `audit-YYYY-MM-DD.jsonl` + // gets the `message_received` event written for each inbound + // message. Empty disables audit emission. + AuditStateDir string + + // Log scopes per-listener structured log entries. Required. + Log *slog.Logger +} + +// StartListeners resolves vault tokens, constructs the Slack and +// Discord listeners that the user configured, connects them, and +// spawns a bridge goroutine per listener that pushes inbound messages +// into the [NotifyQueue]. Successful starts are written to the +// supplied registry — the daemon's comms handlers consult the +// registry to dispatch outbound `send_message` / `draft_reply` calls +// after the user approves. +// +// Best-effort: a single listener that fails to connect is logged and +// skipped; the others still start. Return value is the count of +// successfully-started listeners so the caller can log "0 of 2 +// listeners up" when the vault has stale tokens. +func StartListeners(ctx context.Context, opts StartOptions, registry *ListenerRegistry) (int, error) { + if opts.Notifications == nil { + return 0, nil + } + if opts.Queue == nil { + return 0, fmt.Errorf("StartListeners requires a NotifyQueue") + } + if opts.Log == nil { + return 0, fmt.Errorf("StartListeners requires a logger") + } + + created, autoDraft, priority, err := buildListeners(ctx, opts) + if err != nil { + return 0, err + } + + started := 0 + for _, l := range created { + if err := l.Connect(ctx); err != nil { + opts.Log.Warn("listener connect failed", "service", l.Service(), "error", err) + continue + } + msgs, err := l.Listen(ctx) + if err != nil { + opts.Log.Warn("listener listen failed", "service", l.Service(), "error", err) + continue + } + opts.Log.Info("listener started", "service", l.Service()) + registry.Set(l.Service(), l) + started++ + go bridgeMessages(msgs, opts.Queue, autoDraft, priority, opts.AuditStateDir, opts.Log) + } + return started, nil +} + +// buildListeners constructs the concrete Slack/Discord listeners from +// the user's notification config and returns the autoDraft + priority +// channel maps the bridge goroutine reads. +func buildListeners(ctx context.Context, opts StartOptions) ([]Listener, map[string]bool, map[string]string, error) { + autoDraft := make(map[string]bool) + priority := make(map[string]string) + var listeners []Listener + + if cfg := opts.Notifications.Slack; cfg != nil && cfg.AppToken != "" && cfg.BotToken != "" { + appToken, err := ResolveVaultRef(ctx, cfg.AppToken, opts.Vault) + if err != nil { + return nil, nil, nil, err + } + botToken, err := ResolveVaultRef(ctx, cfg.BotToken, opts.Vault) + if err != nil { + return nil, nil, nil, err + } + var userToken string + if cfg.UserToken != "" { + userToken, err = ResolveVaultRef(ctx, cfg.UserToken, opts.Vault) + if err != nil { + return nil, nil, nil, err + } + } + channels := make([]string, 0, len(cfg.Channels)) + for _, ch := range cfg.Channels { + channels = append(channels, ch.Name) + if ch.AutoDraft { + autoDraft[ch.Name] = true + } + if ch.Priority != "" { + priority[ch.Name] = ch.Priority + } + } + opts.Log.Info("slack listener configured", + "channels", channels, + "ignore", cfg.Ignore, + "user_token", userToken != "", + ) + listeners = append(listeners, NewSlackListener(appToken, botToken, userToken, channels, cfg.Ignore, opts.Log.With("component", "slack"))) + } + + if cfg := opts.Notifications.Discord; cfg != nil && cfg.BotToken != "" { + botToken, err := ResolveVaultRef(ctx, cfg.BotToken, opts.Vault) + if err != nil { + return nil, nil, nil, err + } + channels := make([]string, 0, len(cfg.Channels)) + for _, ch := range cfg.Channels { + channels = append(channels, ch.Name) + if ch.Priority != "" { + priority[ch.Name] = ch.Priority + } + } + opts.Log.Info("discord listener configured", + "channels", channels, + "ignore", cfg.Ignore, + ) + listeners = append(listeners, NewDiscordListener(botToken, channels, cfg.Ignore, opts.Log.With("component", "discord"))) + } + + return listeners, autoDraft, priority, nil +} + +// bridgeMessages reads from a listener's IncomingMessage channel and +// pushes each message into the daemon-owned NotifyQueue with the +// configured auto-draft and priority flags applied. Also writes a +// `message_received` audit entry when an audit dir is configured. +func bridgeMessages(msgs <-chan IncomingMessage, queue *NotifyQueue, autoDraft map[string]bool, priority map[string]string, auditStateDir string, log *slog.Logger) { + for msg := range msgs { + preview := msg.Body + if len(preview) > 80 { + preview = preview[:77] + "..." + } + pri := priority[msg.Channel] + if pri == "" { + pri = "normal" + } + log.Debug("message received", + "service", msg.Service, + "channel", msg.Channel, + "author", msg.Author, + "priority", pri, + "preview", preview, + ) + queue.Push(Message{ + ID: msg.ID, + Source: msg.Service, + Channel: msg.Channel, + Author: msg.Author, + Preview: preview, + Body: msg.Body, + Timestamp: msg.Timestamp, + AutoDraft: autoDraft[msg.Channel], + Priority: pri, + }) + if auditStateDir != "" { + audit.AppendMessageEntry(audit.DailyPath(auditStateDir), audit.MessageEntry{ + Timestamp: msg.Timestamp, + Event: "message_received", + Service: msg.Service, + Channel: msg.Channel, + Author: msg.Author, + Body: msg.Body, + }) + } + } +} + +// ValidateNotificationTokens checks that every token in the +// notifications block is either empty or a vault reference. Plaintext +// tokens trip a fail-loud error so users don't accidentally commit +// secrets in `~/.aileron/config.yaml`. +// +// Called at daemon startup so the failure surfaces in the daemon log +// rather than mid-listener, and so `aileron status notifications` can +// surface the problem alongside the rest of the config snapshot. +func ValidateNotificationTokens(n *config.NotifyConfig) error { + if n == nil { + return nil + } + if cfg := n.Slack; cfg != nil { + if err := validateTokenRef("slack.app_token", cfg.AppToken); err != nil { + return err + } + if err := validateTokenRef("slack.bot_token", cfg.BotToken); err != nil { + return err + } + if err := validateTokenRef("slack.user_token", cfg.UserToken); err != nil { + return err + } + } + if cfg := n.Discord; cfg != nil { + if err := validateTokenRef("discord.bot_token", cfg.BotToken); err != nil { + return err + } + } + return nil +} + +func validateTokenRef(field, value string) error { + if value == "" || IsVaultRef(value) { + return nil + } + return fmt.Errorf("%s contains a plaintext token — use 'aileron secret set ' and reference it as 'vault:' instead", field) +} diff --git a/internal/comms/listeners_internal_test.go b/internal/comms/listeners_internal_test.go new file mode 100644 index 00000000..8f801028 --- /dev/null +++ b/internal/comms/listeners_internal_test.go @@ -0,0 +1,209 @@ +package comms + +import ( + "context" + "io" + "log/slog" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/ALRubinger/aileron/internal/audit" + "github.com/ALRubinger/aileron/internal/config" + "github.com/ALRubinger/aileron/internal/vault" +) + +// bridgeMessages contract: +// - Pushes incoming messages onto the queue with the autoDraft + +// priority maps applied. +// - Truncates Preview to 80 chars. +// - Writes a `message_received` audit entry when AuditStateDir is +// set; skips audit when empty. + +func TestBridgeMessages_PushesAndAppliesFlags(t *testing.T) { + q := NewNotifyQueue(10, nil) + msgs := make(chan IncomingMessage, 3) + + auto := map[string]bool{"#dev": true} + pri := map[string]string{"#alerts": "high"} + go bridgeMessages(msgs, q, auto, pri, "", nopLogger()) + + msgs <- IncomingMessage{ID: "1", Service: "slack", Channel: "#dev", Author: "alice", Body: "hi"} + msgs <- IncomingMessage{ID: "2", Service: "slack", Channel: "#alerts", Author: "bob", Body: "WAKE UP"} + msgs <- IncomingMessage{ID: "3", Service: "discord", Channel: "general", Author: "carol", Body: "elsewhere"} + close(msgs) + time.Sleep(50 * time.Millisecond) + + all := q.Messages() + if len(all) != 3 { + t.Fatalf("queue size = %d, want 3", len(all)) + } + if !all[0].AutoDraft { + t.Error("first message should have AutoDraft=true (#dev)") + } + if all[1].Priority != "high" { + t.Errorf("second message Priority = %q, want high (#alerts)", all[1].Priority) + } + if all[2].Priority != "normal" { + t.Errorf("third message default Priority = %q, want normal", all[2].Priority) + } +} + +func TestBridgeMessages_TruncatesPreview(t *testing.T) { + q := NewNotifyQueue(10, nil) + msgs := make(chan IncomingMessage, 1) + go bridgeMessages(msgs, q, nil, nil, "", nopLogger()) + + long := make([]byte, 200) + for i := range long { + long[i] = 'x' + } + msgs <- IncomingMessage{ID: "1", Body: string(long)} + close(msgs) + time.Sleep(50 * time.Millisecond) + + all := q.Messages() + if len(all) != 1 { + t.Fatal("queue empty") + } + if len(all[0].Preview) > 80 { + t.Errorf("preview length %d > 80", len(all[0].Preview)) + } + if all[0].Body != string(long) { + t.Error("full body should be preserved on the queue") + } +} + +func TestBridgeMessages_AuditWrite(t *testing.T) { + dir := t.TempDir() + q := NewNotifyQueue(10, nil) + msgs := make(chan IncomingMessage, 1) + go bridgeMessages(msgs, q, nil, nil, dir, nopLogger()) + + msgs <- IncomingMessage{ID: "1", Service: "slack", Channel: "#dev", Author: "alice", Body: "hi", Timestamp: time.Now()} + close(msgs) + time.Sleep(50 * time.Millisecond) + + entries, err := audit.ReadMessageEntries(filepath.Join(dir, "audit")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Event != "message_received" { + t.Errorf("audit entries = %+v", entries) + } +} + +// buildListeners contract: only builds a listener when both required +// tokens for the service are populated. Empty / partial config yields +// a zero-listener slice. + +func TestBuildListeners_SkipsIncompleteSlackConfig(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "slack-app", []byte("xapp"), vault.Metadata{}) + + listeners, _, _, err := buildListeners(context.Background(), StartOptions{ + Notifications: &config.NotifyConfig{ + Slack: &config.SlackNotifyConfig{AppToken: "vault:slack-app"}, // bot token missing + }, + Vault: v, + Queue: NewNotifyQueue(10, nil), + Log: nopLogger(), + }) + if err != nil { + t.Fatalf("buildListeners: %v", err) + } + if len(listeners) != 0 { + t.Errorf("expected 0 listeners for incomplete Slack config, got %d", len(listeners)) + } +} + +func TestBuildListeners_BuildsSlackListener(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "slack-app", []byte("xapp"), vault.Metadata{}) + _ = v.Put(context.Background(), "slack-bot", []byte("xoxb"), vault.Metadata{}) + + listeners, autoDraft, priority, err := buildListeners(context.Background(), StartOptions{ + Notifications: &config.NotifyConfig{ + Slack: &config.SlackNotifyConfig{ + AppToken: "vault:slack-app", + BotToken: "vault:slack-bot", + Channels: []config.ChannelConfig{ + {Name: "#dev", AutoDraft: true}, + {Name: "#alerts", Priority: "high"}, + }, + }, + }, + Vault: v, + Queue: NewNotifyQueue(10, nil), + Log: nopLogger(), + }) + if err != nil { + t.Fatalf("buildListeners: %v", err) + } + if len(listeners) != 1 { + t.Fatalf("expected 1 Slack listener, got %d", len(listeners)) + } + if listeners[0].Service() != "slack" { + t.Errorf("listener service = %q, want slack", listeners[0].Service()) + } + if !autoDraft["#dev"] { + t.Error("expected #dev in autoDraft map") + } + if priority["#alerts"] != "high" { + t.Errorf("priority[#alerts] = %q, want high", priority["#alerts"]) + } +} + +func TestBuildListeners_BuildsDiscordListener(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "discord-bot", []byte("token"), vault.Metadata{}) + + listeners, _, _, err := buildListeners(context.Background(), StartOptions{ + Notifications: &config.NotifyConfig{ + Discord: &config.DiscordNotifyConfig{ + BotToken: "vault:discord-bot", + Channels: []config.ChannelConfig{{Name: "general"}}, + }, + }, + Vault: v, + Queue: NewNotifyQueue(10, nil), + Log: nopLogger(), + }) + if err != nil { + t.Fatalf("buildListeners: %v", err) + } + if len(listeners) != 1 || listeners[0].Service() != "discord" { + t.Errorf("expected 1 Discord listener, got %+v", listeners) + } +} + +// Services contract: returns every registered service name. + +func TestListenerRegistry_Services(t *testing.T) { + r := NewListenerRegistry() + r.Set("slack", &fakeListener{service: "slack"}) + r.Set("discord", &fakeListener{service: "discord"}) + got := r.Services() + sort.Strings(got) + want := []string{"discord", "slack"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("Services = %v, want %v", got, want) + } +} + +// --- helpers --- + +func nopLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +type fakeListener struct { + service string +} + +func (f *fakeListener) Service() string { return f.service } +func (f *fakeListener) Connect(context.Context) error { return nil } +func (f *fakeListener) Listen(context.Context) (<-chan IncomingMessage, error) { return nil, nil } +func (f *fakeListener) Send(_ context.Context, _ OutgoingMessage) error { return nil } +func (f *fakeListener) Close() error { return nil } diff --git a/internal/comms/listeners_test.go b/internal/comms/listeners_test.go new file mode 100644 index 00000000..ae99873f --- /dev/null +++ b/internal/comms/listeners_test.go @@ -0,0 +1,222 @@ +package comms_test + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/config" + "github.com/ALRubinger/aileron/internal/vault" +) + +// ListenerRegistry contract: +// +// - Set / Get round-trip a listener by service name. +// - Len reflects the number of distinct services registered. +// - CloseAll empties the registry and closes every listener; per- +// listener Close errors are logged but don't abort the loop. +// - Concurrent Set + Get is safe. + +func TestListenerRegistry_SetGet(t *testing.T) { + r := comms.NewListenerRegistry() + l := &fakeListenerForReg{service: "slack"} + r.Set("slack", l) + got, ok := r.Get("slack") + if !ok || got != l { + t.Errorf("Get = (%v, %v), want fakeListener", got, ok) + } + if r.Len() != 1 { + t.Errorf("Len = %d, want 1", r.Len()) + } +} + +func TestListenerRegistry_GetMissing(t *testing.T) { + r := comms.NewListenerRegistry() + if _, ok := r.Get("discord"); ok { + t.Error("Get on missing service returned ok=true") + } +} + +func TestListenerRegistry_CloseAllClearsAndCloses(t *testing.T) { + r := comms.NewListenerRegistry() + a := &fakeListenerForReg{service: "slack"} + b := &fakeListenerForReg{service: "discord"} + r.Set("slack", a) + r.Set("discord", b) + + r.CloseAll(slog.New(slog.NewTextHandler(io.Discard, nil))) + + if r.Len() != 0 { + t.Errorf("Len after CloseAll = %d, want 0", r.Len()) + } + if !a.closed || !b.closed { + t.Error("listeners not closed") + } +} + +func TestListenerRegistry_CloseAllSurvivesPerListenerError(t *testing.T) { + r := comms.NewListenerRegistry() + bad := &fakeListenerForReg{service: "slack", closeErr: errors.New("boom")} + good := &fakeListenerForReg{service: "discord"} + r.Set("slack", bad) + r.Set("discord", good) + + r.CloseAll(slog.New(slog.NewTextHandler(io.Discard, nil))) + + if !good.closed { + t.Error("good listener not closed when bad listener returned an error") + } + if r.Len() != 0 { + t.Errorf("registry not cleared after CloseAll despite per-listener error") + } +} + +func TestListenerRegistry_ConcurrentSetGet(t *testing.T) { + r := comms.NewListenerRegistry() + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(2) + go func() { + defer wg.Done() + r.Set("svc", &fakeListenerForReg{service: "svc"}) + }() + go func() { + defer wg.Done() + _, _ = r.Get("svc") + }() + } + wg.Wait() +} + +// StartListeners contract: +// +// - Empty / nil notification config: returns 0, no error. +// - Plaintext token (caught earlier by ValidateNotificationTokens): +// not exercised here. +// - ValidateNotificationTokens rejects plaintext, accepts vault refs +// and empty values. + +func TestStartListeners_NoConfig(t *testing.T) { + r := comms.NewListenerRegistry() + q := comms.NewNotifyQueue(10, nil) + started, err := comms.StartListeners(context.Background(), comms.StartOptions{ + Notifications: nil, + Queue: q, + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + }, r) + if err != nil { + t.Fatalf("StartListeners: %v", err) + } + if started != 0 { + t.Errorf("started = %d, want 0", started) + } +} + +func TestStartListeners_RequiresQueue(t *testing.T) { + _, err := comms.StartListeners(context.Background(), comms.StartOptions{ + Notifications: &config.NotifyConfig{Slack: &config.SlackNotifyConfig{AppToken: "vault:a", BotToken: "vault:b"}}, + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + }, comms.NewListenerRegistry()) + if err == nil { + t.Fatal("expected error for nil queue") + } +} + +func TestStartListeners_VaultMissingForRef(t *testing.T) { + // Notifications reference vault:foo but no vault is supplied → + // ResolveVaultRef errors and StartListeners surfaces it. + _, err := comms.StartListeners(context.Background(), comms.StartOptions{ + Notifications: &config.NotifyConfig{ + Slack: &config.SlackNotifyConfig{AppToken: "vault:a", BotToken: "vault:b"}, + }, + Vault: nil, + Queue: comms.NewNotifyQueue(10, nil), + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + }, comms.NewListenerRegistry()) + if err == nil { + t.Fatal("expected error when vault refs but no vault") + } +} + +func TestValidateNotificationTokens(t *testing.T) { + cases := []struct { + name string + cfg *config.NotifyConfig + wantErr bool + }{ + {"nil config", nil, false}, + {"empty tokens", &config.NotifyConfig{Slack: &config.SlackNotifyConfig{}}, false}, + {"vault refs OK", &config.NotifyConfig{ + Slack: &config.SlackNotifyConfig{AppToken: "vault:a", BotToken: "vault:b", UserToken: "vault:c"}, + }, false}, + {"plaintext slack app rejected", &config.NotifyConfig{ + Slack: &config.SlackNotifyConfig{AppToken: "xapp-plain", BotToken: "vault:b"}, + }, true}, + {"plaintext discord bot rejected", &config.NotifyConfig{ + Discord: &config.DiscordNotifyConfig{BotToken: "plain"}, + }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := comms.ValidateNotificationTokens(tc.cfg) + if (err != nil) != tc.wantErr { + t.Errorf("err = %v, wantErr = %v", err, tc.wantErr) + } + }) + } +} + +func TestIsVaultRef(t *testing.T) { + if !comms.IsVaultRef("vault:foo") { + t.Error("IsVaultRef(vault:foo) should be true") + } + if comms.IsVaultRef("plaintext") { + t.Error("IsVaultRef(plaintext) should be false") + } + if comms.IsVaultRef("") { + t.Error("IsVaultRef(empty) should be false") + } +} + +func TestResolveVaultRef_Passthrough(t *testing.T) { + got, err := comms.ResolveVaultRef(context.Background(), "plaintext", nil) + if err != nil { + t.Fatalf("ResolveVaultRef on plaintext: %v", err) + } + if got != "plaintext" { + t.Errorf("got %q, want plaintext", got) + } +} + +func TestResolveVaultRef_LooksUpInVault(t *testing.T) { + v := vault.NewMemVault() + _ = v.Put(context.Background(), "slack-app-token", []byte("xapp-resolved"), vault.Metadata{}) + got, err := comms.ResolveVaultRef(context.Background(), "vault:slack-app-token", v) + if err != nil { + t.Fatalf("ResolveVaultRef: %v", err) + } + if got != "xapp-resolved" { + t.Errorf("got %q, want xapp-resolved", got) + } +} + +// --- helpers --- + +type fakeListenerForReg struct { + service string + closed bool + closeErr error +} + +func (f *fakeListenerForReg) Service() string { return f.service } +func (f *fakeListenerForReg) Connect(context.Context) error { return nil } +func (f *fakeListenerForReg) Listen(context.Context) (<-chan comms.IncomingMessage, error) { return nil, nil } +func (f *fakeListenerForReg) Send(_ context.Context, _ comms.OutgoingMessage) error { return nil } +func (f *fakeListenerForReg) Close() error { + f.closed = true + return f.closeErr +} diff --git a/internal/launch/notifyqueue.go b/internal/comms/notifyqueue.go similarity index 96% rename from internal/launch/notifyqueue.go rename to internal/comms/notifyqueue.go index e6cb564d..446f78ee 100644 --- a/internal/launch/notifyqueue.go +++ b/internal/comms/notifyqueue.go @@ -1,10 +1,10 @@ -package launch +package comms import ( "sync" "time" - launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" + "github.com/ALRubinger/aileron/internal/config" ) // Message represents an incoming notification from a comms channel. @@ -39,7 +39,7 @@ type NotifyQueue struct { maxSize int onChange func() onAutoDraft func(Message) - quietHours *launchpolicy.QuietHoursConfig + quietHours *config.QuietHoursConfig nowFunc func() time.Time // injectable clock for testing; defaults to time.Now } @@ -202,7 +202,7 @@ func (q *NotifyQueue) SetDraft(targetID, draftText string) bool { } // ApproveDraft sets the draft to the approved sentinel so the -// commsserver knows to send the message as-is. +// dispatcher knows to send the message as-is. func (q *NotifyQueue) ApproveDraft(targetID string) { q.mu.Lock() for i := range q.messages { @@ -267,7 +267,7 @@ func (q *NotifyQueue) MarkAllRead() { } // SetQuietHours configures the quiet hours window. Pass nil to disable. -func (q *NotifyQueue) SetQuietHours(cfg *launchpolicy.QuietHoursConfig) { +func (q *NotifyQueue) SetQuietHours(cfg *config.QuietHoursConfig) { q.mu.Lock() defer q.mu.Unlock() q.quietHours = cfg @@ -302,7 +302,7 @@ func (q *NotifyQueue) isQuiet() bool { // IsQuietHours determines whether the given time (from nowFunc, or // time.Now if nil) falls within the quiet hours window defined by cfg. // Returns false if cfg is nil or the start/end times are unparseable. -func IsQuietHours(cfg *launchpolicy.QuietHoursConfig, nowFunc func() time.Time) bool { +func IsQuietHours(cfg *config.QuietHoursConfig, nowFunc func() time.Time) bool { if cfg == nil { return false } diff --git a/internal/launch/notifyqueue_test.go b/internal/comms/notifyqueue_test.go similarity index 70% rename from internal/launch/notifyqueue_test.go rename to internal/comms/notifyqueue_test.go index 678b7ac8..16d706de 100644 --- a/internal/launch/notifyqueue_test.go +++ b/internal/comms/notifyqueue_test.go @@ -1,4 +1,4 @@ -package launch_test +package comms_test import ( "sync" @@ -6,14 +6,14 @@ import ( "testing" "time" - "github.com/ALRubinger/aileron/internal/launch" - launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/config" ) func TestNotifyQueue_PushAndMessages(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Author: "Alice", Preview: "hello"}) - q.Push(launch.Message{ID: "2", Author: "Bob", Preview: "world"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Author: "Alice", Preview: "hello"}) + q.Push(comms.Message{ID: "2", Author: "Bob", Preview: "world"}) msgs := q.Messages() if len(msgs) != 2 { @@ -28,9 +28,9 @@ func TestNotifyQueue_PushAndMessages(t *testing.T) { } func TestNotifyQueue_Overflow(t *testing.T) { - q := launch.NewNotifyQueue(3, nil) + q := comms.NewNotifyQueue(3, nil) for i := 0; i < 5; i++ { - q.Push(launch.Message{ID: string(rune('a' + i)), Preview: string(rune('a' + i))}) + q.Push(comms.Message{ID: string(rune('a' + i)), Preview: string(rune('a' + i))}) } msgs := q.Messages() @@ -47,22 +47,22 @@ func TestNotifyQueue_Overflow(t *testing.T) { } func TestNotifyQueue_Len(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) if q.Len() != 0 { t.Errorf("expected 0, got %d", q.Len()) } - q.Push(launch.Message{ID: "1"}) - q.Push(launch.Message{ID: "2"}) + q.Push(comms.Message{ID: "1"}) + q.Push(comms.Message{ID: "2"}) if q.Len() != 2 { t.Errorf("expected 2, got %d", q.Len()) } } func TestNotifyQueue_UnreadCount(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) - q.Push(launch.Message{ID: "2"}) - q.Push(launch.Message{ID: "3", Read: true}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) + q.Push(comms.Message{ID: "2"}) + q.Push(comms.Message{ID: "3", Read: true}) if got := q.UnreadCount(); got != 2 { t.Errorf("UnreadCount = %d, want 2", got) @@ -70,15 +70,15 @@ func TestNotifyQueue_UnreadCount(t *testing.T) { } func TestNotifyQueue_Latest(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) _, ok := q.Latest() if ok { t.Error("expected false for empty queue") } - q.Push(launch.Message{ID: "1", Preview: "first"}) - q.Push(launch.Message{ID: "2", Preview: "second"}) + q.Push(comms.Message{ID: "1", Preview: "first"}) + q.Push(comms.Message{ID: "2", Preview: "second"}) msg, ok := q.Latest() if !ok { @@ -90,9 +90,9 @@ func TestNotifyQueue_Latest(t *testing.T) { } func TestNotifyQueue_MarkRead(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) - q.Push(launch.Message{ID: "2"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) + q.Push(comms.Message{ID: "2"}) q.MarkRead("1") @@ -110,10 +110,10 @@ func TestNotifyQueue_MarkRead(t *testing.T) { } func TestNotifyQueue_MarkAllRead(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) - q.Push(launch.Message{ID: "2"}) - q.Push(launch.Message{ID: "3"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) + q.Push(comms.Message{ID: "2"}) + q.Push(comms.Message{ID: "3"}) q.MarkAllRead() @@ -123,8 +123,8 @@ func TestNotifyQueue_MarkAllRead(t *testing.T) { } func TestNotifyQueue_MarkReadNonexistent(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) // Should not panic or change anything. q.MarkRead("nonexistent") @@ -136,11 +136,11 @@ func TestNotifyQueue_MarkReadNonexistent(t *testing.T) { func TestNotifyQueue_OnChange(t *testing.T) { var count atomic.Int32 - q := launch.NewNotifyQueue(10, func() { + q := comms.NewNotifyQueue(10, func() { count.Add(1) }) - q.Push(launch.Message{ID: "1"}) + q.Push(comms.Message{ID: "1"}) q.MarkRead("1") q.MarkAllRead() @@ -150,8 +150,8 @@ func TestNotifyQueue_OnChange(t *testing.T) { } func TestNotifyQueue_MessagesReturnsSnapshot(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Preview: "original"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Preview: "original"}) msgs := q.Messages() msgs[0].Preview = "mutated" @@ -164,15 +164,15 @@ func TestNotifyQueue_MessagesReturnsSnapshot(t *testing.T) { } func TestNotifyQueue_AutoDraftCallbackFires(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) var got []string - q.SetOnAutoDraft(func(msg launch.Message) { + q.SetOnAutoDraft(func(msg comms.Message) { got = append(got, msg.ID) }) - q.Push(launch.Message{ID: "1", AutoDraft: true}) - q.Push(launch.Message{ID: "2", AutoDraft: false}) - q.Push(launch.Message{ID: "3", AutoDraft: true}) + q.Push(comms.Message{ID: "1", AutoDraft: true}) + q.Push(comms.Message{ID: "2", AutoDraft: false}) + q.Push(comms.Message{ID: "3", AutoDraft: true}) if len(got) != 2 { t.Fatalf("expected onAutoDraft called 2 times, got %d", len(got)) @@ -183,15 +183,15 @@ func TestNotifyQueue_AutoDraftCallbackFires(t *testing.T) { } func TestNotifyQueue_AutoDraftNilCallback(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) // Should not panic when onAutoDraft is nil. - q.Push(launch.Message{ID: "1", AutoDraft: true}) + q.Push(comms.Message{ID: "1", AutoDraft: true}) } func TestNotifyQueue_AutoDraftRoundtrip(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", AutoDraft: true}) - q.Push(launch.Message{ID: "2", AutoDraft: false}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", AutoDraft: true}) + q.Push(comms.Message{ID: "2", AutoDraft: false}) msgs := q.Messages() if !msgs[0].AutoDraft { @@ -203,9 +203,9 @@ func TestNotifyQueue_AutoDraftRoundtrip(t *testing.T) { } func TestNotifyQueue_FindByID(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Author: "Alice"}) - q.Push(launch.Message{ID: "2", Author: "Bob"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Author: "Alice"}) + q.Push(comms.Message{ID: "2", Author: "Bob"}) msg, found := q.FindByID("2") if !found { @@ -222,8 +222,8 @@ func TestNotifyQueue_FindByID(t *testing.T) { } func TestNotifyQueue_SetDraft(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Author: "Alice", Read: true}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Author: "Alice", Read: true}) ok := q.SetDraft("1", "Here is my draft reply") if !ok { @@ -243,7 +243,7 @@ func TestNotifyQueue_SetDraft(t *testing.T) { } func TestNotifyQueue_SetDraft_Nonexistent(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) ok := q.SetDraft("nonexistent", "draft") if ok { t.Error("SetDraft should return false for nonexistent message") @@ -252,8 +252,8 @@ func TestNotifyQueue_SetDraft_Nonexistent(t *testing.T) { func TestNotifyQueue_SetDraft_OnChangeCallback(t *testing.T) { var called int - q := launch.NewNotifyQueue(10, func() { called++ }) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, func() { called++ }) + q.Push(comms.Message{ID: "1"}) called = 0 // reset after Push q.SetDraft("1", "draft") @@ -270,8 +270,8 @@ func TestNotifyQueue_SetDraft_OnChangeCallback(t *testing.T) { } func TestNotifyQueue_ApproveDraft(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) q.SetDraft("1", "my draft") q.ApproveDraft("1") @@ -283,8 +283,8 @@ func TestNotifyQueue_ApproveDraft(t *testing.T) { } func TestNotifyQueue_ClearDraft(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) q.SetDraft("1", "my draft") q.ClearDraft("1") @@ -299,10 +299,10 @@ func TestNotifyQueue_ClearDraft(t *testing.T) { } func TestNotifyQueue_RecentByChannel(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) - q.Push(launch.Message{ID: "2", Source: "slack", Channel: "#frontend", AutoDraft: true, Timestamp: time.Now()}) - q.Push(launch.Message{ID: "3", Source: "discord", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) + q.Push(comms.Message{ID: "2", Source: "slack", Channel: "#frontend", AutoDraft: true, Timestamp: time.Now()}) + q.Push(comms.Message{ID: "3", Source: "discord", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) msg, found := q.RecentByChannel("slack", "#backend", 5*time.Minute) if !found { @@ -313,8 +313,8 @@ func TestNotifyQueue_RecentByChannel(t *testing.T) { } // Non-auto-draft message should not match. - q2 := launch.NewNotifyQueue(10, nil) - q2.Push(launch.Message{ID: "4", Source: "slack", Channel: "#backend", AutoDraft: false, Timestamp: time.Now()}) + q2 := comms.NewNotifyQueue(10, nil) + q2.Push(comms.Message{ID: "4", Source: "slack", Channel: "#backend", AutoDraft: false, Timestamp: time.Now()}) _, found = q2.RecentByChannel("slack", "#backend", 5*time.Minute) if found { t.Error("should not match non-auto-draft message") @@ -322,8 +322,8 @@ func TestNotifyQueue_RecentByChannel(t *testing.T) { } func TestNotifyQueue_RecentByChannel_Expired(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ ID: "1", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now().Add(-10 * time.Minute), }) @@ -335,9 +335,9 @@ func TestNotifyQueue_RecentByChannel_Expired(t *testing.T) { } func TestNotifyQueue_RecentByChannel_ReturnsNewest(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "old", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now().Add(-1 * time.Minute)}) - q.Push(launch.Message{ID: "new", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "old", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now().Add(-1 * time.Minute)}) + q.Push(comms.Message{ID: "new", Source: "slack", Channel: "#backend", AutoDraft: true, Timestamp: time.Now()}) msg, found := q.RecentByChannel("slack", "#backend", 5*time.Minute) if !found { @@ -349,8 +349,8 @@ func TestNotifyQueue_RecentByChannel_ReturnsNewest(t *testing.T) { } func TestNotifyQueue_DraftFieldsRoundtrip(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Draft: "pre-set draft", DraftFor: "orig-1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Draft: "pre-set draft", DraftFor: "orig-1"}) msgs := q.Messages() if msgs[0].Draft != "pre-set draft" { @@ -363,8 +363,8 @@ func TestNotifyQueue_DraftFieldsRoundtrip(t *testing.T) { func TestNotifyQueue_ApproveDraft_Nonexistent(t *testing.T) { var called int - q := launch.NewNotifyQueue(10, func() { called++ }) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, func() { called++ }) + q.Push(comms.Message{ID: "1"}) called = 0 // ApproveDraft on a nonexistent ID should still call onChange @@ -383,8 +383,8 @@ func TestNotifyQueue_ApproveDraft_Nonexistent(t *testing.T) { func TestNotifyQueue_ClearDraft_Nonexistent(t *testing.T) { var called int - q := launch.NewNotifyQueue(10, func() { called++ }) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, func() { called++ }) + q.Push(comms.Message{ID: "1"}) q.SetDraft("1", "my draft") called = 0 @@ -402,8 +402,8 @@ func TestNotifyQueue_ClearDraft_Nonexistent(t *testing.T) { } func TestNotifyQueue_ApproveDraft_NilOnChange(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) q.SetDraft("1", "draft") // Should not panic with nil onChange. @@ -415,8 +415,8 @@ func TestNotifyQueue_ApproveDraft_NilOnChange(t *testing.T) { } func TestNotifyQueue_ClearDraft_NilOnChange(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1"}) q.SetDraft("1", "draft") // Should not panic with nil onChange. @@ -429,10 +429,10 @@ func TestNotifyQueue_ClearDraft_NilOnChange(t *testing.T) { func TestNotifyQueue_QuietHoursSuppressesOnChange(t *testing.T) { var count atomic.Int32 - q := launch.NewNotifyQueue(10, func() { + q := comms.NewNotifyQueue(10, func() { count.Add(1) }) - q.SetQuietHours(&launchpolicy.QuietHoursConfig{ + q.SetQuietHours(&config.QuietHoursConfig{ Start: "20:00", End: "08:00", }) @@ -441,7 +441,7 @@ func TestNotifyQueue_QuietHoursSuppressesOnChange(t *testing.T) { return time.Date(2026, 1, 15, 23, 0, 0, 0, time.Local) }) - q.Push(launch.Message{ID: "1", Priority: "normal"}) + q.Push(comms.Message{ID: "1", Priority: "normal"}) if count.Load() != 0 { t.Errorf("onChange should be suppressed during quiet hours, called %d times", count.Load()) @@ -454,10 +454,10 @@ func TestNotifyQueue_QuietHoursSuppressesOnChange(t *testing.T) { func TestNotifyQueue_QuietHoursHighPriorityBypasses(t *testing.T) { var count atomic.Int32 - q := launch.NewNotifyQueue(10, func() { + q := comms.NewNotifyQueue(10, func() { count.Add(1) }) - q.SetQuietHours(&launchpolicy.QuietHoursConfig{ + q.SetQuietHours(&config.QuietHoursConfig{ Start: "20:00", End: "08:00", }) @@ -465,7 +465,7 @@ func TestNotifyQueue_QuietHoursHighPriorityBypasses(t *testing.T) { return time.Date(2026, 1, 15, 23, 0, 0, 0, time.Local) }) - q.Push(launch.Message{ID: "1", Priority: "high"}) + q.Push(comms.Message{ID: "1", Priority: "high"}) if count.Load() != 1 { t.Errorf("onChange should fire for high-priority during quiet hours, called %d times", count.Load()) @@ -474,10 +474,10 @@ func TestNotifyQueue_QuietHoursHighPriorityBypasses(t *testing.T) { func TestNotifyQueue_OutsideQuietHoursNotSuppressed(t *testing.T) { var count atomic.Int32 - q := launch.NewNotifyQueue(10, func() { + q := comms.NewNotifyQueue(10, func() { count.Add(1) }) - q.SetQuietHours(&launchpolicy.QuietHoursConfig{ + q.SetQuietHours(&config.QuietHoursConfig{ Start: "22:00", End: "06:00", }) @@ -486,7 +486,7 @@ func TestNotifyQueue_OutsideQuietHoursNotSuppressed(t *testing.T) { return time.Date(2026, 1, 15, 12, 0, 0, 0, time.Local) }) - q.Push(launch.Message{ID: "1", Priority: "normal"}) + q.Push(comms.Message{ID: "1", Priority: "normal"}) if count.Load() != 1 { t.Errorf("onChange should fire outside quiet hours, called %d times", count.Load()) @@ -495,11 +495,11 @@ func TestNotifyQueue_OutsideQuietHoursNotSuppressed(t *testing.T) { func TestNotifyQueue_NoQuietHoursNotSuppressed(t *testing.T) { var count atomic.Int32 - q := launch.NewNotifyQueue(10, func() { + q := comms.NewNotifyQueue(10, func() { count.Add(1) }) // No quiet hours configured. - q.Push(launch.Message{ID: "1", Priority: "normal"}) + q.Push(comms.Message{ID: "1", Priority: "normal"}) if count.Load() != 1 { t.Errorf("onChange should fire when no quiet hours configured, called %d times", count.Load()) @@ -507,8 +507,8 @@ func TestNotifyQueue_NoQuietHoursNotSuppressed(t *testing.T) { } func TestNotifyQueue_IsQuietHours(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.SetQuietHours(&launchpolicy.QuietHoursConfig{ + q := comms.NewNotifyQueue(10, nil) + q.SetQuietHours(&config.QuietHoursConfig{ Start: "22:00", End: "06:00", }) @@ -531,11 +531,11 @@ func TestNotifyQueue_IsQuietHours(t *testing.T) { } func TestNotifyQueue_HighPriorityUnreadCount(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) - q.Push(launch.Message{ID: "1", Priority: "normal"}) - q.Push(launch.Message{ID: "2", Priority: "high"}) - q.Push(launch.Message{ID: "3", Priority: "high", Read: true}) - q.Push(launch.Message{ID: "4", Priority: "high"}) + q := comms.NewNotifyQueue(10, nil) + q.Push(comms.Message{ID: "1", Priority: "normal"}) + q.Push(comms.Message{ID: "2", Priority: "high"}) + q.Push(comms.Message{ID: "3", Priority: "high", Read: true}) + q.Push(comms.Message{ID: "4", Priority: "high"}) if got := q.HighPriorityUnreadCount(); got != 2 { t.Errorf("HighPriorityUnreadCount = %d, want 2", got) @@ -543,7 +543,7 @@ func TestNotifyQueue_HighPriorityUnreadCount(t *testing.T) { } func TestNotifyQueue_ConcurrentAccess(t *testing.T) { - q := launch.NewNotifyQueue(100, nil) + q := comms.NewNotifyQueue(100, nil) var wg sync.WaitGroup // 10 writers, 10 readers. @@ -552,7 +552,7 @@ func TestNotifyQueue_ConcurrentAccess(t *testing.T) { go func(n int) { defer wg.Done() for j := 0; j < 100; j++ { - q.Push(launch.Message{ + q.Push(comms.Message{ ID: string(rune(n*100 + j)), Timestamp: time.Now(), }) @@ -576,14 +576,14 @@ func TestNotifyQueue_ConcurrentAccess(t *testing.T) { } func TestNotifyQueue_DraftPendingCount(t *testing.T) { - q := launch.NewNotifyQueue(10, nil) + q := comms.NewNotifyQueue(10, nil) // Auto-draft without a draft yet — counts as pending. - q.Push(launch.Message{ID: "1", AutoDraft: true}) + q.Push(comms.Message{ID: "1", AutoDraft: true}) // Non-auto-draft — not counted. - q.Push(launch.Message{ID: "2", AutoDraft: false}) + q.Push(comms.Message{ID: "2", AutoDraft: false}) // Auto-draft with a draft already — not pending. - q.Push(launch.Message{ID: "3", AutoDraft: true, Draft: "reply", DraftFor: "3"}) + q.Push(comms.Message{ID: "3", AutoDraft: true, Draft: "reply", DraftFor: "3"}) if got := q.DraftPendingCount(); got != 1 { t.Errorf("DraftPendingCount = %d, want 1", got) diff --git a/internal/launch/quiethours_test.go b/internal/comms/quiethours_test.go similarity index 81% rename from internal/launch/quiethours_test.go rename to internal/comms/quiethours_test.go index c59dc955..983c57b8 100644 --- a/internal/launch/quiethours_test.go +++ b/internal/comms/quiethours_test.go @@ -1,21 +1,21 @@ -package launch_test +package comms_test import ( "testing" "time" - "github.com/ALRubinger/aileron/internal/launch" - launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/config" ) func TestIsQuietHours_NilConfig(t *testing.T) { - if launch.IsQuietHours(nil, nil) { + if comms.IsQuietHours(nil, nil) { t.Error("expected false for nil config") } } func TestIsQuietHours_OvernightWindow(t *testing.T) { - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: "22:00", End: "06:00", } @@ -40,7 +40,7 @@ func TestIsQuietHours_OvernightWindow(t *testing.T) { now := func() time.Time { return time.Date(2026, 1, 15, tt.hour, tt.min, 0, 0, time.Local) } - if got := launch.IsQuietHours(cfg, now); got != tt.want { + if got := comms.IsQuietHours(cfg, now); got != tt.want { t.Errorf("IsQuietHours at %02d:%02d = %v, want %v", tt.hour, tt.min, got, tt.want) } }) @@ -48,7 +48,7 @@ func TestIsQuietHours_OvernightWindow(t *testing.T) { } func TestIsQuietHours_SameDayWindow(t *testing.T) { - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: "09:00", End: "17:00", } @@ -72,7 +72,7 @@ func TestIsQuietHours_SameDayWindow(t *testing.T) { now := func() time.Time { return time.Date(2026, 1, 15, tt.hour, tt.min, 0, 0, time.Local) } - if got := launch.IsQuietHours(cfg, now); got != tt.want { + if got := comms.IsQuietHours(cfg, now); got != tt.want { t.Errorf("IsQuietHours at %02d:%02d = %v, want %v", tt.hour, tt.min, got, tt.want) } }) @@ -80,7 +80,7 @@ func TestIsQuietHours_SameDayWindow(t *testing.T) { } func TestIsQuietHours_WithTimezone(t *testing.T) { - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: "22:00", End: "06:00", Timezone: "America/New_York", @@ -91,7 +91,7 @@ func TestIsQuietHours_WithTimezone(t *testing.T) { now := func() time.Time { return time.Date(2026, 1, 15, 23, 0, 0, 0, ny) } - if !launch.IsQuietHours(cfg, now) { + if !comms.IsQuietHours(cfg, now) { t.Error("expected quiet at 23:00 America/New_York") } @@ -99,13 +99,13 @@ func TestIsQuietHours_WithTimezone(t *testing.T) { now = func() time.Time { return time.Date(2026, 1, 15, 12, 0, 0, 0, ny) } - if launch.IsQuietHours(cfg, now) { + if comms.IsQuietHours(cfg, now) { t.Error("expected not quiet at 12:00 America/New_York") } } func TestIsQuietHours_InvalidTimezone(t *testing.T) { - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: "22:00", End: "06:00", Timezone: "Invalid/Zone", @@ -113,7 +113,7 @@ func TestIsQuietHours_InvalidTimezone(t *testing.T) { now := func() time.Time { return time.Date(2026, 1, 15, 23, 0, 0, 0, time.UTC) } - if launch.IsQuietHours(cfg, now) { + if comms.IsQuietHours(cfg, now) { t.Error("expected false for invalid timezone") } } @@ -122,11 +122,11 @@ func TestIsQuietHours_NilNowFunc(t *testing.T) { // When nowFunc is nil, IsQuietHours should use time.Now and not panic. // We use a wide window (00:00–23:59) to guarantee the result is true // regardless of when the test runs. - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: "00:00", End: "23:59", } - if !launch.IsQuietHours(cfg, nil) { + if !comms.IsQuietHours(cfg, nil) { t.Error("expected true for all-day window with nil nowFunc") } } @@ -144,14 +144,14 @@ func TestIsQuietHours_InvalidTimeFormat(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := &launchpolicy.QuietHoursConfig{ + cfg := &config.QuietHoursConfig{ Start: tt.start, End: tt.end, } now := func() time.Time { return time.Date(2026, 1, 15, 23, 0, 0, 0, time.Local) } - if launch.IsQuietHours(cfg, now) { + if comms.IsQuietHours(cfg, now) { t.Error("expected false for invalid time format") } }) diff --git a/internal/launch/commsserver.go b/internal/launch/commsserver.go deleted file mode 100644 index 63b4c80a..00000000 --- a/internal/launch/commsserver.go +++ /dev/null @@ -1,533 +0,0 @@ -package launch - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/ALRubinger/aileron/internal/approval" - "github.com/ALRubinger/aileron/internal/audit" - "github.com/ALRubinger/aileron/internal/comms" - launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" - "github.com/ALRubinger/aileron/internal/vault" -) - -// CommsRequest is sent by aileron-mcp to read or send messages. -// Wire shape preserved across the pty-removal in #419 — aileron-mcp's -// tool definitions don't change. The behaviors of `send_message`, -// `draft_reply`, and `http_request` regressed from "in-pty user -// approval prompt" to "deny pending webapp wire-through" (tracked -// as a #419 follow-up); the protocol itself is unchanged. -type CommsRequest struct { - Method string `json:"method"` // "read_messages", "send_message", "draft_reply", or "http_request" - Service string `json:"service,omitempty"` - Channel string `json:"channel,omitempty"` - Body string `json:"body,omitempty"` - ReplyTo string `json:"reply_to,omitempty"` -} - -// CommsResponse is returned to aileron-mcp. -type CommsResponse struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Messages []CommsMessageDTO `json:"messages,omitempty"` -} - -// CommsMessageDTO is the wire format for a message. -type CommsMessageDTO struct { - ID string `json:"id"` - Service string `json:"service"` - Channel string `json:"channel"` - Author string `json:"author"` - Body string `json:"body"` - Timestamp string `json:"timestamp"` - DraftRequest bool `json:"draft_request,omitempty"` -} - -// CommsServer handles IPC requests from aileron-mcp. -// -// `read_messages` is a pure queue read. `send_message`, `draft_reply`, -// and `http_request` register an entry on the shared -// [approval.ActionApprovalQueue] so the webapp's `/approvals` page -// surfaces the request and the user decides; on Approve the server -// dispatches the actual send / HTTP call, on Deny it returns an -// agent-facing error. The shape replaces the pre-#419 in-pty overlay -// with the (#418) webapp surface (#428). -// -// Approvals is nil only in legacy callers that haven't been updated -// to the queue-aware constructor; in that fallback the send-shaped -// methods fail-closed with a clear regression message so the agent -// learns to route around them. -type CommsServer struct { - socketPath string - listener net.Listener - queue *NotifyQueue - senders map[string]comms.Listener - auditStateDir string - sessionID string - secrets launchpolicy.SecretsConfig - vault vault.Vault - httpClient *http.Client - approvals *approval.ActionApprovalQueue - approvalTTL time.Duration - - mu sync.Mutex - done bool -} - -// NewCommsServer creates a comms IPC server. Run in a goroutine via Serve. -// -// approvals is the shared action-approval queue the daemon also -// exposes via `/v1/action-approvals`. Send-shaped tool calls -// (`send_message`, `draft_reply`, `http_request`) register kind- -// specific entries here and wait for the user to decide via the -// webapp. Pass nil for the legacy fail-closed behaviour preserved -// for callers that don't yet route through the queue. -func NewCommsServer(socketPath string, queue *NotifyQueue, senders []comms.Listener, auditStateDir, sessionID string, approvals *approval.ActionApprovalQueue) (*CommsServer, error) { - os.Remove(socketPath) - - ln, err := net.Listen("unix", socketPath) - if err != nil { - return nil, fmt.Errorf("comms socket: %w", err) - } - - senderMap := make(map[string]comms.Listener, len(senders)) - for _, s := range senders { - senderMap[s.Service()] = s - } - - return &CommsServer{ - socketPath: socketPath, - listener: ln, - queue: queue, - senders: senderMap, - auditStateDir: auditStateDir, - sessionID: sessionID, - approvals: approvals, - approvalTTL: defaultCommsApprovalTimeout, - }, nil -} - -// defaultCommsApprovalTimeout is how long a send-shaped CommsServer -// method holds the IPC response open waiting for the user to decide -// in the webapp. Matches the apiServer's RunAction timeout (5 -// minutes) so all kinds of pending approvals share the same upper -// bound — predictable for the user and bounded for upstream MCP / -// HTTP timeouts. -const defaultCommsApprovalTimeout = 5 * time.Minute - -// Serve accepts connections. Blocks until Close. Run in a goroutine. -func (cs *CommsServer) Serve() { - for { - conn, err := cs.listener.Accept() - if err != nil { - cs.mu.Lock() - done := cs.done - cs.mu.Unlock() - if done { - return - } - continue - } - go cs.handleConn(conn) - } -} - -func (cs *CommsServer) handleConn(conn net.Conn) { - defer conn.Close() - - var req CommsRequest - if err := json.NewDecoder(conn).Decode(&req); err != nil { - json.NewEncoder(conn).Encode(CommsResponse{Error: "invalid request"}) - return - } - - var resp CommsResponse - switch req.Method { - case "read_messages": - resp = cs.readMessages(req) - case "send_message": - resp = cs.sendMessage(req) - case "draft_reply": - resp = cs.draftReply(req) - case "http_request": - resp = cs.httpRequest(req) - default: - resp = CommsResponse{Error: "unknown method: " + req.Method} - } - - json.NewEncoder(conn).Encode(resp) -} - -// readMessages returns messages currently in the notification queue. -// Filters by service and channel when provided. Marks messages read -// after surfacing them so the agent doesn't see the same message -// twice across consecutive calls. -func (cs *CommsServer) readMessages(req CommsRequest) CommsResponse { - msgs := cs.queue.Messages() - var dtos []CommsMessageDTO - for _, m := range msgs { - if req.Service != "" && m.Source != req.Service { - continue - } - if req.Channel != "" && m.Channel != req.Channel { - continue - } - dto := CommsMessageDTO{ - ID: m.ID, - Service: m.Source, - Channel: m.Channel, - Author: m.Author, - Body: m.Body, - Timestamp: m.Timestamp.Format(time.RFC3339), - } - if m.AutoDraft && m.Draft == "" { - dto.DraftRequest = true - } - dtos = append(dtos, dto) - } - cs.queue.MarkAllRead() - return CommsResponse{OK: true, Messages: dtos} -} - -// sendMessage registers a [approval.ApprovalKindCommsSend] entry on -// the shared action-approval queue, blocks until the user decides -// via the webapp, and on Approve dispatches the message via the -// matched [comms.Listener]. The IPC response holds open for up to -// [CommsServer.approvalTTL]; on Deny / Timeout / context cancel the -// agent receives a structured error (#428). -func (cs *CommsServer) sendMessage(req CommsRequest) CommsResponse { - if req.Service == "" || req.Channel == "" || req.Body == "" { - return CommsResponse{Error: "service, channel, and body are required"} - } - sender, ok := cs.senders[req.Service] - if !ok { - return CommsResponse{Error: "no listener for service: " + req.Service} - } - if cs.approvals == nil { - cs.logMessage("message_denied_no_surface", req.Service, req.Channel, "", req.Body, "") - return CommsResponse{Error: errSendApprovalUnavailable} - } - - entry := cs.approvals.RegisterCommsSend(req.Service, req.Channel, req.Body, cs.sessionID) - decision, err := entry.Wait(context.Background(), cs.approvalTTL) - if errors.Is(err, approval.ErrActionApprovalTimeout) { - cs.logMessage("message_denied_timeout", req.Service, req.Channel, "", req.Body, "") - return CommsResponse{Error: "send_message: user did not respond before timeout"} - } - if err != nil { - cs.logMessage("message_denied_error", req.Service, req.Channel, "", req.Body, "") - return CommsResponse{Error: "send_message: " + err.Error()} - } - if !decision.Approved { - cs.logMessage("message_denied", req.Service, req.Channel, "", req.Body, "") - msg := "send_message: user denied" - if decision.Reason != "" { - msg += ": " + decision.Reason - } - return CommsResponse{Error: msg} - } - - if sendErr := sender.Send(context.Background(), comms.OutgoingMessage{ - Channel: req.Channel, - Body: req.Body, - }); sendErr != nil { - cs.logMessage("message_send_failed", req.Service, req.Channel, "", req.Body, "") - return CommsResponse{Error: "send_message: dispatch failed: " + sendErr.Error()} - } - cs.logMessage("message_sent", req.Service, req.Channel, "", req.Body, "") - return CommsResponse{OK: true} -} - -// draftReply registers a [approval.ApprovalKindCommsDraft] entry — -// the user reviews the original incoming message alongside the -// proposed reply, optionally edits the reply body, and approves or -// discards. On approve the (possibly edited) body is dispatched -// through the listener for the same service/channel as the original -// message; on discard the agent receives a structured error. -// -// The original message is looked up in the [NotifyQueue] by -// [CommsRequest.ReplyTo]. If the message is no longer in the queue -// (TTL'd / cleared) the user can still approve the draft body in -// isolation; the dispatch path then surfaces an error rather than -// guessing at routing (#428). -func (cs *CommsServer) draftReply(req CommsRequest) CommsResponse { - if req.ReplyTo == "" || req.Body == "" { - return CommsResponse{Error: "reply_to and body are required"} - } - if cs.approvals == nil { - cs.logMessage("draft_denied_no_surface", "", "", "", req.Body, req.ReplyTo) - return CommsResponse{Error: errSendApprovalUnavailable} - } - - original, found := cs.queue.FindByID(req.ReplyTo) - service := original.Source - channel := original.Channel - originalAuthor := original.Author - originalBody := original.Body - if !found { - service = "" - channel = "" - originalAuthor = "" - originalBody = "" - } - - entry := cs.approvals.RegisterCommsDraft(service, channel, originalAuthor, originalBody, req.Body, req.ReplyTo, cs.sessionID) - decision, err := entry.Wait(context.Background(), cs.approvalTTL) - if errors.Is(err, approval.ErrActionApprovalTimeout) { - cs.logMessage("draft_denied_timeout", service, channel, "", req.Body, req.ReplyTo) - return CommsResponse{Error: "draft_reply: user did not respond before timeout"} - } - if err != nil { - cs.logMessage("draft_denied_error", service, channel, "", req.Body, req.ReplyTo) - return CommsResponse{Error: "draft_reply: " + err.Error()} - } - if !decision.Approved { - cs.logMessage("draft_discarded", service, channel, "", req.Body, req.ReplyTo) - msg := "draft_reply: user discarded" - if decision.Reason != "" { - msg += ": " + decision.Reason - } - return CommsResponse{Error: msg} - } - - body := req.Body - if edited, ok := decision.EditedPayload["body"].(string); ok && edited != "" { - body = edited - cs.logMessage("draft_edited", service, channel, "", body, req.ReplyTo) - } - if !found || service == "" || channel == "" { - return CommsResponse{Error: "draft_reply: original message is no longer available; cannot dispatch"} - } - sender, ok := cs.senders[service] - if !ok { - return CommsResponse{Error: "draft_reply: no listener for service: " + service} - } - if sendErr := sender.Send(context.Background(), comms.OutgoingMessage{ - Channel: channel, - Body: body, - }); sendErr != nil { - cs.logMessage("draft_send_failed", service, channel, "", body, req.ReplyTo) - return CommsResponse{Error: "draft_reply: dispatch failed: " + sendErr.Error()} - } - cs.logMessage("reply_sent", service, channel, "", body, req.ReplyTo) - return CommsResponse{OK: true} -} - -// httpRequest registers an [approval.ApprovalKindHTTPRequest] entry -// with the matched api_key binding name (never the value), waits for -// the user to decide via the webapp, and on Approve issues the HTTP -// call with the binding's bytes injected as a Bearer token. -// -// CommsRequest field reuse (preserved from pre-#419 for protocol -// continuity): Service = HTTP method, Channel = URL, Body = request -// body, ReplyTo = a JSON object of additional headers (#428). -func (cs *CommsServer) httpRequest(req CommsRequest) CommsResponse { - method := req.Service - url := req.Channel - body := req.Body - headersJSON := req.ReplyTo - if method == "" || url == "" { - return CommsResponse{Error: "method and url are required"} - } - if cs.approvals == nil { - cs.logMessage("http_request_denied_no_surface", method, url, "", body, "") - return CommsResponse{Error: errSendApprovalUnavailable} - } - - secretName, _ := cs.matchSecret(url) - - entry := cs.approvals.RegisterHTTPRequest(method, url, body, secretName, cs.sessionID) - decision, err := entry.Wait(context.Background(), cs.approvalTTL) - if errors.Is(err, approval.ErrActionApprovalTimeout) { - cs.logMessage("http_request_denied_timeout", method, url, "", body, "") - return CommsResponse{Error: "http_request: user did not respond before timeout"} - } - if err != nil { - cs.logMessage("http_request_denied_error", method, url, "", body, "") - return CommsResponse{Error: "http_request: " + err.Error()} - } - if !decision.Approved { - cs.logMessage("http_request_denied", method, url, "", body, "") - msg := "http_request: user denied" - if decision.Reason != "" { - msg += ": " + decision.Reason - } - return CommsResponse{Error: msg} - } - - var bodyReader io.Reader - if body != "" { - bodyReader = strings.NewReader(body) - } - httpReq, err := http.NewRequest(method, url, bodyReader) - if err != nil { - return CommsResponse{Error: "http_request: invalid request: " + err.Error()} - } - if headersJSON != "" { - var headers map[string]string - if err := json.Unmarshal([]byte(headersJSON), &headers); err == nil { - for k, v := range headers { - httpReq.Header.Set(k, v) - } - } - } - if secretName != "" && cs.vault != nil { - secret, err := cs.vault.Get(context.Background(), secretName) - if err == nil { - httpReq.Header.Set("Authorization", "Bearer "+string(secret.Value)) - } - } - - client := cs.httpClient - if client == nil { - client = &http.Client{} - } - resp, err := client.Do(httpReq) - if err != nil { - return CommsResponse{Error: "http_request: dispatch failed: " + err.Error()} - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - cs.logMessage("http_request_sent", method, url, "", string(respBody), "") - return CommsResponse{ - OK: true, - Messages: []CommsMessageDTO{{ - ID: fmt.Sprintf("%d", resp.StatusCode), - Body: string(respBody), - }}, - } -} - -// matchSecret finds the first secret whose target patterns match the -// URL. The match is best-effort and prefix-based; multiple matches -// resolve in iteration order. Returns the binding name (not the -// value) so the user surface can show "credential: " without -// exposing bytes. -func (cs *CommsServer) matchSecret(url string) (string, bool) { - for name, def := range cs.secrets { - for _, pattern := range def.Targets { - if URLMatchesPattern(url, pattern) { - return name, true - } - } - } - return "", false -} - -// URLMatchesPattern checks if a URL matches a target pattern. -// Patterns use simple containment with optional trailing wildcard. -// Examples: -// -// - "slack.com/api/*" matches "https://slack.com/api/chat.postMessage". -// - "api.github.com" matches "https://api.github.com/repos/foo/bar". -// -// Exact regex / glob expressiveness is out of scope; the pre-#419 -// code shipped this exact matcher and its behaviour is what -// `aileron-mcp`'s `http_request` tool descriptions promise. -func URLMatchesPattern(url, pattern string) bool { - if strings.HasSuffix(pattern, "*") { - prefix := strings.TrimSuffix(pattern, "*") - return strings.Contains(url, prefix) - } - return strings.Contains(url, pattern) -} - -// errSendApprovalUnavailable is the agent-facing message when a -// send-shaped CommsRequest hits a CommsServer that wasn't given an -// [approval.ActionApprovalQueue] — the legacy fallback for callers -// that haven't been updated. Production wiring under `aileron launch` -// always supplies the queue, so this surface is reachable only by -// stale code paths or tests that don't construct the queue. -const errSendApprovalUnavailable = "send / draft / http_request approval surface is unavailable: comms server has no action-approval queue wired (production launch always supplies one — this is a legacy / test fallback)" - -// DirectSend sends a message without an approval prompt. Used by -// downstream callers that have already received explicit user -// authorization via some other surface. Pre-#419, the in-pty overlay -// drove this; under the new launch path nothing currently calls -// DirectSend, but the method is preserved so any future webapp-driven -// reply path can dispatch through it without re-implementing send -// plumbing. -func (cs *CommsServer) DirectSend(service, channel, body string) error { - sender, ok := cs.senders[service] - if !ok { - return fmt.Errorf("no listener for service: %s", service) - } - err := sender.Send(context.Background(), comms.OutgoingMessage{ - Channel: channel, - Body: body, - }) - if err == nil { - cs.logMessage("reply_sent", service, channel, "", body, "") - } - return err -} - -// SetSecrets configures the secrets mapping and vault for http_request -// credential injection. Currently unused — httpRequest fail-closes -// before reaching credential lookup — but preserved for the eventual -// rewire so call sites don't have to re-add it. -func (cs *CommsServer) SetSecrets(secrets launchpolicy.SecretsConfig, v vault.Vault) { - cs.secrets = secrets - cs.vault = v - cs.httpClient = &http.Client{} -} - -// SocketPath returns the path to the Unix socket. -func (cs *CommsServer) SocketPath() string { - return cs.socketPath -} - -// Close shuts down the comms server. -func (cs *CommsServer) Close() { - cs.mu.Lock() - cs.done = true - cs.mu.Unlock() - cs.listener.Close() - os.Remove(cs.socketPath) -} - -// RequestComms connects to the comms server and makes a request. -// Called from aileron-mcp. -func RequestComms(socketPath string, req CommsRequest) CommsResponse { - conn, err := net.Dial("unix", socketPath) - if err != nil { - return CommsResponse{Error: "connection failed: " + err.Error()} - } - defer conn.Close() - - if err := json.NewEncoder(conn).Encode(req); err != nil { - return CommsResponse{Error: "encode failed: " + err.Error()} - } - - var resp CommsResponse - if err := json.NewDecoder(conn).Decode(&resp); err != nil { - return CommsResponse{Error: "decode failed: " + err.Error()} - } - return resp -} - -func (cs *CommsServer) logMessage(event, service, channel, author, body, inReplyTo string) { - if cs.auditStateDir == "" { - return - } - audit.AppendMessageEntry(audit.DailyPath(cs.auditStateDir), audit.MessageEntry{ - Timestamp: time.Now(), - SessionID: cs.sessionID, - Event: event, - Service: service, - Channel: channel, - Author: author, - Body: body, - InReplyTo: inReplyTo, - }) -} - diff --git a/internal/launch/commsserver_branches_test.go b/internal/launch/commsserver_branches_test.go deleted file mode 100644 index 5f889376..00000000 --- a/internal/launch/commsserver_branches_test.go +++ /dev/null @@ -1,455 +0,0 @@ -package launch_test - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/ALRubinger/aileron/internal/approval" - "github.com/ALRubinger/aileron/internal/comms" - "github.com/ALRubinger/aileron/internal/crypto" - "github.com/ALRubinger/aileron/internal/launch" - launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" - "github.com/ALRubinger/aileron/internal/vault" -) - -// Branch coverage for the CommsServer's send-shaped methods (#428). -// Each test names the specific branch it exercises so a failure -// localises to one path. Together they cover the validation guards, -// timeout / dispatch-failure paths, the secrets injection helper, -// and the URL pattern matcher. - -// --- URLMatchesPattern --------------------------------------------- - -func TestURLMatchesPattern_Cases(t *testing.T) { - cases := []struct { - name string - url string - pattern string - want bool - }{ - {"trailing wildcard match", "https://slack.com/api/chat.postMessage", "slack.com/api/*", true}, - {"trailing wildcard no match", "https://discord.com/api/x", "slack.com/api/*", false}, - {"exact containment match", "https://api.github.com/repos/foo/bar", "api.github.com", true}, - {"exact containment no match", "https://api.gitlab.com/repos/foo/bar", "api.github.com", false}, - {"empty pattern matches anywhere via Contains", "https://example.com", "", true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := launch.URLMatchesPattern(tc.url, tc.pattern); got != tc.want { - t.Errorf("URLMatchesPattern(%q, %q) = %v, want %v", tc.url, tc.pattern, got, tc.want) - } - }) - } -} - -// --- send_message validation --------------------------------------- - -func TestCommsServer_SendMessage_ValidationErrors(t *testing.T) { - cases := []struct { - name string - req launch.CommsRequest - }{ - {"missing service", launch.CommsRequest{Method: "send_message", Channel: "#x", Body: "hi"}}, - {"missing channel", launch.CommsRequest{Method: "send_message", Service: "slack", Body: "hi"}}, - {"missing body", launch.CommsRequest{Method: "send_message", Service: "slack", Channel: "#x"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, sock, _ := newQueueWiredCommsServer(t, newStubListener("slack")) - resp := dialComms(t, sock, tc.req) - if resp.OK { - t.Errorf("OK = true; want validation error") - } - }) - } -} - -func TestCommsServer_SendMessage_TimeoutReturnsAgentError(t *testing.T) { - listener := newStubListener("slack") - srv, sock, _ := newQueueWiredCommsServer(t, listener) - launch.SetApprovalTTLForTest(srv, 100*time.Millisecond) - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#x", Body: "hi", - }) - if resp.OK { - t.Fatalf("OK = true; want timeout error") - } - if resp.Error == "" { - t.Error("Error = empty; want a timeout message") - } - if !contains(resp.Error, "timeout") { - t.Errorf("Error = %q, want a timeout message", resp.Error) - } -} - -func TestCommsServer_SendMessage_DispatchFailureSurfacesUpstream(t *testing.T) { - listener := newStubListener("slack") - listener.sendErr = errors.New("upstream slack 500") - - _, sock, q := newQueueWiredCommsServer(t, listener) - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#x", Body: "hi", - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want dispatch failure") - } - if !contains(resp.Error, "dispatch failed") || !contains(resp.Error, "upstream slack 500") { - t.Errorf("Error = %q, want both 'dispatch failed' and the upstream message", resp.Error) - } -} - -// --- draft_reply ---------------------------------------------------- - -func TestCommsServer_DraftReply_ValidationErrors(t *testing.T) { - cases := []struct { - name string - req launch.CommsRequest - }{ - {"missing reply_to", launch.CommsRequest{Method: "draft_reply", Body: "hi"}}, - {"missing body", launch.CommsRequest{Method: "draft_reply", ReplyTo: "m1"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, sock, _ := newQueueWiredCommsServer(t) - resp := dialComms(t, sock, tc.req) - if resp.OK { - t.Errorf("OK = true; want validation error") - } - }) - } -} - -func TestCommsServer_DraftReply_NoOriginalDispatchError(t *testing.T) { - // reply_to that's not in the NotifyQueue. The user can still - // approve, but the dispatcher has no service / channel — handler - // surfaces a clear error rather than silently dropping. - _, sock, q := newQueueWiredCommsServer(t) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "no-such-msg", Body: "ack", - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want dispatch error") - } - if !contains(resp.Error, "no longer available") { - t.Errorf("Error = %q, want 'no longer available' message", resp.Error) - } -} - -func TestCommsServer_DraftReply_ApproveWithoutEditDispatchesOriginal(t *testing.T) { - // Approve with no editedPayload — the dispatcher must send the - // agent's original draft body verbatim. - listener := newStubListener("slack") - srv, sock, q := newQueueWiredCommsServer(t, listener) - launch.PushIncomingForTest(srv, launch.IncomingForTest{ - ID: "msg-x", Service: "slack", Channel: "#general", Author: "alice", Body: "ping", - }) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "msg-x", Body: "agent draft", - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - if got := listener.Last(); got.Body != "agent draft" { - t.Errorf("dispatched body = %q, want agent's original \"agent draft\"", got.Body) - } -} - -func TestCommsServer_DraftReply_DispatchFailureSurfacesUpstream(t *testing.T) { - listener := newStubListener("slack") - listener.sendErr = errors.New("rate limited") - srv, sock, q := newQueueWiredCommsServer(t, listener) - launch.PushIncomingForTest(srv, launch.IncomingForTest{ - ID: "msg-y", Service: "slack", Channel: "#general", Author: "alice", Body: "ping", - }) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "msg-y", Body: "ack", - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want dispatch failure") - } - if !contains(resp.Error, "dispatch failed") { - t.Errorf("Error = %q, want 'dispatch failed'", resp.Error) - } -} - -func TestCommsServer_DraftReply_TimeoutReturnsAgentError(t *testing.T) { - srv, sock, _ := newQueueWiredCommsServer(t, newStubListener("slack")) - launch.SetApprovalTTLForTest(srv, 100*time.Millisecond) - launch.PushIncomingForTest(srv, launch.IncomingForTest{ - ID: "msg-z", Service: "slack", Channel: "#general", Author: "a", Body: "p", - }) - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "msg-z", Body: "ack", - }) - if resp.OK { - t.Errorf("OK = true; want timeout error") - } -} - -// --- http_request --------------------------------------------------- - -func TestCommsServer_HTTPRequest_ValidationErrors(t *testing.T) { - cases := []struct { - name string - req launch.CommsRequest - }{ - {"missing method", launch.CommsRequest{Method: "http_request", Channel: "https://x"}}, - {"missing url", launch.CommsRequest{Method: "http_request", Service: "GET"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, sock, _ := newQueueWiredCommsServer(t) - resp := dialComms(t, sock, tc.req) - if resp.OK { - t.Errorf("OK = true; want validation error") - } - }) - } -} - -func TestCommsServer_HTTPRequest_TimeoutReturnsAgentError(t *testing.T) { - srv, sock, _ := newQueueWiredCommsServer(t) - launch.SetApprovalTTLForTest(srv, 100*time.Millisecond) - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "http_request", Service: "GET", Channel: "https://example.com", - }) - if resp.OK { - t.Errorf("OK = true; want timeout error") - } -} - -func TestCommsServer_HTTPRequest_HeadersForwarded(t *testing.T) { - var gotXTrace string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotXTrace = r.Header.Get("X-Trace") - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(upstream.Close) - - _, sock, q := newQueueWiredCommsServer(t) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "http_request", - Service: "GET", - Channel: upstream.URL, - ReplyTo: `{"X-Trace":"abc-123"}`, - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - if gotXTrace != "abc-123" { - t.Errorf("upstream X-Trace = %q, want \"abc-123\" (custom header didn't forward)", gotXTrace) - } -} - -func TestCommsServer_HTTPRequest_SecretInjectedAsBearer(t *testing.T) { - // Stand up an upstream that captures the Authorization header, - // configure a secret whose target pattern matches the URL, and - // confirm the daemon injects the binding's bytes (not the name) - // as a Bearer token. - var gotAuth string - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(upstream.Close) - - listener := newStubListener("slack") - srv, sock, q := newQueueWiredCommsServer(t, listener) - - // In-memory vault with a known credential. - kek, err := crypto.GenerateRandomKEK() - if err != nil { - t.Fatalf("GenerateRandomKEK: %v", err) - } - v, err := vault.NewEncryptedVault(vault.NewMemVault(), kek) - if err != nil { - t.Fatalf("NewEncryptedVault: %v", err) - } - const secretName = "linear-api-key" - const secretValue = "lin_api_xyz" - if err := v.Put(context.Background(), secretName, []byte(secretValue), vault.Metadata{Type: "api_key"}); err != nil { - t.Fatalf("vault.Put: %v", err) - } - srv.SetSecrets(launchpolicy.SecretsConfig{ - secretName: launchpolicy.SecretDef{Targets: []string{upstream.URL}}, - }, v) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "http_request", Service: "GET", Channel: upstream.URL, - }) - entry := pollFirstPending(t, q, 1*time.Second) - if entry.Args["secret_name"] != secretName { - t.Errorf("queue entry secret_name = %v, want %q", entry.Args["secret_name"], secretName) - } - if _, ok := entry.Args["secret_value"]; ok { - t.Error("queue entry leaked secret_value to the webapp surface") - } - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - want := "Bearer " + secretValue - if gotAuth != want { - t.Errorf("upstream Authorization = %q, want %q", gotAuth, want) - } -} - -func TestCommsServer_HTTPRequest_DispatchFailureSurfacesUpstreamError(t *testing.T) { - // Point at a closed listener — http.Client.Do returns "connection - // refused" or similar. Confirms the dispatch-failed branch. - closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - addr := closed.URL - closed.Close() - - _, sock, q := newQueueWiredCommsServer(t) - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "http_request", Service: "GET", Channel: addr, - }) - entry := pollFirstPending(t, q, 1*time.Second) - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want dispatch failure when upstream is closed") - } - if !contains(resp.Error, "dispatch failed") { - t.Errorf("Error = %q, want 'dispatch failed'", resp.Error) - } -} - -// --- matchSecret + DirectSend -------------------------------------- - -func TestMatchSecret_FindsConfiguredPattern(t *testing.T) { - srv, _, _ := newQueueWiredCommsServer(t) - srv.SetSecrets(launchpolicy.SecretsConfig{ - "linear": launchpolicy.SecretDef{Targets: []string{"linear.app/*"}}, - "github": launchpolicy.SecretDef{Targets: []string{"api.github.com"}}, - }, nil) - - if got, ok := launch.MatchSecretForTest(srv, "https://linear.app/graphql"); !ok || got != "linear" { - t.Errorf("MatchSecret(linear url) = (%q, %v), want (linear, true)", got, ok) - } - if got, ok := launch.MatchSecretForTest(srv, "https://api.github.com/x"); !ok || got != "github" { - t.Errorf("MatchSecret(github url) = (%q, %v), want (github, true)", got, ok) - } - if _, ok := launch.MatchSecretForTest(srv, "https://example.com/x"); ok { - t.Error("MatchSecret(example.com) returned ok=true; want no match") - } -} - -func TestCommsServer_DirectSend_DispatchesViaListener(t *testing.T) { - listener := newStubListener("slack") - srv, _, _ := newQueueWiredCommsServer(t, listener) - if err := srv.DirectSend("slack", "#general", "hello"); err != nil { - t.Fatalf("DirectSend: %v", err) - } - if !listener.Sent() { - t.Error("listener.Send was not called") - } - if got := listener.Last(); got.Channel != "#general" || got.Body != "hello" { - t.Errorf("Send received %+v, want #general / hello", got) - } -} - -// --- shared apiServer + CommsServer queue --------------------------- - -func TestCommsServer_AndApiServerShareQueue(t *testing.T) { - // Constructing the apiServer with a Config.ActionApprovals set - // must reuse that queue rather than creating a fresh one — so an - // entry the CommsServer registers is observable through the - // gateway's `/v1/action-approvals` API on the same instance. - q := approval.NewActionApprovalQueue(nil, nil) - listener := newStubListener("slack") - - // macOS caps unix-socket paths at 104 bytes; use a short /tmp - // path rather than t.TempDir() which nests test names. - f, err := os.CreateTemp("/tmp", "aileron-comms-shared-*.sock") - if err != nil { - t.Fatalf("CreateTemp: %v", err) - } - sock := f.Name() - f.Close() - os.Remove(sock) - t.Cleanup(func() { os.Remove(sock) }) - - queueNotify := launch.NewNotifyQueue(8, nil) - srv, err := launch.NewCommsServer(sock, queueNotify, []comms.Listener{listener}, "", "session-1", q) - if err != nil { - t.Fatalf("NewCommsServer: %v", err) - } - t.Cleanup(func() { srv.Close() }) - go srv.Serve() - time.Sleep(20 * time.Millisecond) - - // Register an entry directly on the queue (mirrors what - // CommsServer's sendMessage would do internally) and confirm - // List sees it. - a := q.RegisterCommsSend("slack", "#x", "ping", "session-1") - if got := q.List(); len(got) != 1 || got[0].ID != a.ID { - t.Errorf("queue.List = %+v, want exactly the just-registered entry", got) - } -} - -// --- helpers --- - -func contains(s, sub string) bool { - return s != "" && sub != "" && (s == sub || (len(s) >= len(sub) && (indexOf(s, sub) >= 0))) -} - -func indexOf(s, sub string) int { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return i - } - } - return -1 -} - -// silence unused imports if the file later trims any test that needed them -var _ = fmt.Sprintf diff --git a/internal/launch/commsserver_queue_test.go b/internal/launch/commsserver_queue_test.go deleted file mode 100644 index c37672cb..00000000 --- a/internal/launch/commsserver_queue_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package launch_test - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync" - "testing" - "time" - - "github.com/ALRubinger/aileron/internal/approval" - "github.com/ALRubinger/aileron/internal/comms" - "github.com/ALRubinger/aileron/internal/launch" -) - -// CommsServer + ActionApprovalQueue contract (#428): -// -// - send_message → register comms_send → on Approve, dispatch via the -// matched [comms.Listener] and return OK. -// - send_message → on Deny, return an agent-facing error; no dispatch. -// - draft_reply → register comms_draft with the original message -// context → on Approve (with optional edited body), dispatch the -// edited bytes; on Discard, no dispatch. -// - http_request → register http_request with the matched secret -// name → on Approve, issue the HTTP call. -// - The queue entry's Args carry exactly the kind-specific shape -// the webapp relies on (service/channel/body for comms_send; -// original_author/original_body/draft_body for comms_draft; -// method/url/secret_name for http_request). - -// stubListener is a minimal [comms.Listener] that records the last -// outgoing message. Tests use it to assert "the dispatcher actually -// called Send with these bytes after the user approved." -type stubListener struct { - service string - mu sync.Mutex - last comms.OutgoingMessage - sendErr error - sent bool -} - -func newStubListener(service string) *stubListener { - return &stubListener{service: service} -} - -func (s *stubListener) Connect(context.Context) error { return nil } -func (s *stubListener) Listen(context.Context) (<-chan comms.IncomingMessage, error) { - return nil, nil -} -func (s *stubListener) Send(_ context.Context, msg comms.OutgoingMessage) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.sendErr != nil { - return s.sendErr - } - s.last = msg - s.sent = true - return nil -} -func (s *stubListener) Close() error { return nil } -func (s *stubListener) Service() string { return s.service } -func (s *stubListener) Last() comms.OutgoingMessage { - s.mu.Lock() - defer s.mu.Unlock() - return s.last -} -func (s *stubListener) Sent() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.sent -} - -// newQueueWiredCommsServer stands up a CommsServer with a real -// approval queue. The returned q is the same instance the server -// holds; tests Decide on it to drive the approval/deny path. -func newQueueWiredCommsServer(t *testing.T, listeners ...comms.Listener) (*launch.CommsServer, string, *approval.ActionApprovalQueue) { - t.Helper() - f, err := os.CreateTemp("/tmp", "aileron-comms-queue-*.sock") - if err != nil { - t.Fatalf("CreateTemp: %v", err) - } - sock := f.Name() - f.Close() - os.Remove(sock) - t.Cleanup(func() { os.Remove(sock) }) - - q := approval.NewActionApprovalQueue(nil, nil) - queue := launch.NewNotifyQueue(100, nil) - srv, err := launch.NewCommsServer(sock, queue, listeners, "", "test-session", q) - if err != nil { - t.Fatalf("NewCommsServer: %v", err) - } - go srv.Serve() - t.Cleanup(func() { srv.Close() }) - time.Sleep(20 * time.Millisecond) - return srv, sock, q -} - -func TestCommsServer_SendMessage_ApproveDispatches(t *testing.T) { - listener := newStubListener("slack") - _, sock, q := newQueueWiredCommsServer(t, listener) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#general", Body: "hello", - }) - - // Wait for the entry to land in the queue, then approve. - entry := pollFirstPending(t, q, 2*time.Second) - if entry.Kind != approval.ApprovalKindCommsSend { - t.Errorf("Kind = %q, want %q", entry.Kind, approval.ApprovalKindCommsSend) - } - if entry.Args["service"] != "slack" || entry.Args["channel"] != "#general" || entry.Args["body"] != "hello" { - t.Errorf("Args = %+v, want service=slack channel=#general body=hello", entry.Args) - } - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - if !listener.Sent() { - t.Fatal("listener.Send was never called after Approve") - } - if got := listener.Last(); got.Channel != "#general" || got.Body != "hello" { - t.Errorf("Send received %+v, want #general / hello", got) - } -} - -func TestCommsServer_SendMessage_DenyReturnsError(t *testing.T) { - listener := newStubListener("slack") - _, sock, q := newQueueWiredCommsServer(t, listener) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#general", Body: "hello", - }) - - entry := pollFirstPending(t, q, 2*time.Second) - if err := q.Decide(entry.ID, false, "wrong recipient", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want error after Deny") - } - if !strings.Contains(resp.Error, "user denied") || !strings.Contains(resp.Error, "wrong recipient") { - t.Errorf("Error = %q, want a deny+reason message", resp.Error) - } - if listener.Sent() { - t.Error("listener.Send was called despite the Deny") - } -} - -func TestCommsServer_SendMessage_NoMatchingService(t *testing.T) { - // No listener registered for "slack" — handler must reject before - // the queue entry is registered, otherwise the user sees a card - // for a request that can never be fulfilled. - _, sock, q := newQueueWiredCommsServer(t) - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#general", Body: "hi", - }) - if resp.OK { - t.Errorf("OK = true; want error when no listener for service") - } - if !strings.Contains(resp.Error, "no listener") { - t.Errorf("Error = %q, want \"no listener for service\"", resp.Error) - } - if pending := q.List(); len(pending) != 0 { - t.Errorf("queue.List len = %d, want 0 (no entry should land for an undispatchable request)", len(pending)) - } -} - -func TestCommsServer_DraftReply_ApproveWithEditDispatchesEdited(t *testing.T) { - listener := newStubListener("slack") - srv, sock, q := newQueueWiredCommsServer(t, listener) - - // Push an "incoming" message into the NotifyQueue so draftReply - // has an original to look up. The CommsServer's queue is - // inaccessible from outside the package; use the public - // PushNotifyMessage helper... wait, there isn't one. Instead, use - // the internal queue field via the [launch] package's - // constructor: the test helper above keeps a handle but we don't - // expose it. Workaround: send via DirectSend's path — - // no, that's send-only. Cleanest path: piggyback on the server's - // Notify queue handle via the PushIncoming helper exposed for - // tests in #428. - launch.PushIncomingForTest(srv, launch.IncomingForTest{ - ID: "msg-orig-1", - Service: "slack", - Channel: "#general", - Author: "alice", - Body: "ping", - }) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "draft_reply", - ReplyTo: "msg-orig-1", - Body: "ack", - }) - - entry := pollFirstPending(t, q, 2*time.Second) - if entry.Kind != approval.ApprovalKindCommsDraft { - t.Errorf("Kind = %q, want %q", entry.Kind, approval.ApprovalKindCommsDraft) - } - if entry.Args["original_body"] != "ping" || entry.Args["original_author"] != "alice" { - t.Errorf("Args = %+v, want original_body=ping original_author=alice", entry.Args) - } - if entry.Args["draft_body"] != "ack" { - t.Errorf("draft_body = %v, want \"ack\"", entry.Args["draft_body"]) - } - - if err := q.Decide(entry.ID, true, "", map[string]any{"body": "ack — got it"}); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - got := listener.Last() - if got.Body != "ack — got it" { - t.Errorf("dispatched body = %q, want the edited \"ack — got it\"", got.Body) - } - if got.Channel != "#general" { - t.Errorf("dispatched channel = %q, want \"#general\" (taken from the original message)", got.Channel) - } -} - -func TestCommsServer_DraftReply_DiscardReturnsError(t *testing.T) { - listener := newStubListener("slack") - srv, sock, q := newQueueWiredCommsServer(t, listener) - launch.PushIncomingForTest(srv, launch.IncomingForTest{ - ID: "msg-orig-2", Service: "slack", Channel: "#general", Author: "bob", Body: "yo", - }) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "msg-orig-2", Body: "hi", - }) - - entry := pollFirstPending(t, q, 2*time.Second) - if err := q.Decide(entry.ID, false, "off-topic", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want error after discard") - } - if listener.Sent() { - t.Error("listener.Send was called despite the discard") - } -} - -func TestCommsServer_HTTPRequest_ApproveDispatches(t *testing.T) { - // Spin up a tiny upstream HTTP server. The CommsServer dispatches - // the user-approved request to it; assert the body / method are - // forwarded verbatim. - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"got":"` + r.Method + `:` + string(body) + `"}`)) - })) - t.Cleanup(upstream.Close) - - _, sock, q := newQueueWiredCommsServer(t) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "http_request", - Service: "POST", - Channel: upstream.URL, - Body: `{"hello":"world"}`, - }) - - entry := pollFirstPending(t, q, 2*time.Second) - if entry.Kind != approval.ApprovalKindHTTPRequest { - t.Errorf("Kind = %q, want %q", entry.Kind, approval.ApprovalKindHTTPRequest) - } - if entry.Args["method"] != "POST" || entry.Args["url"] != upstream.URL { - t.Errorf("Args = %+v, want method=POST url=%s", entry.Args, upstream.URL) - } - if entry.Args["secret_name"] != "" { - t.Errorf("secret_name = %v, want empty (no secrets configured in this test)", entry.Args["secret_name"]) - } - if err := q.Decide(entry.ID, true, "", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if !resp.OK { - t.Fatalf("OK = false, err = %q", resp.Error) - } - if len(resp.Messages) == 0 { - t.Fatal("Messages is empty; want one entry with the upstream response") - } - if !strings.Contains(resp.Messages[0].Body, `"got":"POST:{"hello":"world"}"`) { - t.Errorf("upstream response body = %q, want the POST-passthrough payload", resp.Messages[0].Body) - } -} - -func TestCommsServer_HTTPRequest_DenyReturnsError(t *testing.T) { - _, sock, q := newQueueWiredCommsServer(t) - - respCh := dialCommsAsync(t, sock, launch.CommsRequest{ - Method: "http_request", - Service: "GET", - Channel: "https://example.com/should-not-be-hit", - }) - - entry := pollFirstPending(t, q, 2*time.Second) - if err := q.Decide(entry.ID, false, "no thanks", nil); err != nil { - t.Fatalf("Decide: %v", err) - } - - resp := <-respCh - if resp.OK { - t.Errorf("OK = true; want error on Deny") - } - if !strings.Contains(resp.Error, "user denied") { - t.Errorf("Error = %q, want a deny message", resp.Error) - } -} - -// --- helpers --- - -// dialCommsAsync dispatches a request on a goroutine so the test can -// drive the queue's Decide concurrently. The channel buffers one -// response; the caller reads it after Deciding. -func dialCommsAsync(t *testing.T, sock string, req launch.CommsRequest) chan launch.CommsResponse { - t.Helper() - ch := make(chan launch.CommsResponse, 1) - go func() { - ch <- dialComms(t, sock, req) - }() - return ch -} - -// pollFirstPending waits up to timeout for the queue to have at -// least one pending entry, then returns the first one. Avoids -// race-on-register flakes between dialing and Deciding. -func pollFirstPending(t *testing.T, q *approval.ActionApprovalQueue, timeout time.Duration) *approval.ActionApproval { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if pending := q.List(); len(pending) > 0 { - return pending[0] - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("queue had no pending entries after %s", timeout) - return nil -} - -// --- silence unused-import warnings on the json import in case the -// file later trims the asserts that need it. --- - -var _ = json.Marshal diff --git a/internal/launch/commsserver_test.go b/internal/launch/commsserver_test.go deleted file mode 100644 index b68f1ae0..00000000 --- a/internal/launch/commsserver_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package launch_test - -import ( - "encoding/json" - "net" - "os" - "testing" - "time" - - "github.com/ALRubinger/aileron/internal/comms" - "github.com/ALRubinger/aileron/internal/launch" -) - -// TestCommsServer_ReadMessages_Empty asserts the queue-read happy -// path: a fresh server with an empty NotifyQueue returns OK + no -// messages. This is the load-bearing path for `aileron-mcp`'s -// `read_messages` tool — runs every time the agent polls for new -// Slack/Discord traffic. -func TestCommsServer_ReadMessages_Empty(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - - resp := dialComms(t, sock, launch.CommsRequest{Method: "read_messages"}) - if !resp.OK { - t.Errorf("OK = false; err=%q", resp.Error) - } - if len(resp.Messages) != 0 { - t.Errorf("Messages = %d, want 0", len(resp.Messages)) - } -} - -// TestCommsServer_SendMessage_NoQueueFailsClosed asserts the legacy -// fallback: when a CommsServer is constructed without an -// [approval.ActionApprovalQueue], send-shaped requests fail-closed -// rather than block indefinitely. Production launch always wires -// the queue (#428); this branch protects callers that haven't -// updated yet (e.g. tests that don't care about the approval flow). -func TestCommsServer_SendMessage_NoQueueFailsClosed(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "send_message", Service: "slack", Channel: "#x", Body: "hi", - }) - if resp.OK { - t.Errorf("OK = true; want fail-closed when no queue wired") - } - if resp.Error == "" { - t.Error("Error is empty; want a clear fallback message") - } -} - -// TestCommsServer_DraftReply_NoQueueFailsClosed: same fallback as -// sendMessage. Kept separate so a future change to either method's -// fallback shape doesn't have to retire two tests at once. -func TestCommsServer_DraftReply_NoQueueFailsClosed(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "draft_reply", ReplyTo: "m1", Body: "ok", - }) - if resp.OK { - t.Errorf("OK = true; want fail-closed when no queue wired") - } -} - -// TestCommsServer_HTTPRequest_NoQueueFailsClosed: the generic -// `http_request` MCP tool (api_key-binding-injecting fetch) had its -// own pty approval prompt pre-#419 and now routes through the same -// queue (#428). Same fallback as the other two when no queue is -// supplied. -func TestCommsServer_HTTPRequest_NoQueueFailsClosed(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - - resp := dialComms(t, sock, launch.CommsRequest{ - Method: "http_request", Service: "GET", Channel: "https://example.com", - }) - if resp.OK { - t.Errorf("OK = true; want fail-closed when no queue wired") - } -} - -// TestCommsServer_UnknownMethod asserts the dispatch guard. Catches -// a typo in aileron-mcp before it produces an opaque "send failed" -// at runtime. -func TestCommsServer_UnknownMethod(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - - resp := dialComms(t, sock, launch.CommsRequest{Method: "blah"}) - if resp.Error == "" { - t.Error("Error is empty; want unknown-method message") - } -} - -// TestRequestComms_NoSocket covers the client-side fail-soft for -// callers (aileron-mcp) that dial a non-existent socket. Returns a -// CommsResponse with an Error rather than panicking. -func TestRequestComms_NoSocket(t *testing.T) { - resp := launch.RequestComms("/nonexistent/socket.sock", launch.CommsRequest{Method: "read_messages"}) - if resp.OK { - t.Errorf("OK = true; want fail-soft on dial error") - } - if resp.Error == "" { - t.Error("Error is empty; want connection-failure message") - } -} - -// TestCommsServer_DirectSend_NoSender asserts the direct-send error -// path when no listener is registered for the requested service. -// DirectSend is preserved across #419 for future webapp-driven -// reply paths; this guards against accidental nil-deref refactors. -func TestCommsServer_DirectSend_NoSender(t *testing.T) { - srv, _ := newCommsServer(t) - defer srv.Close() - - err := srv.DirectSend("nonexistent-service", "#x", "hi") - if err == nil { - t.Error("err = nil; want \"no listener for service\"") - } -} - -// TestCommsServer_SocketPath asserts the trivial accessor still works -// — useful for callers that need to delete the socket file out-of-band. -func TestCommsServer_SocketPath(t *testing.T) { - srv, sock := newCommsServer(t) - defer srv.Close() - if got := srv.SocketPath(); got != sock { - t.Errorf("SocketPath = %q, want %q", got, sock) - } -} - -// --- helpers --- - -// newCommsServer stands up a CommsServer on a fresh socket. Caller -// must close. No bar/copier/router params — those died with #419's -// pty removal. -// -// macOS caps unix-socket paths at 104 bytes; t.TempDir() nests the -// test name and exceeds that on long descriptive test identifiers, -// so we use os.CreateTemp under /tmp explicitly to keep the socket -// path short and stable. -func newCommsServer(t *testing.T) (*launch.CommsServer, string) { - t.Helper() - f, err := os.CreateTemp("/tmp", "aileron-comms-test-*.sock") - if err != nil { - t.Fatalf("CreateTemp: %v", err) - } - sock := f.Name() - f.Close() - os.Remove(sock) // listener will recreate it - t.Cleanup(func() { os.Remove(sock) }) - - q := launch.NewNotifyQueue(100, nil) - srv, err := launch.NewCommsServer(sock, q, []comms.Listener{}, "", "test-session", nil) - if err != nil { - t.Fatalf("NewCommsServer: %v", err) - } - go srv.Serve() - // Tiny pause so the listener is ready before Dial. - time.Sleep(20 * time.Millisecond) - return srv, sock -} - -// dialComms connects to the comms socket, sends one CommsRequest, -// returns the CommsResponse. Test-side mirror of [launch.RequestComms] -// without the fail-soft wrapping — we want test failures, not nils. -func dialComms(t *testing.T, sock string, req launch.CommsRequest) launch.CommsResponse { - t.Helper() - conn, err := net.Dial("unix", sock) - if err != nil { - t.Fatalf("dial %s: %v", sock, err) - } - defer conn.Close() - if err := json.NewEncoder(conn).Encode(req); err != nil { - t.Fatalf("encode: %v", err) - } - var resp launch.CommsResponse - if err := json.NewDecoder(conn).Decode(&resp); err != nil { - t.Fatalf("decode: %v", err) - } - return resp -} diff --git a/internal/launch/export_test.go b/internal/launch/export_test.go index 5315d872..3f512569 100644 --- a/internal/launch/export_test.go +++ b/internal/launch/export_test.go @@ -1,55 +1,7 @@ package launch -import "time" - // OpenSessionLogger exposes openSessionLogger for testing. var OpenSessionLogger = openSessionLogger // SessionLogPath exposes sessionLogPath for testing. var SessionLogPath = sessionLogPath - -// `wrapText` and `visibleWidth` lived in the pty rendering files -// (panel.go) deleted by #419; their tests die with them. - -// IncomingForTest is the test-only shape for seeding a CommsServer's -// NotifyQueue with an "incoming" message. Used by the comms-server -// queue tests (#428) to set up a draft_reply scenario where the -// original message is in the queue and the dispatcher can route the -// edited reply back through the matching listener. -type IncomingForTest struct { - ID string - Service string - Channel string - Author string - Body string -} - -// PushIncomingForTest pushes a synthetic incoming message onto the -// CommsServer's NotifyQueue, exposed for tests in the `launch_test` -// package. Production callers go through the actual comms.Listener -// dispatch path. -func PushIncomingForTest(cs *CommsServer, msg IncomingForTest) { - cs.queue.Push(Message{ - ID: msg.ID, - Source: msg.Service, - Channel: msg.Channel, - Author: msg.Author, - Body: msg.Body, - Preview: msg.Body, - Timestamp: time.Now(), - }) -} - -// SetApprovalTTLForTest overrides the CommsServer's approval-wait -// timeout so timeout-path tests can drive the deadline deterministically -// without sleeping for minutes. -func SetApprovalTTLForTest(cs *CommsServer, d time.Duration) { - cs.approvalTTL = d -} - -// MatchSecretForTest exposes matchSecret so tests can assert the -// per-name URL pattern matching without round-tripping through the -// httpRequest dispatch path. -func MatchSecretForTest(cs *CommsServer, url string) (string, bool) { - return cs.matchSecret(url) -} diff --git a/internal/launch/launcher.go b/internal/launch/launcher.go index 2125927f..ad96d472 100644 --- a/internal/launch/launcher.go +++ b/internal/launch/launcher.go @@ -14,12 +14,9 @@ import ( "syscall" "time" - "github.com/ALRubinger/aileron/internal/approval" "github.com/ALRubinger/aileron/internal/audit" - "github.com/ALRubinger/aileron/internal/comms" "github.com/ALRubinger/aileron/internal/daemon/spawn" launchpolicy "github.com/ALRubinger/aileron/internal/policy/launch" - "github.com/ALRubinger/aileron/internal/vault" ) // LaunchConfig holds the configuration for launching an agent. @@ -131,12 +128,6 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { return LaunchResult{}, fmt.Errorf("register session: %w", err) } - // Action-approval queue: under Step 8, the per-launch queue still - // powers the local approval-socket and the comms server. - // Step 9 (#454) moves the queue to daemon ownership and routes - // per-session via HTTP. For now the queue is process-local. - approvalQueue := approval.NewActionApprovalQueue(nil, nil) - agentPath, err := ResolveBinary(config.Agent.BinaryNames()) if err != nil { return LaunchResult{}, fmt.Errorf("agent %q: %w", config.Agent.Name(), err) @@ -150,7 +141,6 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { auditStateDir := resolveAuditStateDir() envConfig := loadEnvConfig(config.Dir) - commsSocket := filepath.Join(os.TempDir(), "ai-comms-"+sessionID+".sock") // LLM-endpoint env (e.g. ANTHROPIC_BASE_URL for Claude Code) now // points at the daemon. Agents that don't override their LLM @@ -162,7 +152,6 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { // uses to POST shell-approval requests to the daemon (Step 9A of // #454, replacing the pre-9A unix socket). env = append(env, "AILERON_APPROVAL_URL="+daemonURL) - env = append(env, "AILERON_COMMS_SOCKET="+commsSocket) // Agent-required args come first, then user-supplied args. allArgs := append(config.Agent.Args(), config.Args...) @@ -172,14 +161,22 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { // (was the embedded gateway pre-ADR-0012); aileron-mcp routes action // discovery (`tools/list`) and execution (`tools/call`) there. // + // AILERON_COMMS_URL + AILERON_SESSION_ID are how aileron-mcp's + // comms tools (`read_messages`, `send_message`, `draft_reply`, + // `http_request`) reach the daemon-owned comms surface — Step + // 9B-2 of #454 replaced the pre-9B per-session unix socket + // (/tmp/ai-comms-{sessionID}.sock) with HTTP long-poll handlers + // matching the 9A shell-approval pattern. + // // AILERON_APPROVAL_URL is the user-facing approval surface — the // daemon's `/approvals` page. The agent embeds this in templated // tool descriptions so the user knows where to approve gated actions. selfPath, _ := os.Executable() if mcpBin, err := resolveSibling(selfPath, "aileron-mcp"); err == nil { mcpEnv := map[string]string{ - "AILERON_COMMS_SOCKET": commsSocket, "AILERON_URL": daemonURL, + "AILERON_COMMS_URL": daemonURL, + "AILERON_SESSION_ID": sessionID, "AILERON_APPROVAL_URL": daemonURL + "/approvals", } envJSON, _ := json.Marshal(mcpEnv) @@ -196,9 +193,6 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { cmd.Dir = config.Dir } - // Create the notification queue and prepare comms config. - queue := NewNotifyQueue(100, nil) - wireQuietHours(config.Dir, queue) sessionLog, closeSessionLog := openSessionLogger(config.Dir, config.LogLevel) defer closeSessionLog() fmt.Fprintf(os.Stderr, "aileron: session log → %s\n", sessionLogPath(config.Dir)) @@ -211,25 +205,6 @@ func Launch(ctx context.Context, config LaunchConfig) (LaunchResult, error) { "daemon_url", daemonURL, ) - // Comms listeners (Slack, Discord) so incoming messages still - // land in the NotifyQueue and are readable via the - // `read_messages` MCP tool. Step 9 (#454) moves these to daemon - // ownership; until then they stay per-launch. - listeners := startCommsListeners(ctx, config.Dir, queue, auditStateDir, sessionID, sessionLog) - defer stopCommsListeners(listeners) - - // Comms server: same socket the pre-#419 launch exposed to - // aileron-mcp for `read_messages` etc. The send / draft / http - // paths fail-closed under the new launch (no in-pty approval - // surface; webapp wire-through pending) — see commsserver.go for - // the regression detail. - commsSrv, err := NewCommsServer(commsSocket, queue, listeners, auditStateDir, sessionID, approvalQueue) - if err != nil { - return LaunchResult{}, fmt.Errorf("starting comms server: %w", err) - } - defer commsSrv.Close() - go commsSrv.Serve() - // Banner: probe the daemon's vault state so we can include the // "open the URL to unlock" hint when needed. Failure is silent — // the banner just omits the hint. @@ -489,22 +464,6 @@ func PrintSessionSummary(w io.Writer, auditPath, sessionID string) { } } -// wireQuietHours reads the QuietHours config from the policy file and -// attaches it to the notification queue. -func wireQuietHours(dir string, queue *NotifyQueue) { - if dir == "" { - dir, _ = os.Getwd() - } - policyPath := FindPolicyFile(dir) - if policyPath == "" { - return - } - pf := loadPolicyFileFrom(policyPath) - if pf.Notifications != nil && pf.Notifications.QuietHours != nil { - queue.SetQuietHours(pf.Notifications.QuietHours) - } -} - // loadEnvConfig loads the merged policy's EnvConfig for env scrubbing. func loadEnvConfig(dir string) *launchpolicy.EnvConfig { if dir == "" { @@ -557,263 +516,6 @@ func EnvGlobMatch(pattern, name string) bool { return pattern == name } -// commsSetup holds the parsed notification config from the policy file, -// ready for vault resolution and listener creation. -type commsSetup struct { - pf *launchpolicy.PolicyFile - tokenRefs []string - needsVault bool - sessionLog *slog.Logger -} - -// prepareCommsConfig reads the notification policy and validates token -// refs. Returns a commsSetup indicating whether a vault is needed. Does -// NOT open the vault or start listeners — that is deferred until after -// the PTY is set up so we can show a Panel-based passphrase prompt. -func prepareCommsConfig(dir string, sessionLog *slog.Logger) *commsSetup { - if dir == "" { - dir, _ = os.Getwd() - } - policyPath := FindPolicyFile(dir) - if policyPath == "" { - sessionLog.Debug("no policy file found, skipping comms listeners") - return nil - } - pf := loadPolicyFileFrom(policyPath) - if pf.Notifications == nil { - sessionLog.Debug("no notifications configured in policy file") - return nil - } - - // Validate that token fields use vault references, not plaintext. - if cfg := pf.Notifications.Slack; cfg != nil { - if err := ValidateTokenRef("slack.app_token", cfg.AppToken); err != nil { - sessionLog.Warn("slack token validation failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: %v\n", err) - return nil - } - if err := ValidateTokenRef("slack.bot_token", cfg.BotToken); err != nil { - sessionLog.Warn("slack token validation failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: %v\n", err) - return nil - } - } - if cfg := pf.Notifications.Discord; cfg != nil { - if err := ValidateTokenRef("discord.bot_token", cfg.BotToken); err != nil { - sessionLog.Warn("discord token validation failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: %v\n", err) - return nil - } - } - - if cfg := pf.Notifications.Slack; cfg != nil && cfg.UserToken != "" { - if err := ValidateTokenRef("slack.user_token", cfg.UserToken); err != nil { - sessionLog.Warn("slack token validation failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: %v\n", err) - return nil - } - } - - var tokenRefs []string - if cfg := pf.Notifications.Slack; cfg != nil { - tokenRefs = append(tokenRefs, cfg.AppToken, cfg.BotToken) - if cfg.UserToken != "" { - tokenRefs = append(tokenRefs, cfg.UserToken) - } - } - if cfg := pf.Notifications.Discord; cfg != nil { - tokenRefs = append(tokenRefs, cfg.BotToken) - } - - needsVault := false - for _, ref := range tokenRefs { - if IsVaultRef(ref) { - needsVault = true - break - } - } - - return &commsSetup{ - pf: pf, - tokenRefs: tokenRefs, - needsVault: needsVault, - sessionLog: sessionLog, - } -} - -// startCommsWithVault resolves tokens (using the provided vault, which -// may be nil), creates listeners, and starts them. -func startCommsWithVault(ctx context.Context, setup *commsSetup, v vault.Vault, queue *NotifyQueue, auditStateDir, sessionID string) []comms.Listener { - if setup == nil { - return nil - } - sessionLog := setup.sessionLog - - resolved, err := ResolveTokens(setup.tokenRefs, v) - if err != nil { - sessionLog.Warn("token resolution failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: %v\n", err) - if strings.Contains(err.Error(), "decryption failed") { - fmt.Fprintln(os.Stderr, "aileron: wrong vault passphrase — notifications will not be available this session") - } else { - fmt.Fprintln(os.Stderr, "aileron: notifications will not be available this session") - } - return nil - } - - // Map resolved tokens back to config positions. - idx := 0 - autoDraft := make(map[string]bool) - priority := make(map[string]string) - var created []comms.Listener - if cfg := setup.pf.Notifications.Slack; cfg != nil && cfg.AppToken != "" && cfg.BotToken != "" { - appToken, botToken := resolved[idx], resolved[idx+1] - idx += 2 - var userToken string - if cfg.UserToken != "" { - userToken = resolved[idx] - idx++ - } - channels := make([]string, 0, len(cfg.Channels)) - for _, ch := range cfg.Channels { - channels = append(channels, ch.Name) - if ch.AutoDraft { - autoDraft[ch.Name] = true - } - if ch.Priority != "" { - priority[ch.Name] = ch.Priority - } - } - sessionLog.Info("slack listener configured", - "channels", channels, - "ignore", cfg.Ignore, - "user_token", userToken != "", - ) - sl := comms.NewSlackListener(appToken, botToken, userToken, channels, cfg.Ignore, sessionLog.With("component", "slack")) - created = append(created, sl) - } - if cfg := setup.pf.Notifications.Discord; cfg != nil && cfg.BotToken != "" { - botToken := resolved[idx] - channels := make([]string, 0, len(cfg.Channels)) - for _, ch := range cfg.Channels { - channels = append(channels, ch.Name) - if ch.Priority != "" { - priority[ch.Name] = ch.Priority - } - } - sessionLog.Info("discord listener configured", - "channels", channels, - "ignore", cfg.Ignore, - ) - dl := comms.NewDiscordListener(botToken, channels, cfg.Ignore, sessionLog.With("component", "discord")) - created = append(created, dl) - } - - return StartListeners(ctx, created, queue, os.Stderr, autoDraft, priority, auditStateDir, sessionID, sessionLog) -} - -// startCommsListeners is the legacy convenience wrapper that prepares -// config, opens the vault via the old tty prompt, and starts listeners. -// Used by the non-pty (direct) launch path. -func startCommsListeners(ctx context.Context, dir string, queue *NotifyQueue, auditStateDir, sessionID string, sessionLog *slog.Logger) []comms.Listener { - setup := prepareCommsConfig(dir, sessionLog) - if setup == nil { - return nil - } - - var v vault.Vault - if setup.needsVault { - var err error - v, err = OpenVaultFunc(os.Stderr) - if err != nil { - sessionLog.Warn("vault open failed", "error", err) - fmt.Fprintf(os.Stderr, "aileron: vault: %v\n", err) - return nil - } - } - - return startCommsWithVault(ctx, setup, v, queue, auditStateDir, sessionID) -} - -// StartListeners connects and starts each listener, bridging incoming -// messages to the NotifyQueue. The autoDraft map controls which channels -// trigger automatic draft replies. The priority map controls the -// priority level ("normal", "high") per channel. Returns the -// successfully started listeners. Errors are written to w. -func StartListeners(ctx context.Context, listeners []comms.Listener, queue *NotifyQueue, w io.Writer, autoDraft map[string]bool, priority map[string]string, auditStateDir, sessionID string, log *slog.Logger) []comms.Listener { - var started []comms.Listener - for _, l := range listeners { - if err := l.Connect(ctx); err != nil { - fmt.Fprintf(w, "aileron: %s connect failed: %v\n", l.Service(), err) - log.Warn("listener connect failed", "service", l.Service(), "error", err) - continue - } - msgs, err := l.Listen(ctx) - if err != nil { - fmt.Fprintf(w, "aileron: %s listen failed: %v\n", l.Service(), err) - log.Warn("listener listen failed", "service", l.Service(), "error", err) - continue - } - log.Info("listener started", "service", l.Service()) - started = append(started, l) - go BridgeMessages(msgs, queue, autoDraft, priority, auditStateDir, sessionID, log) - } - return started -} - -// BridgeMessages reads from a comms listener channel and pushes messages -// into the NotifyQueue. The autoDraft map controls which channels trigger -// automatic draft replies. The priority map sets the priority level per -// channel. Exported for testing. -func BridgeMessages(msgs <-chan comms.IncomingMessage, queue *NotifyQueue, autoDraft map[string]bool, priority map[string]string, auditStateDir, sessionID string, log *slog.Logger) { - for msg := range msgs { - preview := msg.Body - if len(preview) > 80 { - preview = preview[:77] + "..." - } - pri := priority[msg.Channel] - if pri == "" { - pri = "normal" - } - log.Debug("message received", - "service", msg.Service, - "channel", msg.Channel, - "author", msg.Author, - "priority", pri, - "preview", preview, - ) - queue.Push(Message{ - ID: msg.ID, - Source: msg.Service, - Channel: msg.Channel, - Author: msg.Author, - Preview: preview, - Body: msg.Body, - Timestamp: msg.Timestamp, - AutoDraft: autoDraft[msg.Channel], - Priority: pri, - }) - if auditStateDir != "" { - audit.AppendMessageEntry(audit.DailyPath(auditStateDir), audit.MessageEntry{ - Timestamp: msg.Timestamp, - SessionID: sessionID, - Event: "message_received", - Service: msg.Service, - Channel: msg.Channel, - Author: msg.Author, - Body: msg.Body, - }) - } - } -} - -// stopCommsListeners shuts down all running listeners. -func stopCommsListeners(listeners []comms.Listener) { - for _, l := range listeners { - l.Close() - } -} - // sessionLogPath returns the path to the session log file, // located alongside the audit log in .aileron/. func sessionLogPath(dir string) string { diff --git a/internal/launch/launcher_test.go b/internal/launch/launcher_test.go index d7aef564..d2cc2ced 100644 --- a/internal/launch/launcher_test.go +++ b/internal/launch/launcher_test.go @@ -2,19 +2,15 @@ package launch_test import ( "context" - "fmt" "io" "log/slog" "os" "path/filepath" "strings" "testing" - "time" "github.com/ALRubinger/aileron/internal/audit" - "github.com/ALRubinger/aileron/internal/comms" "github.com/ALRubinger/aileron/internal/launch" - "github.com/ALRubinger/aileron/internal/vault" ) func nopLogger() *slog.Logger { @@ -482,524 +478,6 @@ func TestPrintSessionSummary_EmptySession(t *testing.T) { } } -func TestBridgeMessages(t *testing.T) { - queue := launch.NewNotifyQueue(10, nil) - msgs := make(chan comms.IncomingMessage, 5) - - go launch.BridgeMessages(msgs, queue, nil, nil, "", "", nopLogger()) - - msgs <- comms.IncomingMessage{ - ID: "msg-1", - Service: "slack", - Channel: "#backend", - Author: "Alice", - Body: "Is the deploy blocked?", - Timestamp: time.Now(), - } - msgs <- comms.IncomingMessage{ - ID: "msg-2", - Service: "slack", - Channel: "#backend", - Author: "Bob", - Body: "No, it went through.", - } - close(msgs) - time.Sleep(50 * time.Millisecond) - - if queue.Len() != 2 { - t.Fatalf("expected 2 messages in queue, got %d", queue.Len()) - } - latest, ok := queue.Latest() - if !ok || latest.Author != "Bob" { - t.Errorf("expected latest from Bob, got %+v", latest) - } - if latest.Source != "slack" { - t.Errorf("Source = %q, want 'slack'", latest.Source) - } -} - - -func TestBridgeMessages_AutoDraft(t *testing.T) { - queue := launch.NewNotifyQueue(10, nil) - msgs := make(chan comms.IncomingMessage, 3) - - autoDraft := map[string]bool{"#backend": true} - go launch.BridgeMessages(msgs, queue, autoDraft, nil, "", "", nopLogger()) - - msgs <- comms.IncomingMessage{ID: "1", Service: "slack", Channel: "#backend", Author: "Sarah", Body: "question"} - msgs <- comms.IncomingMessage{ID: "2", Service: "slack", Channel: "#general", Author: "Bob", Body: "chat"} - close(msgs) - time.Sleep(50 * time.Millisecond) - - all := queue.Messages() - if len(all) != 2 { - t.Fatalf("expected 2 messages, got %d", len(all)) - } - if !all[0].AutoDraft { - t.Error("expected AutoDraft=true for #backend message") - } - if all[1].AutoDraft { - t.Error("expected AutoDraft=false for #general message") - } -} - -func TestLaunch_VaultRefsResolved(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: vault:slack_app - bot_token: vault:slack_bot - channels: - - name: "#test" -`), 0o644) - - // Mock the vault opener to return a vault with the tokens. - v := vault.NewMemVault() - v.Put(nil, "slack_app", []byte("xapp-resolved"), vault.Metadata{}) - v.Put(nil, "slack_bot", []byte("xoxb-resolved"), vault.Metadata{}) - - old := launch.OpenVaultFunc - launch.OpenVaultFunc = func(w io.Writer) (vault.Vault, error) { return v, nil } - defer func() { launch.OpenVaultFunc = old }() - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch with vault refs should succeed: %v", err) - } -} - -func TestLaunch_VaultRefsWithUserToken(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: vault:slack_app - bot_token: vault:slack_bot - user_token: vault:slack_user - channels: - - name: "#test" -`), 0o644) - - v := vault.NewMemVault() - v.Put(nil, "slack_app", []byte("xapp-resolved"), vault.Metadata{}) - v.Put(nil, "slack_bot", []byte("xoxb-resolved"), vault.Metadata{}) - v.Put(nil, "slack_user", []byte("xoxp-resolved"), vault.Metadata{}) - - old := launch.OpenVaultFunc - launch.OpenVaultFunc = func(w io.Writer) (vault.Vault, error) { return v, nil } - defer func() { launch.OpenVaultFunc = old }() - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch with user_token vault ref should succeed: %v", err) - } -} - -func TestLaunch_UserTokenPlaintextRejected(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: vault:slack_app - bot_token: vault:slack_bot - user_token: xoxp-plaintext-bad - channels: - - name: "#test" -`), 0o644) - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - // Should succeed (launch continues without notifications when validation fails). - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch should succeed even when user_token is rejected: %v", err) - } -} - -func TestLaunch_VaultRefsWithDiscord(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - discord: - bot_token: vault:discord_bot - channels: - - name: "123456" -`), 0o644) - - v := vault.NewMemVault() - v.Put(nil, "discord_bot", []byte("discord-token-resolved"), vault.Metadata{}) - - old := launch.OpenVaultFunc - launch.OpenVaultFunc = func(w io.Writer) (vault.Vault, error) { return v, nil } - defer func() { launch.OpenVaultFunc = old }() - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch with discord vault ref should succeed: %v", err) - } -} - -func TestLaunch_VaultOpenError(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: vault:slack_app - bot_token: vault:slack_bot - channels: - - name: "#test" -`), 0o644) - - old := launch.OpenVaultFunc - launch.OpenVaultFunc = func(w io.Writer) (vault.Vault, error) { - return nil, fmt.Errorf("vault locked") - } - defer func() { launch.OpenVaultFunc = old }() - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch should succeed even if vault fails: %v", err) - } -} - -func TestLaunch_RejectsPlaintextTokens(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: xapp-plaintext-token - bot_token: xoxb-plaintext-token - channels: - - name: "#test" -`), 0o644) - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch should succeed even if token validation fails: %v", err) - } -} - -func TestLaunch_VaultRefsNoTTY(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - slack: - app_token: vault:slack_app_token - bot_token: vault:slack_bot_token - channels: - - name: "#test" -`), 0o644) - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch should succeed even if vault prompt fails: %v", err) - } -} - -func TestLaunch_DiscordPlaintextRejected(t *testing.T) { - dir := t.TempDir() - t.Setenv("HOME", dir) - - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte(` -version: 1 -default: allow -notifications: - discord: - bot_token: plaintext-discord-token - channels: - - name: "123456" -`), 0o644) - - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch should succeed: %v", err) - } -} - -func TestBridgeMessages_AuditLog(t *testing.T) { - stateDir := t.TempDir() - queue := launch.NewNotifyQueue(10, nil) - msgs := make(chan comms.IncomingMessage, 2) - - go launch.BridgeMessages(msgs, queue, nil, nil, stateDir, "test-session", nopLogger()) - - msgs <- comms.IncomingMessage{ID: "1", Service: "slack", Channel: "#backend", Author: "Alice", Body: "hello", Timestamp: time.Now()} - msgs <- comms.IncomingMessage{ID: "2", Service: "discord", Channel: "dev-chat", Author: "Bob", Body: "hi", Timestamp: time.Now()} - close(msgs) - time.Sleep(50 * time.Millisecond) - - // Reader sees the daily-rotated layout: scan /audit/. - entries, err := audit.ReadMessageEntries(audit.DailyDir(stateDir)) - if err != nil { - t.Fatal(err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 audit entries, got %d", len(entries)) - } - if entries[0].Event != "message_received" { - t.Errorf("expected 'message_received', got %q", entries[0].Event) - } - if entries[0].Author != "Alice" { - t.Errorf("expected author 'Alice', got %q", entries[0].Author) - } - if entries[0].SessionID != "test-session" { - t.Errorf("expected session 'test-session', got %q", entries[0].SessionID) - } - if entries[1].Service != "discord" { - t.Errorf("expected service 'discord', got %q", entries[1].Service) - } -} - -func TestBridgeMessages_LongPreviewTruncated(t *testing.T) { - queue := launch.NewNotifyQueue(10, nil) - msgs := make(chan comms.IncomingMessage, 1) - - go launch.BridgeMessages(msgs, queue, nil, nil, "", "", nopLogger()) - - longBody := strings.Repeat("x", 100) - msgs <- comms.IncomingMessage{ - ID: "msg-1", - Body: longBody, - } - close(msgs) - time.Sleep(50 * time.Millisecond) - - all := queue.Messages() - if len(all) != 1 { - t.Fatal("expected 1 message") - } - if len(all[0].Preview) > 80 { - t.Errorf("preview should be truncated, got %d chars", len(all[0].Preview)) - } - if all[0].Body != longBody { - t.Error("full body should be preserved") - } -} - -// testListener is a mock comms.Listener for testing StartListeners. -type testListener struct { - service string - connectErr error - listenErr error - msgs chan comms.IncomingMessage - closed bool -} - -func (l *testListener) Service() string { return l.service } -func (l *testListener) Connect(ctx context.Context) error { return l.connectErr } -func (l *testListener) Listen(ctx context.Context) (<-chan comms.IncomingMessage, error) { - if l.listenErr != nil { - return nil, l.listenErr - } - return l.msgs, nil -} -func (l *testListener) Send(ctx context.Context, msg comms.OutgoingMessage) error { return nil } -func (l *testListener) Close() error { l.closed = true; return nil } - -func TestStartListeners_Success(t *testing.T) { - msgs := make(chan comms.IncomingMessage, 10) - l := &testListener{service: "test", msgs: msgs} - queue := launch.NewNotifyQueue(10, nil) - - var stderr strings.Builder - started := launch.StartListeners(context.Background(), []comms.Listener{l}, queue, &stderr, nil, nil, "", "", nopLogger()) - - if len(started) != 1 { - t.Fatalf("expected 1 started listener, got %d", len(started)) - } - if stderr.Len() != 0 { - t.Errorf("expected no errors, got %q", stderr.String()) - } - - // Send a message and verify it reaches the queue. - msgs <- comms.IncomingMessage{ID: "1", Service: "test", Author: "Alice", Body: "hello"} - close(msgs) - time.Sleep(50 * time.Millisecond) - - if queue.Len() != 1 { - t.Errorf("expected 1 message in queue, got %d", queue.Len()) - } -} - -func TestStartListeners_ConnectError(t *testing.T) { - l := &testListener{ - service: "bad", - connectErr: fmt.Errorf("connection refused"), - } - queue := launch.NewNotifyQueue(10, nil) - - var stderr strings.Builder - started := launch.StartListeners(context.Background(), []comms.Listener{l}, queue, &stderr, nil, nil, "", "", nopLogger()) - - if len(started) != 0 { - t.Error("expected 0 started listeners on connect error") - } - if !strings.Contains(stderr.String(), "connect failed") { - t.Errorf("expected connect error in stderr, got %q", stderr.String()) - } -} - -func TestStartListeners_ListenError(t *testing.T) { - l := &testListener{ - service: "bad", - listenErr: fmt.Errorf("listen failed"), - } - queue := launch.NewNotifyQueue(10, nil) - - var stderr strings.Builder - started := launch.StartListeners(context.Background(), []comms.Listener{l}, queue, &stderr, nil, nil, "", "", nopLogger()) - - if len(started) != 0 { - t.Error("expected 0 started listeners on listen error") - } - if !strings.Contains(stderr.String(), "listen failed") { - t.Errorf("expected listen error in stderr, got %q", stderr.String()) - } -} - -func TestStartListeners_Empty(t *testing.T) { - queue := launch.NewNotifyQueue(10, nil) - var stderr strings.Builder - started := launch.StartListeners(context.Background(), nil, queue, &stderr, nil, nil, "", "", nopLogger()) - if len(started) != 0 { - t.Error("expected 0 listeners for nil input") - } -} - -func TestStartListeners_Mixed(t *testing.T) { - good := &testListener{service: "good", msgs: make(chan comms.IncomingMessage, 1)} - bad := &testListener{service: "bad", connectErr: fmt.Errorf("nope")} - queue := launch.NewNotifyQueue(10, nil) - - var stderr strings.Builder - started := launch.StartListeners(context.Background(), []comms.Listener{bad, good}, queue, &stderr, nil, nil, "", "", nopLogger()) - - if len(started) != 1 { - t.Fatalf("expected 1 started, got %d", len(started)) - } - if started[0].Service() != "good" { - t.Errorf("expected 'good' listener, got %q", started[0].Service()) - } -} - -func TestLaunch_CommsListenersStartWithConfig(t *testing.T) { - // Verify that startCommsListeners doesn't panic when no config exists. - dir := t.TempDir() - t.Setenv("HOME", dir) - - // No aileron.yaml → no listeners, no panic. - queue := launch.NewNotifyQueue(10, nil) - _ = queue // listeners would push to this - - // Just verify Launch doesn't crash when there's no notifications config. - script := filepath.Join(dir, "noop.sh") - os.WriteFile(script, []byte("#!/bin/sh\ntrue\n"), 0o755) - os.WriteFile(filepath.Join(dir, "aileron.yaml"), []byte("version: 1\ndefault: allow\n"), 0o644) - - agent := scriptAgent{script: script} - _, err := launch.Launch(context.Background(), launch.LaunchConfig{ - Agent: agent, - ShellShim: "/tmp/fake-shim", - Dir: dir, - }) - if err != nil { - t.Fatalf("launch with no comms config should succeed: %v", err) - } -} - - - func TestEnvGlobMatch(t *testing.T) { tests := []struct { pattern string diff --git a/internal/policy/launch/loader.go b/internal/policy/launch/loader.go index b18a9d1b..8f7b7218 100644 --- a/internal/policy/launch/loader.go +++ b/internal/policy/launch/loader.go @@ -167,9 +167,6 @@ func Merge(base, overlay *PolicyFile) *PolicyFile { // Merge env: union scrub, passthrough beats scrub. result.Env = mergeEnv(base.Env, overlay.Env) - // Merge notifications: overlay wins (last-writer-wins for the whole block). - result.Notifications = mergeNotify(base.Notifications, overlay.Notifications) - // Process overrides: collect all override directives from overlay, then // remove matching rules from allow and ask. Deny rules cannot be overridden. allOverlaySources := append(append([]Rule{}, overlay.Allow...), overlay.Ask...) @@ -240,97 +237,6 @@ func mergeEnv(base, overlay *EnvConfig) *EnvConfig { return result } -// mergeNotify merges notification configs with per-channel granularity. -// Tokens use last-writer-wins. Channels are unioned by name; for channels -// present in both layers the overlay's non-zero fields override the base's. -// Ignore lists are unioned. -func mergeNotify(base, overlay *NotifyConfig) *NotifyConfig { - if base == nil && overlay == nil { - return nil - } - if base == nil { - return overlay - } - if overlay == nil { - return base - } - result := *base - if overlay.Slack != nil { - result.Slack = mergeSlack(base.Slack, overlay.Slack) - } - if overlay.Discord != nil { - result.Discord = mergeDiscord(base.Discord, overlay.Discord) - } - return &result -} - -// mergeSlack merges two SlackNotifyConfigs with per-channel granularity. -func mergeSlack(base, overlay *SlackNotifyConfig) *SlackNotifyConfig { - if base == nil { - return overlay - } - result := *base - // Tokens: last-writer-wins. - if overlay.AppToken != "" { - result.AppToken = overlay.AppToken - } - if overlay.BotToken != "" { - result.BotToken = overlay.BotToken - } - // Channels: union by name, overlay fields override per channel. - result.Channels = mergeChannels(base.Channels, overlay.Channels) - // Ignore: union. - result.Ignore = appendUnique(append([]string{}, base.Ignore...), overlay.Ignore...) - return &result -} - -// mergeDiscord merges two DiscordNotifyConfigs with per-channel granularity. -func mergeDiscord(base, overlay *DiscordNotifyConfig) *DiscordNotifyConfig { - if base == nil { - return overlay - } - result := *base - // Token: last-writer-wins. - if overlay.BotToken != "" { - result.BotToken = overlay.BotToken - } - // Channels: union by name, overlay fields override per channel. - result.Channels = mergeChannels(base.Channels, overlay.Channels) - // Ignore: union. - result.Ignore = appendUnique(append([]string{}, base.Ignore...), overlay.Ignore...) - return &result -} - -// mergeChannels unions two channel lists by name. For channels present in -// both, the overlay's non-zero fields override the base's values. -func mergeChannels(base, overlay []ChannelConfig) []ChannelConfig { - index := make(map[string]int, len(base)) - result := make([]ChannelConfig, len(base)) - copy(result, base) - for i, ch := range result { - index[ch.Name] = i - } - for _, ch := range overlay { - if i, ok := index[ch.Name]; ok { - // Merge: overlay fields win. When a user defines a channel - // in their settings, all specified fields take effect. - if ch.Show != "" { - result[i].Show = ch.Show - } - // AutoDraft is always applied from overlay because the user - // explicitly listing a channel means they intend to control it. - result[i].AutoDraft = ch.AutoDraft - if ch.Priority != "" { - result[i].Priority = ch.Priority - } - } else { - index[ch.Name] = len(result) - result = append(result, ch) - } - } - return result -} - func appendUnique(base []string, items ...string) []string { seen := make(map[string]bool, len(base)) for _, s := range base { diff --git a/internal/policy/launch/schema.go b/internal/policy/launch/schema.go index bf55ea5a..e2b3816b 100644 --- a/internal/policy/launch/schema.go +++ b/internal/policy/launch/schema.go @@ -9,16 +9,20 @@ import ( ) // PolicyFile is the top-level schema for aileron.yaml. +// +// The historic `notifications:` block has moved to the user-scoped +// `~/.aileron/config.yaml` (see [config.AileronConfig]) per ADR-0012: +// the daemon owns Slack/Discord listener lifecycle, not the per-project +// policy. type PolicyFile struct { - Version int `yaml:"version"` - Default string `yaml:"default,omitempty"` // "allow", "deny", "ask" - Settings *Settings `yaml:"settings,omitempty"` - Env *EnvConfig `yaml:"env,omitempty"` - Notifications *NotifyConfig `yaml:"notifications,omitempty"` - Secrets SecretsConfig `yaml:"secrets,omitempty"` - Allow []Rule `yaml:"allow,omitempty"` - Deny []Rule `yaml:"deny,omitempty"` - Ask []Rule `yaml:"ask,omitempty"` + Version int `yaml:"version"` + Default string `yaml:"default,omitempty"` // "allow", "deny", "ask" + Settings *Settings `yaml:"settings,omitempty"` + Env *EnvConfig `yaml:"env,omitempty"` + Secrets SecretsConfig `yaml:"secrets,omitempty"` + Allow []Rule `yaml:"allow,omitempty"` + Deny []Rule `yaml:"deny,omitempty"` + Ask []Rule `yaml:"ask,omitempty"` } // SecretsConfig maps secret names to their target URL patterns. When an @@ -37,46 +41,6 @@ type Settings struct { Timeout int `yaml:"timeout,omitempty"` // seconds to wait for human response } -// NotifyConfig holds notification channel configuration for Slack and Discord. -type NotifyConfig struct { - Slack *SlackNotifyConfig `yaml:"slack,omitempty"` - Discord *DiscordNotifyConfig `yaml:"discord,omitempty"` - QuietHours *QuietHoursConfig `yaml:"quiet_hours,omitempty"` -} - -// QuietHoursConfig defines a daily window during which non-high-priority -// notifications are suppressed. Messages are still queued but the status -// bar and onChange callback are not triggered. -type QuietHoursConfig struct { - Start string `yaml:"start"` // "HH:MM" in 24-hour format, e.g. "22:00" - End string `yaml:"end"` // "HH:MM" in 24-hour format, e.g. "08:00" - Timezone string `yaml:"timezone,omitempty"` // IANA timezone, e.g. "America/New_York"; defaults to local -} - -// SlackNotifyConfig configures Slack integration. -type SlackNotifyConfig struct { - AppToken string `yaml:"app_token,omitempty"` // xapp-... Socket Mode token - BotToken string `yaml:"bot_token,omitempty"` // xoxb-... Bot token (receiving) - UserToken string `yaml:"user_token,omitempty"` // xoxp-... User OAuth token (sending as you) - Channels []ChannelConfig `yaml:"channels,omitempty"` - Ignore []string `yaml:"ignore,omitempty"` -} - -// DiscordNotifyConfig configures Discord integration. -type DiscordNotifyConfig struct { - BotToken string `yaml:"bot_token,omitempty"` - Channels []ChannelConfig `yaml:"channels,omitempty"` - Ignore []string `yaml:"ignore,omitempty"` -} - -// ChannelConfig defines how a single channel is handled. -type ChannelConfig struct { - Name string `yaml:"name"` - Show string `yaml:"show,omitempty"` // "all", "mentions", "none" - AutoDraft bool `yaml:"auto_draft,omitempty"` // route to agent for draft reply - Priority string `yaml:"priority,omitempty"` // "normal", "high" -} - // EnvConfig controls environment variable scrubbing. type EnvConfig struct { Scrub []string `yaml:"scrub,omitempty"` diff --git a/internal/policy/launch/schema_test.go b/internal/policy/launch/schema_test.go index 77ef9761..f2dc86d7 100644 --- a/internal/policy/launch/schema_test.go +++ b/internal/policy/launch/schema_test.go @@ -7,61 +7,6 @@ import ( "gopkg.in/yaml.v3" ) -func TestQuietHours_ParseFromYAML(t *testing.T) { - pf, err := launch.Load(testdataPath("quiet_hours.yaml")) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if pf.Notifications == nil { - t.Fatal("expected Notifications to be non-nil") - } - qh := pf.Notifications.QuietHours - if qh == nil { - t.Fatal("expected QuietHours to be non-nil") - } - if qh.Start != "22:00" { - t.Errorf("Start = %q, want '22:00'", qh.Start) - } - if qh.End != "08:00" { - t.Errorf("End = %q, want '08:00'", qh.End) - } - if qh.Timezone != "America/New_York" { - t.Errorf("Timezone = %q, want 'America/New_York'", qh.Timezone) - } -} - -func TestQuietHours_ChannelPriority(t *testing.T) { - pf, err := launch.Load(testdataPath("quiet_hours.yaml")) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if pf.Notifications == nil || pf.Notifications.Slack == nil { - t.Fatal("expected Slack notifications to be non-nil") - } - channels := pf.Notifications.Slack.Channels - if len(channels) != 2 { - t.Fatalf("expected 2 channels, got %d", len(channels)) - } - if channels[0].Priority != "high" { - t.Errorf("channels[0].Priority = %q, want 'high'", channels[0].Priority) - } - if channels[1].Priority != "normal" { - t.Errorf("channels[1].Priority = %q, want 'normal'", channels[1].Priority) - } -} - -func TestQuietHours_OmittedIsNil(t *testing.T) { - pf, err := launch.Load(testdataPath("basic.yaml")) - if err != nil { - t.Fatalf("Load: %v", err) - } - if pf.Notifications != nil { - t.Error("expected Notifications to be nil for basic.yaml") - } -} - func TestRule_UnmarshalYAML_InvalidNodeKind(t *testing.T) { // A rule must be a string or mapping; a sequence should produce an error. input := ` diff --git a/internal/policy/launch/usersettings_test.go b/internal/policy/launch/usersettings_test.go index 40e0fc1d..8623cbc2 100644 --- a/internal/policy/launch/usersettings_test.go +++ b/internal/policy/launch/usersettings_test.go @@ -171,353 +171,6 @@ default: deny } } -func TestParse_NotificationsConfig(t *testing.T) { - yaml := ` -version: 1 -notifications: - slack: - app_token: xapp-test - bot_token: xoxb-test - channels: - - name: "#backend" - show: all - auto_draft: true - - name: "#incidents" - show: all - priority: high - ignore: - - "#random" - discord: - bot_token: discord-test - channels: - - name: "dev-chat" - show: all - auto_draft: true -` - pf, err := launch.Parse([]byte(yaml)) - if err != nil { - t.Fatal(err) - } - if pf.Notifications == nil { - t.Fatal("expected notifications config") - } - if pf.Notifications.Slack == nil { - t.Fatal("expected slack config") - } - if pf.Notifications.Slack.AppToken != "xapp-test" { - t.Errorf("AppToken = %q, want 'xapp-test'", pf.Notifications.Slack.AppToken) - } - if len(pf.Notifications.Slack.Channels) != 2 { - t.Fatalf("Slack channels = %d, want 2", len(pf.Notifications.Slack.Channels)) - } - ch := pf.Notifications.Slack.Channels[0] - if ch.Name != "#backend" || ch.Show != "all" || !ch.AutoDraft { - t.Errorf("channel 0 = %+v, want #backend/all/auto_draft", ch) - } - if len(pf.Notifications.Slack.Ignore) != 1 || pf.Notifications.Slack.Ignore[0] != "#random" { - t.Errorf("Ignore = %v, want [#random]", pf.Notifications.Slack.Ignore) - } - if pf.Notifications.Discord == nil { - t.Fatal("expected discord config") - } - if len(pf.Notifications.Discord.Channels) != 1 { - t.Errorf("Discord channels = %d, want 1", len(pf.Notifications.Discord.Channels)) - } -} - -func TestMerge_Notifications(t *testing.T) { - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - AppToken: "base-token", - Channels: []launch.ChannelConfig{{Name: "#general", Show: "all"}}, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Discord: &launch.DiscordNotifyConfig{ - BotToken: "discord-token", - Channels: []launch.ChannelConfig{{Name: "dev-chat", Show: "all"}}, - }, - }, - } - merged := launch.Merge(base, overlay) - - if merged.Notifications == nil { - t.Fatal("expected merged notifications") - } - // Slack from base should survive since overlay doesn't define it. - if merged.Notifications.Slack == nil || merged.Notifications.Slack.AppToken != "base-token" { - t.Error("expected Slack config from base to survive") - } - // Discord from overlay. - if merged.Notifications.Discord == nil || merged.Notifications.Discord.BotToken != "discord-token" { - t.Error("expected Discord config from overlay") - } -} - -func TestMerge_NotificationsBaseNil(t *testing.T) { - base := &launch.PolicyFile{Version: 1} - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{AppToken: "tok"}, - }, - } - merged := launch.Merge(base, overlay) - if merged.Notifications == nil || merged.Notifications.Slack == nil { - t.Error("expected overlay notifications when base has none") - } -} - -func TestMerge_NotificationsOverlayNil(t *testing.T) { - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{AppToken: "tok"}, - }, - } - overlay := &launch.PolicyFile{Version: 1} - merged := launch.Merge(base, overlay) - if merged.Notifications == nil || merged.Notifications.Slack == nil { - t.Error("expected base notifications when overlay has none") - } -} - -func TestMerge_NotificationsOverlayWins(t *testing.T) { - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - AppToken: "base-token", - Channels: []launch.ChannelConfig{{Name: "#general"}}, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - AppToken: "overlay-token", - Channels: []launch.ChannelConfig{{Name: "#backend"}}, - }, - }, - } - merged := launch.Merge(base, overlay) - - // Overlay token wins (last-writer-wins for tokens). - if merged.Notifications.Slack.AppToken != "overlay-token" { - t.Errorf("AppToken = %q, want 'overlay-token'", merged.Notifications.Slack.AppToken) - } - // Channels are unioned: base #general + overlay #backend = 2. - if len(merged.Notifications.Slack.Channels) != 2 { - t.Errorf("Channels = %d, want 2 (union of base and overlay)", len(merged.Notifications.Slack.Channels)) - } -} - -func TestMerge_NotificationsPerChannelOverride(t *testing.T) { - // Project sets #backend to show:all, auto_draft:true. - // User overrides to show:mentions, auto_draft:false. - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - AppToken: "project-token", - BotToken: "project-bot", - Channels: []launch.ChannelConfig{ - {Name: "#backend", Show: "all", AutoDraft: true, Priority: "normal"}, - }, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - Channels: []launch.ChannelConfig{ - {Name: "#backend", Show: "mentions", AutoDraft: false}, - }, - }, - }, - } - merged := launch.Merge(base, overlay) - - if merged.Notifications.Slack == nil { - t.Fatal("expected merged slack config") - } - if len(merged.Notifications.Slack.Channels) != 1 { - t.Fatalf("Channels = %d, want 1", len(merged.Notifications.Slack.Channels)) - } - ch := merged.Notifications.Slack.Channels[0] - if ch.Show != "mentions" { - t.Errorf("Show = %q, want 'mentions' (user override)", ch.Show) - } - if ch.AutoDraft { - t.Error("AutoDraft = true, want false (user override)") - } - // Priority not set in overlay, should keep base value. - if ch.Priority != "normal" { - t.Errorf("Priority = %q, want 'normal' (base preserved)", ch.Priority) - } - // Token should survive from base since overlay doesn't set it. - if merged.Notifications.Slack.AppToken != "project-token" { - t.Errorf("AppToken = %q, want 'project-token' (base preserved)", merged.Notifications.Slack.AppToken) - } -} - -func TestMerge_NotificationsChannelUnion(t *testing.T) { - // Project has channels A, B; user adds channel C. - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - Channels: []launch.ChannelConfig{ - {Name: "#chanA", Show: "all"}, - {Name: "#chanB", Show: "mentions"}, - }, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - Channels: []launch.ChannelConfig{ - {Name: "#chanC", Show: "all", AutoDraft: true}, - }, - }, - }, - } - merged := launch.Merge(base, overlay) - - channels := merged.Notifications.Slack.Channels - if len(channels) != 3 { - t.Fatalf("Channels = %d, want 3 (union of A, B, C)", len(channels)) - } - names := make(map[string]bool) - for _, ch := range channels { - names[ch.Name] = true - } - for _, want := range []string{"#chanA", "#chanB", "#chanC"} { - if !names[want] { - t.Errorf("missing channel %s in merged result", want) - } - } -} - -func TestMerge_NotificationsTokenOverride(t *testing.T) { - // User can provide their own token (last-writer-wins). - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - AppToken: "project-app-token", - BotToken: "project-bot-token", - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - BotToken: "user-bot-token", - }, - }, - } - merged := launch.Merge(base, overlay) - - // AppToken not set in overlay => base survives. - if merged.Notifications.Slack.AppToken != "project-app-token" { - t.Errorf("AppToken = %q, want 'project-app-token'", merged.Notifications.Slack.AppToken) - } - // BotToken set in overlay => overlay wins. - if merged.Notifications.Slack.BotToken != "user-bot-token" { - t.Errorf("BotToken = %q, want 'user-bot-token'", merged.Notifications.Slack.BotToken) - } -} - -func TestMerge_NotificationsIgnoreUnion(t *testing.T) { - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - Ignore: []string{"#random", "#spam"}, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Slack: &launch.SlackNotifyConfig{ - Ignore: []string{"#spam", "#offtopic"}, - }, - }, - } - merged := launch.Merge(base, overlay) - - // Union with dedup: #random, #spam, #offtopic = 3. - if len(merged.Notifications.Slack.Ignore) != 3 { - t.Errorf("Ignore = %d, want 3 (union with dedup)", len(merged.Notifications.Slack.Ignore)) - } -} - -func TestMerge_NotificationsDiscordPerChannel(t *testing.T) { - base := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Discord: &launch.DiscordNotifyConfig{ - BotToken: "base-discord", - Channels: []launch.ChannelConfig{ - {Name: "dev-chat", Show: "all", AutoDraft: true}, - }, - Ignore: []string{"memes"}, - }, - }, - } - overlay := &launch.PolicyFile{ - Version: 1, - Notifications: &launch.NotifyConfig{ - Discord: &launch.DiscordNotifyConfig{ - BotToken: "user-discord", - Channels: []launch.ChannelConfig{ - {Name: "dev-chat", Show: "mentions"}, - {Name: "alerts", Show: "all", Priority: "high"}, - }, - Ignore: []string{"memes", "off-topic"}, - }, - }, - } - merged := launch.Merge(base, overlay) - - dc := merged.Notifications.Discord - if dc == nil { - t.Fatal("expected discord config") - } - if dc.BotToken != "user-discord" { - t.Errorf("BotToken = %q, want 'user-discord'", dc.BotToken) - } - if len(dc.Channels) != 2 { - t.Fatalf("Channels = %d, want 2", len(dc.Channels)) - } - // dev-chat should have overlay's Show, overlay's AutoDraft (false). - for _, ch := range dc.Channels { - if ch.Name == "dev-chat" { - if ch.Show != "mentions" { - t.Errorf("dev-chat Show = %q, want 'mentions'", ch.Show) - } - if ch.AutoDraft { - t.Error("dev-chat AutoDraft = true, want false") - } - } - } - if len(dc.Ignore) != 2 { - t.Errorf("Ignore = %d, want 2 (union with dedup)", len(dc.Ignore)) - } -} - func TestLoadWithProfiles_NoUserSettings(t *testing.T) { // With no ~/.aileron/settings.yaml, LoadWithProfiles should still work. t.Setenv("HOME", t.TempDir()) diff --git a/internal/server/main.go b/internal/server/main.go index 9c373f2f..6f0e2335 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -23,6 +23,8 @@ import ( "time" "github.com/ALRubinger/aileron/internal/app" + "github.com/ALRubinger/aileron/internal/comms" + "github.com/ALRubinger/aileron/internal/config" "github.com/ALRubinger/aileron/internal/daemon/discovery" "github.com/ALRubinger/aileron/internal/launch" "github.com/ALRubinger/aileron/internal/sessions/jsonl" @@ -141,6 +143,71 @@ func run(ctx context.Context, log *slog.Logger, opts options) error { }() cfg.Sessions = sessionStore + // Comms wiring (ADR-0012 step 9B-2). The daemon owns Slack/Discord + // listeners now; the launch product no longer binds a per-session + // unix socket. Lazy startup: listener tokens come from the user's + // vault, which is locked at this point. The OnVaultUnlock callback + // fires from POST /v1/vault/unlock and is where listener startup + // actually runs — until the user unlocks, /comms/messages returns + // an empty queue and /comms/send returns "no listener for service". + aileronCfg, err := config.LoadAileronConfig(config.DefaultAileronConfigPath()) + if err != nil { + // Surface but don't abort — a malformed config.yaml shouldn't + // stop the daemon serving the rest of its endpoints. Vault, + // actions, and approvals all still work without notifications. + log.Warn("loading aileron config", "error", err) + aileronCfg = &config.AileronConfig{} + } + if err := comms.ValidateNotificationTokens(aileronCfg.Notifications); err != nil { + log.Warn("notifications config rejected", "error", err) + aileronCfg.Notifications = nil + } + + notifyQueue := comms.NewNotifyQueue(100, nil) + if aileronCfg.Notifications != nil && aileronCfg.Notifications.QuietHours != nil { + notifyQueue.SetQuietHours(aileronCfg.Notifications.QuietHours) + } + listenerRegistry := comms.NewListenerRegistry() + cfg.NotifyQueue = notifyQueue + cfg.Listeners = listenerRegistry + cfg.AuditStateDir = opts.StateDir + + // Vault-unlock callback: when the user unlocks the local vault via + // the webapp passphrase modal, resolve Slack/Discord tokens and + // start listeners. No-op when the user hasn't configured + // notifications, when listeners are already running (Set replaces + // rather than duplicates per-service), or when the unlocked vault + // is the same one we already saw — relock teardown is deliberately + // out of scope (#454 acceptance criteria). + cfg.OnVaultUnlock = func(v vault.Vault) { + if listenerRegistry.Len() > 0 { + log.Debug("listeners already running; skipping startup") + return + } + started, err := comms.StartListeners(ctx, comms.StartOptions{ + Notifications: aileronCfg.Notifications, + Vault: v, + Queue: notifyQueue, + AuditStateDir: opts.StateDir, + Log: log, + }, listenerRegistry) + if err != nil { + log.Warn("listener startup failed", "error", err) + return + } + log.Info("comms listeners started after vault unlock", "started", started) + } + defer listenerRegistry.CloseAll(log) + + // If the daemon launched with a pre-unlocked vault (the + // `selectVault` interactive path on a TTY), fire the callback + // immediately so listeners come up before the first /comms/* + // request lands. The webapp-driven unlock path drives the same + // callback later via UnlockLocalVault. + if cfg.Vault != nil { + cfg.OnVaultUnlock(cfg.Vault) + } + bindAddr := opts.BindAddr if bindAddr == "" { bindAddr = "127.0.0.1:0" From a2378923a295650f8b4bd52a98841f44d70b4825 Mon Sep 17 00:00:00 2001 From: Andrew Lee Rubinger Date: Wed, 6 May 2026 16:32:03 -0700 Subject: [PATCH 2/2] test(comms): cover listener startup + handler error branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push patch coverage above the 80% bar: - Extract `startBuiltListeners` from `StartListeners` so tests can drive the connect/listen/bridge phase with fake listeners. Covers happy path, Connect-error skip, Listen-error skip, mixed good/bad list. StartListeners: 34.8% → 80%; startBuiltListeners: 100%. - Add 503/400/ctx-cancel branch tests for `DraftCommsReply` (73.7% → 89.5%) and matching shape tests for `SendCommsMessage` (80.5% → 90.2%) and `RequestCommsHTTP` (83.9% → 87.1%). internal/comms: 87.0% → 89.5%. internal/app: 79.9% → 80.2%. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/app/handlers_comms_test.go | 96 +++++++++++++++++ internal/comms/listeners.go | 17 ++- internal/comms/listeners_internal_test.go | 126 ++++++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/internal/app/handlers_comms_test.go b/internal/app/handlers_comms_test.go index 12812b23..f7eeb7e1 100644 --- a/internal/app/handlers_comms_test.go +++ b/internal/app/handlers_comms_test.go @@ -245,6 +245,33 @@ func TestSendCommsMessage_NoListenerForService(t *testing.T) { } } +func TestSendCommsMessage_GarbageBody400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + s.listeners.Set("slack", &fakeListener{service: "slack"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", strings.NewReader("not json")) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestSendCommsMessage_NoApprovalQueue503(t *testing.T) { + // Queue + listeners wired but action-approval queue nil → 503. + s := &apiServer{ + log: slog.Default(), + notifyQueue: comms.NewNotifyQueue(10, nil), + listeners: comms.NewListenerRegistry(), + } + body, _ := json.Marshal(api.SendCommsMessageRequest{Service: "slack", Channel: "#x", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/send", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.SendCommsMessage(w, req, "x") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + func TestSendCommsMessage_NoQueue503(t *testing.T) { // Comms queue + listener registry both nil → 503. s := &apiServer{log: slog.Default(), actionApprovals: approval.NewActionApprovalQueue(nil, nil)} @@ -414,6 +441,16 @@ func TestRequestCommsHTTP_DeniedReturnsError(t *testing.T) { } } +func TestRequestCommsHTTP_GarbageBody400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/http", strings.NewReader("not json")) + w := httptest.NewRecorder() + s.RequestCommsHTTP(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + func TestRequestCommsHTTP_MissingFields400(t *testing.T) { s := newCommsServer(t, 5*time.Second) body, _ := json.Marshal(api.RequestCommsHTTPRequest{Method: "GET"}) // missing url @@ -525,6 +562,65 @@ func TestDraftCommsReply_NoListenerForService(t *testing.T) { } } +func TestDraftCommsReply_NoQueue503(t *testing.T) { + // Comms surface unconfigured → 503, matching SendCommsMessage. + s := &apiServer{log: slog.Default(), actionApprovals: approval.NewActionApprovalQueue(nil, nil)} + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "x", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +func TestDraftCommsReply_NoApprovalQueue503(t *testing.T) { + // Listener registry + queue wired but action-approval queue nil → + // 503 (separate failure mode from no comms at all). + s := &apiServer{ + log: slog.Default(), + notifyQueue: comms.NewNotifyQueue(10, nil), + listeners: comms.NewListenerRegistry(), + } + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "x", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", w.Code) + } +} + +func TestDraftCommsReply_GarbageBody400(t *testing.T) { + s := newCommsServer(t, 5*time.Second) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", strings.NewReader("not json")) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestDraftCommsReply_ContextCancelledCollapsesToError(t *testing.T) { + // ctx.Done before the user decides → waitErr is ctx.Err(), the + // non-timeout error branch. + s := newCommsServer(t, 5*time.Second) + s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before the request runs + body, _ := json.Marshal(api.DraftCommsReplyRequest{ReplyTo: "msg-1", Body: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/x/comms/draft", bytes.NewReader(body)).WithContext(ctx) + w := httptest.NewRecorder() + s.DraftCommsReply(w, req, "x") + + var resp api.CommsToolResponse + mustDecode(t, w.Body, &resp) + if resp.Ok { + t.Fatal("expected ok=false on ctx cancellation") + } +} + func TestDraftCommsReply_TimeoutCollapsesToError(t *testing.T) { s := newCommsServer(t, 50*time.Millisecond) s.notifyQueue.Push(comms.Message{ID: "msg-1", Source: "slack", Channel: "#dev"}) diff --git a/internal/comms/listeners.go b/internal/comms/listeners.go index a00d4c4c..e369fa13 100644 --- a/internal/comms/listeners.go +++ b/internal/comms/listeners.go @@ -164,9 +164,22 @@ func StartListeners(ctx context.Context, opts StartOptions, registry *ListenerRe if err != nil { return 0, err } + return startBuiltListeners(ctx, created, autoDraft, priority, opts, registry), nil +} +// startBuiltListeners runs the connect / listen / bridge phase against +// an already-constructed slice of listeners. Split out from +// [StartListeners] so tests can drive it with fake [Listener]s +// without going through the Slack / Discord constructors — +// `buildListeners` returns concrete types that can't be swapped at +// the call site. +// +// Best-effort: a single listener that fails Connect or Listen is +// logged and skipped; the others still start. Returns the count of +// successfully-started listeners. +func startBuiltListeners(ctx context.Context, listeners []Listener, autoDraft map[string]bool, priority map[string]string, opts StartOptions, registry *ListenerRegistry) int { started := 0 - for _, l := range created { + for _, l := range listeners { if err := l.Connect(ctx); err != nil { opts.Log.Warn("listener connect failed", "service", l.Service(), "error", err) continue @@ -181,7 +194,7 @@ func StartListeners(ctx context.Context, opts StartOptions, registry *ListenerRe started++ go bridgeMessages(msgs, opts.Queue, autoDraft, priority, opts.AuditStateDir, opts.Log) } - return started, nil + return started } // buildListeners constructs the concrete Slack/Discord listeners from diff --git a/internal/comms/listeners_internal_test.go b/internal/comms/listeners_internal_test.go index 8f801028..bd92c0c1 100644 --- a/internal/comms/listeners_internal_test.go +++ b/internal/comms/listeners_internal_test.go @@ -178,6 +178,132 @@ func TestBuildListeners_BuildsDiscordListener(t *testing.T) { } } +// startBuiltListeners contract: +// - Connect-error: listener is logged + skipped, not added to registry. +// - Listen-error: same. +// - Happy path: listener registered, bridge goroutine routes messages +// into the queue with autoDraft + priority maps applied. +// - Mixed: one bad + one good → only the good one starts. + +type startTestListener struct { + service string + connectErr error + listenErr error + msgs chan IncomingMessage + closed bool +} + +func (l *startTestListener) Service() string { return l.service } +func (l *startTestListener) Connect(context.Context) error { return l.connectErr } +func (l *startTestListener) Listen(context.Context) (<-chan IncomingMessage, error) { + if l.listenErr != nil { + return nil, l.listenErr + } + return l.msgs, nil +} +func (l *startTestListener) Send(_ context.Context, _ OutgoingMessage) error { return nil } +func (l *startTestListener) Close() error { l.closed = true; return nil } + +func TestStartBuiltListeners_HappyPath_RegistersAndBridges(t *testing.T) { + q := NewNotifyQueue(10, nil) + reg := NewListenerRegistry() + msgs := make(chan IncomingMessage, 2) + l := &startTestListener{service: "slack", msgs: msgs} + + auto := map[string]bool{"#dev": true} + pri := map[string]string{"#alerts": "high"} + started := startBuiltListeners(context.Background(), + []Listener{l}, auto, pri, + StartOptions{Queue: q, Log: nopLogger()}, + reg) + + if started != 1 { + t.Fatalf("started = %d, want 1", started) + } + if _, ok := reg.Get("slack"); !ok { + t.Error("listener not added to registry") + } + + // Push a message — bridge goroutine should land it in the queue. + msgs <- IncomingMessage{ID: "1", Service: "slack", Channel: "#dev", Body: "hi", Timestamp: time.Now()} + close(msgs) + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) && q.Len() == 0 { + time.Sleep(5 * time.Millisecond) + } + if q.Len() != 1 { + t.Errorf("queue size = %d, want 1 (bridge goroutine should have pushed)", q.Len()) + } + if all := q.Messages(); !all[0].AutoDraft { + t.Error("expected AutoDraft=true on #dev message") + } +} + +func TestStartBuiltListeners_ConnectError_Skips(t *testing.T) { + q := NewNotifyQueue(10, nil) + reg := NewListenerRegistry() + bad := &startTestListener{service: "slack", connectErr: errBoom} + + started := startBuiltListeners(context.Background(), + []Listener{bad}, nil, nil, + StartOptions{Queue: q, Log: nopLogger()}, + reg) + + if started != 0 { + t.Errorf("started = %d, want 0 (Connect failed)", started) + } + if reg.Len() != 0 { + t.Errorf("registry size = %d, want 0", reg.Len()) + } +} + +func TestStartBuiltListeners_ListenError_Skips(t *testing.T) { + q := NewNotifyQueue(10, nil) + reg := NewListenerRegistry() + bad := &startTestListener{service: "slack", listenErr: errBoom} + + started := startBuiltListeners(context.Background(), + []Listener{bad}, nil, nil, + StartOptions{Queue: q, Log: nopLogger()}, + reg) + + if started != 0 { + t.Errorf("started = %d, want 0 (Listen failed)", started) + } + if reg.Len() != 0 { + t.Errorf("registry size = %d, want 0", reg.Len()) + } +} + +func TestStartBuiltListeners_MixedGoodAndBad(t *testing.T) { + q := NewNotifyQueue(10, nil) + reg := NewListenerRegistry() + good := &startTestListener{service: "slack", msgs: make(chan IncomingMessage)} + bad := &startTestListener{service: "discord", connectErr: errBoom} + + started := startBuiltListeners(context.Background(), + []Listener{bad, good}, nil, nil, + StartOptions{Queue: q, Log: nopLogger()}, + reg) + + if started != 1 { + t.Errorf("started = %d, want 1 (only good)", started) + } + if _, ok := reg.Get("slack"); !ok { + t.Error("good listener missing from registry") + } + if _, ok := reg.Get("discord"); ok { + t.Error("bad listener should not be in registry") + } +} + +// errBoom is the sentinel used by the connect / listen error tests. +var errBoom = simpleError("boom") + +type simpleError string + +func (e simpleError) Error() string { return string(e) } + // Services contract: returns every registered service name. func TestListenerRegistry_Services(t *testing.T) {