diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index 9ac0381f7..d94c5a03a 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -229,7 +229,7 @@ the live set. - `get_resource_context` (Get resource context, `GET /api/agent/resource-context/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute. - `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array. -- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. +- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources. - `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output. **Operator state (per-resource intent):** diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 173c1f554..e1dde63f6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1670,7 +1670,11 @@ walks the registry once and reuses the in-memory findings index, one bounded approval-store scan grouped by canonical resource id, and a per-resource operator-state SQLite point lookup. Agents pick "where do I focus?" from the fleet view and then drill into the -per-resource bundle for depth. +per-resource bundle for depth. Optional additive filter query params +(hasFindings, severity, technology, resourceType) narrow the sweep to +a relevant subset; they introduce no new lifecycle state, operator +intent, or persistence — they are a read-only projection over the +same in-memory registry walk. `/api/agent/capabilities` is registered in the router's `publicPaths` list so the global auth middleware does not gate diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 1624edcad..3f71e1edc 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -806,6 +806,14 @@ projections: `resources/list` must call the canonical `get_fleet_context` capability, `resources/read` must call `get_resource_context`, and the `pulse://resource/` URI shape, resource content MIME type, and resource read parameter validation live in `internal/agentcapabilities` rather +than in `cmd/pulse-mcp` or an MCP-only inventory registry. The +`get_fleet_context` capability accepts optional additive filter arguments +(hasFindings, severity, technology, resourceType) that an agent may pass +through `tools/call` to narrow the fleet to a relevant subset; the projection +layer forwards non-path GET arguments as URL query parameters so the manifest +contract, not a per-capability adapter, owns query-string transport. The +`resources/list` adapter calls fleet-context with no filters and continues to +receive the full fleet. than in `cmd/pulse-mcp` or an MCP-only inventory registry. They must enter through `ManifestSurfaceResourceCapabilities`, `ListMCPManifestSurfaceResourcesHTTP`, and diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index af6cd99e8..46e3aaac1 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3693,6 +3693,21 @@ approval counts must come from one org-scoped, resource-keyed scan of the bounded pending-approval list, not N calls to the per-resource approval summary helper. Agents pick a focus from the fleet view, then drill into the per-resource bundle for depth. +`/api/agent/fleet-context` accepts optional additive filter query +parameters — `hasFindings=true` (only resources with at least one +active finding), `severity=critical|warning|info` (at least one +finding at that severity), `technology=`, and +`resourceType=` (case-insensitive exact match). All are +optional and compose by intersection; omitting every filter returns +the full fleet (backward compatible). Unknown or unmatched values +return 200 with `resources: []` rather than an error, because "no +resource matched this filter" is a valid triage answer. The filters +narrow what would otherwise be a full-registry sweep, keeping the +payload bounded for agents triaging a large fleet without paging +through healthy resources; the manifest-owned `inputSchema` declares +them so agents discover the filters through the capability surface, +and non-path GET arguments are forwarded as query parameters by the +shared projection layer rather than a per-capability adapter. `/api/agent/resource-capabilities/{id}` is the agent-consumable structured capability surface for a single resource: the governed diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c27e8ecc0..f6dac90ca 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1730,7 +1730,11 @@ artifact or storage/recovery freshness signal. The companion `/api/agent/resource-capabilities/{id}` endpoint is the same kind of read-only projection: it returns the in-memory `Resource.Capabilities` slice the registry already assembles, so -no new persistence or recovery artifact is introduced. The recovery posture +no new persistence or recovery artifact is introduced. The +optional additive filter query params on fleet-context +(hasFindings, severity, technology, resourceType) likewise +introduce no new persistence: they only narrow the in-memory +registry walk, so the recovery posture is unchanged. The recovery posture is identical to the per-resource bundle: when the unified-resources store and the approval store rehydrate on startup, the fleet view recovers the same situated picture without any fleet-specific diff --git a/internal/agentcapabilities/http.go b/internal/agentcapabilities/http.go index 7458d4d4a..c3094394d 100644 --- a/internal/agentcapabilities/http.go +++ b/internal/agentcapabilities/http.go @@ -152,6 +152,12 @@ func BuildCapabilityHTTPRequest(ctx context.Context, baseURL, token string, cap if err != nil { return nil, ProjectedCall{}, err } + // Attach forwarded query parameters (GET/DELETE filter arguments). The + // projection layer owns which arguments become query params; here we only + // encode them onto the request URL. + if len(projected.Query) > 0 { + req.URL.RawQuery = projected.Query.Encode() + } return req, projected, nil } diff --git a/internal/agentcapabilities/http_test.go b/internal/agentcapabilities/http_test.go index 25f6f0c40..2667699df 100644 --- a/internal/agentcapabilities/http_test.go +++ b/internal/agentcapabilities/http_test.go @@ -80,6 +80,73 @@ func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) { } } +func TestBuildCapabilityHTTPRequestForwardsGETFiltersAsQuery(t *testing.T) { + // A GET capability with non-path arguments must forward them as URL query + // parameters (not a body, not dropped) so filter-style tools like + // get_fleet_context can receive agent-supplied filters through the MCP + // tools/call path. + cap := Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + } + req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{ + "hasFindings": "true", + "technology": "docker", + }) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if req.Method != http.MethodGet { + t.Fatalf("method = %q, want GET", req.Method) + } + if projected.HasBody { + t.Fatalf("GET projection must not produce a body; projected = %+v", projected) + } + if projected.Query == nil { + t.Fatal("projected.Query must be populated for a GET with filter args") + } + if got := projected.Query.Get("hasFindings"); got != "true" { + t.Errorf("query hasFindings = %q, want true", got) + } + if got := projected.Query.Get("technology"); got != "docker" { + t.Errorf("query technology = %q, want docker", got) + } + // The query must land on the request URL encoded. + if req.URL.RawQuery == "" { + t.Fatalf("RawQuery empty; url = %q", req.URL.String()) + } + gotFindings := req.URL.Query().Get("hasFindings") + gotTech := req.URL.Query().Get("technology") + if gotFindings != "true" || gotTech != "docker" { + t.Errorf("url query = hasFindings=%q technology=%q", gotFindings, gotTech) + } + if req.Body != nil { + t.Fatalf("GET request must not carry a body; got %+v", req.Body) + } +} + +func TestBuildCapabilityHTTPRequestGETWithoutArgsHasNoQuery(t *testing.T) { + // A GET with no filter arguments must not synthesize a query string — + // backward compatibility for the resources/list adapter and any caller + // that wants the unfiltered result. + cap := Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + } + req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{}) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if projected.Query != nil { + t.Fatalf("projected.Query = %+v, want nil for argless GET", projected.Query) + } + if req.URL.RawQuery != "" { + t.Fatalf("RawQuery = %q, want empty", req.URL.RawQuery) + } +} + func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) { var got struct { Method string diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 23a159ae3..617f67b5a 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -30,6 +30,31 @@ func fleetContextOutputSchema() json.RawMessage { }) } +// fleetContextInputSchema declares the optional additive filters for +// get_fleet_context. None are required; omitting all returns the full fleet +// (backward compatible). Forwarded as URL query parameters for the GET. +func fleetContextInputSchema() json.RawMessage { + return agentObjectInputSchema(nil, map[string]any{ + "hasFindings": map[string]any{ + "type": "boolean", + "description": "When true, return only resources with at least one active finding. Omit to include healthy resources.", + }, + "severity": map[string]any{ + "type": "string", + "enum": []string{"critical", "warning", "info"}, + "description": "Return only resources with at least one finding at this severity. Composes with hasFindings and the dimension filters.", + }, + "technology": map[string]any{ + "type": "string", + "description": "Return only resources whose technology matches exactly (case-insensitive), such as proxmox, docker, lxc, qemu.", + }, + "resourceType": map[string]any{ + "type": "string", + "description": "Return only resources whose canonical type matches exactly (case-insensitive), such as vm, system-container, app-container, agent, storage.", + }, + }) +} + func operationsLoopStatusOutputSchema() json.RawMessage { return agentObjectOutputSchema([]string{"nextAction", "progressLabel", "steps", "patrolEvidenceCount", "patrolIssueEvidenceCount", "activeFindingCount", "pendingApprovalCount", "governedActionCount", "approvedDecisionCount", "rejectedDecisionCount", "verifiedOutcomeCount", "operationsLoopStarterCount", "assistantOperationsLoopStarterCount", "patrolOperationsLoopStarterCount", "patrolControlOperationsLoopStarterCount", "patrolControlCompletedOperationsLoopCount", "patrolControlResolvedOperationsLoopCount", "patrolControlValueState", "patrolAutonomyOperationsLoopStarterCount", "patrolAutonomyCompletedOperationsLoopCount", "patrolAutonomyResolvedOperationsLoopCount", "patrolAutonomyValueState", "proActivationOperationsLoopStarterCount", "proActivationCompletedOperationsLoopCount", "proActivationResolvedOperationsLoopCount", "proActivationValueProofState", "mcpOperationsLoopStarterCount", "externalAgentReady", "windowStart", "generatedAt"}, map[string]any{ "nextAction": stringEnumOption([]string{"run_patrol", "review_findings", "open_assistant", "review_approvals", "open_mcp", "complete"}, "Next recommended Patrol work action an agent or UI should take."), @@ -721,7 +746,7 @@ var canonicalManifest = Manifest{ { Name: "get_fleet_context", Title: "Get fleet context", - Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.", + Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources.", Category: "context", Method: http.MethodGet, Path: FleetContextCapabilityPath, @@ -729,6 +754,7 @@ var canonicalManifest = Manifest{ ActionMode: agentCapabilityActionModeRead, ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly, ResponseShape: "AgentFleetContext", + InputSchema: fleetContextInputSchema(), OutputSchema: fleetContextOutputSchema(), }, { diff --git a/internal/agentcapabilities/projection.go b/internal/agentcapabilities/projection.go index 695ebdf49..9bea18187 100644 --- a/internal/agentcapabilities/projection.go +++ b/internal/agentcapabilities/projection.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "regexp" "strings" ) @@ -516,6 +517,11 @@ type ProjectedCall struct { Path string Body json.RawMessage HasBody bool + // Query carries filter arguments forwarded as URL query parameters for + // GET/DELETE capabilities (which have no request body). Populated from + // leftover public arguments after path-parameter substitution. Empty for + // capabilities that declare no filter arguments. + Query url.Values } // ProjectCapabilityCall turns agent-facing tool arguments into the Pulse HTTP @@ -528,6 +534,25 @@ func ProjectCapabilityCall(cap Capability, args map[string]any) (ProjectedCall, projected := ProjectedCall{Path: path} if !CapabilityHasRequestBody(cap) { + // GET/DELETE carry non-path arguments as URL query parameters rather + // than a JSON body. Collect leftover public arguments after path + // substitution so filter-style capabilities (e.g. get_fleet_context) + // can forward filters without the agent constructing a raw query + // string. Empty when no non-path arguments are supplied — preserving + // the prior unfiltered behavior. + pathParams := PathParameterSet(cap.Path) + query := url.Values{} + for k, v := range PublicToolArguments(args) { + if pathParams[k] { + continue + } + if s, ok := v.(string); ok { + query.Set(k, s) + } + } + if len(query) > 0 { + projected.Query = query + } return projected, nil } diff --git a/internal/api/agent_resource_context.go b/internal/api/agent_resource_context.go index 3e06dceb9..98b1174ac 100644 --- a/internal/api/agent_resource_context.go +++ b/internal/api/agent_resource_context.go @@ -739,6 +739,13 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. } } + // Optional additive filters. All empty/unset = current behavior (return + // the full fleet). Unknown values match nothing and return an empty + // resources array, which is a valid triage answer, not an error: an agent + // asking for technology=docker on a fleet with none should see [], not a + // 400. String filters are case-insensitive and trimmed. + filter := parseFleetContextFilter(r) + for _, resource := range resources { canonical := unified.CanonicalResourceID(resource.ID) summary := AgentFleetResourceSummary{ @@ -759,6 +766,9 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. if count, ok := pendingByResource[canonical]; ok { summary.PendingApprovalCount = count } + if !fleetContextFilterMatches(filter, summary) { + continue + } out.Resources = append(out.Resources, summary) } @@ -766,7 +776,88 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. _ = json.NewEncoder(w).Encode(out) } -// HandleOperationsLoopStatus serves the content-safe status read for the shared +// fleetContextFilter captures the optional, additive query-param filters for +// GET /api/agent/fleet-context. A nil/zero field means "no filter on this +// dimension." Empty values (hasFindingsFilter set but severity empty, etc.) +// still match per their semantics below. +type fleetContextFilter struct { + // hasFindingsFilter is set when the hasFindings query param is present. + // When true, only resources with at least one active finding are kept. + // When false/absent, no finding-presence filter is applied. + hasFindingsFilter bool + hasFindings bool + // severity, when non-empty, keeps only resources with at least one finding + // at that severity (critical/warning/info). Case-insensitive, trimmed. + severity string + // technology, when non-empty, keeps only resources whose Technology matches + // (case-insensitive, trimmed). Empty technology on a resource does not match + // a non-empty filter. + technology string + // resourceType, when non-empty, keeps only resources whose Type matches + // (case-insensitive, trimmed). + resourceType string +} + +// parseFleetContextFilter reads optional fleet-context filter query params. +// follows the bare r.URL.Query().Get convention used elsewhere in the api +// package. Invalid values are silently ignored (no 400) so the endpoint +// degrades gracefully to a broader result rather than erroring on a typo. +func parseFleetContextFilter(r *http.Request) fleetContextFilter { + q := r.URL.Query() + f := fleetContextFilter{} + if raw := strings.TrimSpace(q.Get("hasFindings")); raw != "" { + // Bare sentinel match, matching the codebase convention (e.g. == "1"). + // Any value other than "true"/"1" is treated as absent to avoid + // erroring on an unfamiliar truthy spelling. + if raw == "true" || raw == "1" { + f.hasFindingsFilter = true + f.hasFindings = true + } + } + f.severity = strings.ToLower(strings.TrimSpace(q.Get("severity"))) + f.technology = strings.ToLower(strings.TrimSpace(q.Get("technology"))) + f.resourceType = strings.ToLower(strings.TrimSpace(q.Get("resourceType"))) + return f +} + +// fleetContextFilterMatches reports whether a resource summary passes the +// active fleet-context filter. An empty filter matches everything (the +// backward-compatible unfiltered behavior). +func fleetContextFilterMatches(f fleetContextFilter, s AgentFleetResourceSummary) bool { + if f.hasFindingsFilter && f.hasFindings && s.Findings.Total == 0 { + return false + } + if f.severity != "" { + switch f.severity { + case "critical": + if s.Findings.Critical == 0 { + return false + } + case "warning": + if s.Findings.Warning == 0 { + return false + } + case "info": + if s.Findings.Info == 0 { + return false + } + default: + // Unknown severity matches nothing — a valid empty result, not an + // error. An agent asking for severity=blocker on a fleet that + // uses critical/warning/info should see [], signaling the filter + // did not apply, rather than the whole fleet. + return false + } + } + if f.technology != "" && strings.ToLower(s.Technology) != f.technology { + return false + } + if f.resourceType != "" && strings.ToLower(s.ResourceType) != f.resourceType { + return false + } + return true +} + // Pulse Intelligence Patrol control loop. The canonical route is // `GET /api/agent/patrol-control/status`; `GET /api/agent/operations-loop/status` // remains as a compatibility alias. It deliberately summarizes counts and diff --git a/internal/api/agent_resource_context_test.go b/internal/api/agent_resource_context_test.go index 33de047c7..0de49460f 100644 --- a/internal/api/agent_resource_context_test.go +++ b/internal/api/agent_resource_context_test.go @@ -1396,6 +1396,189 @@ func TestHandleAgentFleetContext_RejectsNonGet(t *testing.T) { } } +// fleetFilterFixture seeds a mixed fleet for filter tests: one healthy VM +// (vm:healthy, no findings), one degraded VM (vm:critical, a critical finding), +// one docker container (container:web, a warning finding), and one LXC +// (container:lxcone, info finding). Covers every filter dimension. +func fleetFilterFixture(t *testing.T) *AgentContextHandler { + t.Helper() + h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{ + {ID: "vm:healthy", Type: "vm", Name: "healthy-vm", Technology: "qemu"}, + {ID: "vm:critical", Type: "vm", Name: "critical-vm", Technology: "qemu"}, + {ID: "container:web", Type: "container", Name: "web", Technology: "docker"}, + {ID: "container:lxcone", Type: "system-container", Name: "lxcone", Technology: "lxc"}, + }) + findings.byResource["vm:critical"] = []AgentResourceFindingSnapshot{ + {ID: "c1", Severity: "critical"}, + } + findings.byResource["container:web"] = []AgentResourceFindingSnapshot{ + {ID: "w1", Severity: "warning"}, + } + findings.byResource["container:lxcone"] = []AgentResourceFindingSnapshot{ + {ID: "i1", Severity: "info"}, + } + return h +} + +func decodeFleetResponse(t *testing.T, rec *httptest.ResponseRecorder) AgentFleetContext { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String()) + } + var fleet AgentFleetContext + if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil { + t.Fatalf("decode: %v", err) + } + return fleet +} + +func fleetCanonicalIDs(fleet AgentFleetContext) []string { + ids := make([]string, 0, len(fleet.Resources)) + for _, r := range fleet.Resources { + ids = append(ids, r.CanonicalID) + } + return ids +} + +func TestHandleAgentFleetContext_HasFindingsFilter(t *testing.T) { + // hasFindings=true must drop healthy resources and keep only those with at + // least one finding — the headline triage filter. vm:healthy has none and + // must be excluded. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + want := []string{"vm:critical", "container:web", "container:lxcone"} + if len(got) != len(want) { + t.Fatalf("hasFindings=true returned %v; want %v (healthy must be excluded)", got, want) + } + gotSet := map[string]bool{} + for _, id := range got { + gotSet[id] = true + } + for _, id := range want { + if !gotSet[id] { + t.Errorf("hasFindings=true missing %s; got %v", id, got) + } + } + if gotSet["vm:healthy"] { + t.Errorf("vm:healthy must be excluded by hasFindings=true; got %v", got) + } +} + +func TestHandleAgentFleetContext_SeverityFilter(t *testing.T) { + // severity=critical must keep only resources with a critical finding. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=critical", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "vm:critical" { + t.Fatalf("severity=critical returned %v; want [vm:critical]", got) + } + + // severity=warning must keep only the warning-bearing resource. + h2 := fleetFilterFixture(t) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=warning", nil) + h2.HandleFleetContext(rec2, req2) + fleet2 := decodeFleetResponse(t, rec2) + got2 := fleetCanonicalIDs(fleet2) + if len(got2) != 1 || got2[0] != "container:web" { + t.Fatalf("severity=warning returned %v; want [container:web]", got2) + } +} + +func TestHandleAgentFleetContext_TechnologyFilter(t *testing.T) { + // technology=docker (case-insensitive) must keep only the docker container. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=docker", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "container:web" { + t.Fatalf("technology=docker returned %v; want [container:web]", got) + } + + // Case-insensitivity: DOCKER must match the same resource. + h2 := fleetFilterFixture(t) + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=DOCKER", nil) + h2.HandleFleetContext(rec2, req2) + fleet2 := decodeFleetResponse(t, rec2) + got2 := fleetCanonicalIDs(fleet2) + if len(got2) != 1 || got2[0] != "container:web" { + t.Fatalf("technology=DOCKER returned %v; want [container:web] (case-insensitive)", got2) + } +} + +func TestHandleAgentFleetContext_ResourceTypeFilter(t *testing.T) { + // resourceType=vm must keep only VMs (the two qemu VMs). + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?resourceType=vm", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 2 { + t.Fatalf("resourceType=vm returned %v; want 2 vms", got) + } + gotSet := map[string]bool{} + for _, id := range got { + gotSet[id] = true + } + if !gotSet["vm:healthy"] || !gotSet["vm:critical"] { + t.Errorf("resourceType=vm must return both vms; got %v", got) + } +} + +func TestHandleAgentFleetContext_NoFilterReturnsAll(t *testing.T) { + // Backward compatibility: no query params returns the full fleet. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + if len(fleet.Resources) != 4 { + t.Fatalf("no-filter fleet returned %d resources; want 4 (backward compat)", len(fleet.Resources)) + } +} + +func TestHandleAgentFleetContext_UnknownFilterValueReturnsEmpty(t *testing.T) { + // An unknown technology must return 200 with an empty array, not a 400. + // The signal "no resource matched this filter" is a valid triage answer. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=nonexistent", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + if fleet.Resources == nil { + t.Fatal("resources is null; want non-nil empty array") + } + if len(fleet.Resources) != 0 { + t.Errorf("unknown technology returned %d resources; want 0", len(fleet.Resources)) + } +} + +func TestHandleAgentFleetContext_FiltersCompose(t *testing.T) { + // Composed filters must intersect: hasFindings=true AND technology=qemu + // returns only the degraded VM (vm:critical), excluding the healthy qemu + // VM and the non-qemu degraded resources. + h := fleetFilterFixture(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true&technology=qemu", nil) + h.HandleFleetContext(rec, req) + fleet := decodeFleetResponse(t, rec) + got := fleetCanonicalIDs(fleet) + if len(got) != 1 || got[0] != "vm:critical" { + t.Fatalf("composed filter returned %v; want [vm:critical] (degraded qemu only)", got) + } +} + func TestHandleAgentOperationsLoopStatus_StartsAtPatrolWithoutIssueEvidence(t *testing.T) { h, _, _ := fleetFixtureHandlers(t, []unified.Resource{ {ID: "vm:101", Type: "vm", Name: "db-01"}, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 565ba9b44..712a0f4ca 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -19391,6 +19391,57 @@ func TestContract_GetFleetContextCapabilityListed(t *testing.T) { } } +// TestContract_GetFleetContextDeclaresAdditiveFilters pins the agent-surface +// contract for fleet triage at scale: the capability must declare optional +// additive filter arguments (hasFindings, severity, technology, resourceType) +// so an agent can narrow a large fleet to a relevant subset without paging, +// and the GET projection layer must forward non-path arguments as URL query +// parameters (not drop them, not body them). Without the inputSchema the +// filters are undiscoverable; without query forwarding they are unreachable +// through the MCP tools/call path. +func TestContract_GetFleetContextDeclaresAdditiveFilters(t *testing.T) { + manifest := agentcapabilities.CanonicalManifest() + cap, ok := agentcapabilities.FindCapability(manifest.Capabilities, agentcapabilities.FleetContextCapabilityName) + if !ok { + t.Fatalf("manifest missing %s", agentcapabilities.FleetContextCapabilityName) + } + if len(cap.InputSchema) == 0 { + t.Fatalf("%s must declare an inputSchema so filter arguments are discoverable through the manifest", agentcapabilities.FleetContextCapabilityName) + } + var schema map[string]any + if err := json.Unmarshal(cap.InputSchema, &schema); err != nil { + t.Fatalf("%s inputSchema must be valid JSON: %v", agentcapabilities.FleetContextCapabilityName, err) + } + properties, _ := schema["properties"].(map[string]any) + for _, filter := range []string{"hasFindings", "severity", "technology", "resourceType"} { + if _, ok := properties[filter]; !ok { + t.Errorf("%s inputSchema must declare filter %q; properties = %v", agentcapabilities.FleetContextCapabilityName, filter, properties) + } + } + // None of the filters are required — omitting all returns the full fleet. + if required, _ := schema["required"].([]any); len(required) != 0 { + t.Errorf("%s filter args must all be optional (backward compat); required = %v", agentcapabilities.FleetContextCapabilityName, required) + } + + // The GET projection must forward non-path arguments as query params. Pin + // the contract end-to-end: a fleet-context call with filter args produces + // a request whose RawQuery carries them. + req, projected, err := agentcapabilities.BuildCapabilityHTTPRequest( + context.Background(), "http://pulse.local/", "token", cap, map[string]any{ + "hasFindings": "true", + "technology": "docker", + }) + if err != nil { + t.Fatalf("BuildCapabilityHTTPRequest: %v", err) + } + if projected.Query == nil || projected.Query.Get("hasFindings") != "true" || projected.Query.Get("technology") != "docker" { + t.Fatalf("GET projection must forward filter args as query params; projected = %+v", projected) + } + if req.URL.RawQuery == "" { + t.Fatalf("filter args must reach the request URL; url = %q", req.URL.String()) + } +} + // TestContract_FindingsResourceOperatorStateProviderIsWired pins the // startup wiring that gives the findings runtime access to // per-resource operator-set state via the API layer's adapter. The