diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 0150ca214e..16be3542e0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,27 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +### Host filesystem alerts retain exact subjects in scoped Patrol + +An alert-button request may name a collected child subject such as +`agent:/disk:docker-data`. Patrol binds that reference through the +shared current-inventory resolver only when the exact collected alert identifier +and subject agree. The canonical owner becomes the collection scope. The alert +keeps its original child identity, filesystem metadata, observed value, threshold +and message in both full and budgeted model context. Related child alert evidence +is retained only for the selected current owner. Missing owners, caller-authored +child paths, names and conflicting source identities cannot broaden scope. + +Linked VM, container and node owners retain their agent observations. Node/host +views of the same proven canonical owner are one identity. Automatic alert type +hints follow that owner while explicit caller type restrictions stay intact. +Admission and execution resolve independently against current collection state. +`patrol_alert_scope_test.go` covers linked owners, evidence, repeated resolution +and rejected subjects. `patrol_host_disk_scope_test.go` exercises the production +host alert evaluator and HTTP handler. The opt-in browser backend and +`scripts/check-host-disk-patrol.mjs` verify `/alerts` admission and feedback at +1440 and 390 pixels. Browser fixtures qualify admission, not model diagnosis. + ### Fully cached Patrol usage — issues #2118 / #2350 Zero ordinary input tokens do not mean a run was free or skipped. Any positive diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index e4bf9257e1..ff859e1348 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,21 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Collected alert subjects share current canonical ownership + +`AlertResourceReferenceResolver` exposes the current-inventory identity owner +used by history correlation to runtime consumers. Supported producer child +references bind through durable source identities only. Resolution reads the +owner, canonical type and source targets under one registry read lock. It never +uses display names or retained history aliases to resurrect a removed owner. +An agent's disk, temperature, RAID and custom observations remain owned when an +operator link folds the agent into a VM, container or node. The presence of the +agent facet is required for that cross-type ownership. Patrol can therefore +select the canonical owner without interpreting alert strings independently. +`alert_scope_test.go` verifies the registry and both read adapters, rejected +references and absent current owners. Scoped Patrol tests cover linked owners +and conflicting unqualified source IDs. + ### Proxmox node temperature cell follows its own open alert — issue #2068 `ProxmoxNodesTable` finds the node's own temperature alert through diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index e8462ae8f8..b98f7cb5e3 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -1540,6 +1540,13 @@ func buildScopeSection(scope *PatrolScope, effectiveIdentityAliases []string) st if scope.AlertIdentifier != "" { sb.WriteString(fmt.Sprintf("Alert Identifier: %s\n", scope.AlertIdentifier)) } + if ac := scope.AlertContext; ac != nil { + sb.WriteString(fmt.Sprintf("Collected alert subject: %q (%q)\nAlert: %s %s, value %.1f, threshold %.1f\nAlert message: %q\n", + ac.ResourceID, ac.ResourceName, ac.Level, ac.AlertType, ac.Value, ac.Threshold, ac.Message)) + if ac.Mountpoint != "" || ac.Device != "" { + sb.WriteString(fmt.Sprintf("Alert filesystem: mountpoint %q, device %q.\n", ac.Mountpoint, ac.Device)) + } + } if scope.FindingID != "" { sb.WriteString(fmt.Sprintf("Finding ID: %s\n", scope.FindingID)) } @@ -2658,7 +2665,7 @@ func patrolActiveAlertsInScope(snap patrolRuntimeState, scopedSet map[string]boo } alerts := make([]models.Alert, 0, len(snap.ActiveAlerts)) for _, alert := range snap.ActiveAlerts { - if seedIsInScope(scopedSet, alert.ResourceID) { + if patrolAlertBelongsToScope(snap, alert.ResourceID, scopedSet) { alerts = append(alerts, alert) } } @@ -2671,7 +2678,7 @@ func patrolResolvedAlertsInScope(snap patrolRuntimeState, scopedSet map[string]b } alerts := make([]models.ResolvedAlert, 0, len(snap.RecentlyResolved)) for _, resolved := range snap.RecentlyResolved { - if seedIsInScope(scopedSet, resolved.Alert.ResourceID) { + if patrolAlertBelongsToScope(snap, resolved.Alert.ResourceID, scopedSet) { alerts = append(alerts, resolved) } } @@ -3685,16 +3692,18 @@ func (p *PatrolService) seedFindingsAndContextState(scope *PatrolScope, snap pat sb.WriteString(fmt.Sprintf("- Guest Memory warning: %.0f%%\n", thresholds.GuestMemWarning)) sb.WriteString(fmt.Sprintf("- Guest Disk warning: %.0f%%, critical: %.0f%%\n", thresholds.GuestDiskWarn, thresholds.GuestDiskCrit)) sb.WriteString(fmt.Sprintf("- Storage warning: %.0f%%, critical: %.0f%%\n", thresholds.StorageWarning, thresholds.StorageCritical)) - if scope != nil && scope.Reason == TriggerReasonAlertFired && scope.AlertContext != nil { + if scope != nil && scope.AlertContext != nil && scope.Reason != TriggerReasonAlertCleared { ac := scope.AlertContext level := ac.Level if level == "" { level = "threshold" } - sb.WriteString(fmt.Sprintf("Note: A live %s alert just fired on the scoped resource: %s = %.1f (threshold %.1f). "+ + sb.WriteString(fmt.Sprintf("Note: This check investigates the collected %s alert on %q: %s = %.1f (threshold %.1f). "+ "Investigate the root cause of THIS breach specifically: what changed, whether it is transient or sustained, the blast radius on dependent workloads, and the concrete remediation. "+ "Reporting a finding for this breach is expected. It is the reason this patrol was triggered.\n\n", - level, ac.AlertType, ac.Value, ac.Threshold)) + level, ac.ResourceName, ac.AlertType, ac.Value, ac.Threshold)) + } else if scope != nil && scope.AlertContext != nil && scope.Reason == TriggerReasonAlertCleared { + sb.WriteString("Note: Verify that the collected alert's breach has resolved using current evidence.\n\n") } else { sb.WriteString("Note: The real-time alerting system monitors these thresholds continuously. Do NOT report findings for threshold breaches. Focus on trends, capacity planning, and issues alerts cannot detect.\n\n") } diff --git a/internal/ai/patrol_alert_scope.go b/internal/ai/patrol_alert_scope.go new file mode 100644 index 0000000000..df425fc51e --- /dev/null +++ b/internal/ai/patrol_alert_scope.go @@ -0,0 +1,117 @@ +package ai + +import ( + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// A child reference selects its owner only through an exact collected alert. +// An arbitrary caller-authored child path cannot broaden Patrol scope. +func patrolScopeAlert(state patrolRuntimeState, scope PatrolScope) *models.Alert { + identifier := strings.TrimSpace(scope.AlertIdentifier) + if identifier == "" { + return nil + } + var found *models.Alert + if scope.Reason != TriggerReasonAlertCleared { + for i := range state.ActiveAlerts { + if state.ActiveAlerts[i].ID != identifier { + continue + } + if found != nil { + return nil + } + alert := state.ActiveAlerts[i] + found = &alert + } + if found != nil { + return found + } + } + // Recurrences reuse an alert ID. An open occurrence takes precedence; + // after clearing, use the latest collected resolution for that ID. + var latest *models.ResolvedAlert + for i := range state.RecentlyResolved { + if state.RecentlyResolved[i].Alert.ID != identifier { + continue + } + candidate := &state.RecentlyResolved[i] + if latest == nil || candidate.ResolvedTime.After(latest.ResolvedTime) { + latest = candidate + } + } + if latest == nil { + return nil + } + alert := latest.Alert + return &alert +} + +func patrolAlertReferenceResolver(state patrolRuntimeState) unifiedresources.AlertResourceReferenceResolver { + if state.alertResourceResolver != nil { + return state.alertResourceResolver + } + if resolver, ok := state.readState.(unifiedresources.AlertResourceReferenceResolver); ok { + return resolver + } + if resolver, ok := state.unifiedResourceProvider.(unifiedresources.AlertResourceReferenceResolver); ok { + return resolver + } + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(state.resourceSnapshot()) + if state.unifiedResourceProvider != nil { + registry.IngestResources(state.unifiedResourceProvider.GetAll()) + } + return registry +} + +func patrolAlertOwnerIDs(resolver unifiedresources.AlertResourceReferenceResolver, binding unifiedresources.AlertResourceReference) []string { + ids := []string{binding.ResourceID} + for _, target := range binding.SourceTargets { + // Unqualified source IDs can collide across collectors. Only project + // IDs that still uniquely identify this owner into the snapshot matcher. + candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID) + if candidate.ResourceID == binding.ResourceID { + ids = append(ids, target.SourceID) + } + } + return ids +} + +// Keep exact child evidence when its current owner is selected. The subject's +// original identity stays on the alert; it is not an extra scoped resource. +func patrolAlertBelongsToScope(state patrolRuntimeState, resourceID string, included map[string]bool) bool { + if included[resourceID] { + return true + } + resolver := patrolAlertReferenceResolver(state) + binding, _ := resolver.ResolveAlertResourceReference(resourceID) + if binding.ResourceID == "" { + return false + } + if included[binding.ResourceID] { + return true + } + for _, target := range binding.SourceTargets { + if !included[target.SourceID] { + continue + } + candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID) + if candidate.ResourceID == binding.ResourceID { + return true + } + } + return false +} + +func patrolAlertContext(alert *models.Alert) *PatrolAlertContext { + if alert == nil { + return nil + } + metadataString := func(key string) string { value, _ := alert.Metadata[key].(string); return value } + return &PatrolAlertContext{AlertType: alert.Type, Level: alert.Level, Value: alert.Value, Threshold: alert.Threshold, + Message: alert.Message, ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, + Mountpoint: metadataString("mountpoint"), Device: metadataString("device")} +} diff --git a/internal/ai/patrol_alert_scope_test.go b/internal/ai/patrol_alert_scope_test.go new file mode 100644 index 0000000000..e75ed7a6e6 --- /dev/null +++ b/internal/ai/patrol_alert_scope_test.go @@ -0,0 +1,169 @@ +package ai + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Control: selecting a known owner must preserve the exact triggering child +// alert. This exposes why a string-prefix rewrite alone is not a complete fix. +func TestSupportHostDiskOwnerScopeRetainsTrigger(t *testing.T) { + host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Disks: []models.Disk{{Mountpoint: "/docker-data", Usage: 85}}} + alert := models.Alert{ID: "repro-disk-alert", ResourceID: "agent:repro-host-source-01/disk:docker-data", Type: "disk", Metadata: map[string]interface{}{"hostId": host.ID, "mountpoint": "/docker-data"}} + snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{alert}} + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(snapshot) + for _, unified := range []bool{false, true} { + name := "snapshot" + state := newPatrolRuntimeState(snapshot) + parentID := host.ID + if unified { + name = "unified" + state = newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry)) + _, id, found := registry.GetByReference(host.ID) + if !found { + t.Fatal("canonical owner missing") + } + parentID = id + } + t.Run(name, func(t *testing.T) { + resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{parentID}, AlertIdentifier: alert.ID}) + if len(resolution.UnmatchedResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("owner control did not resolve: %+v", resolution) + } + filtered := filterPatrolStateByScopeState(state, resolved) + t.Logf("resolved=%+v effective=%v hosts=%d active_alerts=%d", resolved, resolution.EffectiveResourceIDs, len(filtered.Hosts), len(filtered.ActiveAlerts)) + if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ID != alert.ID { + t.Fatalf("owner scope discarded the exact disk alert despite AlertIdentifier=%q", alert.ID) + } + }) + } +} + +func TestPatrolDiskAlertRecurrenceUsesCurrentEvidence(t *testing.T) { + active := models.Alert{ID: "disk-alert", ResourceID: "agent:source-host/disk:data", Type: "disk", Value: 85, Threshold: 85} + old := active + old.Value = 95 + latest := active + latest.Value = 75 + state := newPatrolRuntimeState(models.StateSnapshot{Hosts: []models.Host{{ID: "source-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{active}, RecentlyResolved: []models.ResolvedAlert{{Alert: old, ResolvedTime: time.Unix(1, 0)}, {Alert: latest, ResolvedTime: time.Unix(2, 0)}}}) + for _, tc := range []struct { + reason TriggerReason + value float64 + }{{TriggerReasonManual, 85}, {TriggerReasonAlertCleared, 75}} { + resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{active.ResourceID}, AlertIdentifier: active.ID, Reason: tc.reason}) + if len(resolution.EffectiveResourceIDs) == 0 || resolved.AlertContext == nil || resolved.AlertContext.Value != tc.value { + t.Fatalf("wrong occurrence evidence for %s: %+v, %+v", tc.reason, resolution, resolved.AlertContext) + } + } +} + +func TestPatrolDiskAlertOwnerAndEvidence(t *testing.T) { + for _, ownerType := range []string{"agent", "vm", "system-container", "node"} { + t.Run(ownerType, func(t *testing.T) { + host := models.Host{ID: "disk-host-source", Hostname: "disk-host", MachineID: "disk-machine"} + alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", ResourceName: "disk-host (/docker-data)", Type: "disk", Level: "warning", Value: 85, Threshold: 85, Message: "Disk usage reached 85%", Metadata: map[string]interface{}{"mountpoint": "/docker-data", "device": "/dev/sdb1"}} + unrelated := models.Alert{ID: "other-alert", ResourceID: "agent:other-host/disk:docker-data", Type: "disk"} + snapshot := models.StateSnapshot{Hosts: []models.Host{host, {ID: "other-host", MachineID: "other-machine"}}, ActiveAlerts: []models.Alert{alert, unrelated}, RecentlyResolved: []models.ResolvedAlert{{Alert: alert}, {Alert: unrelated}}} + // Keep this occurrence distinct; ID reuse is covered separately above. + snapshot.RecentlyResolved[0].ID = "resolved-disk-alert" + ownerSourceID := host.ID + switch ownerType { + case "vm": + ownerSourceID = "vm-owner" + snapshot.VMs = []models.VM{{ID: ownerSourceID, VMID: 101, Instance: "pve", Node: "node-owner", Name: "linked-vm"}} + case "system-container": + ownerSourceID = "ct-owner" + snapshot.Containers = []models.Container{{ID: ownerSourceID, VMID: 102, Instance: "pve", Node: "node-owner", Name: "linked-container"}} + case "node": + ownerSourceID = "node-owner" + snapshot.Nodes = []models.Node{{ID: ownerSourceID, Name: "linked-node", Instance: "pve"}} + } + store := unifiedresources.NewMemoryStore() + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, hostID, hostFound := registry.GetByReference(host.ID) + _, ownerID, ownerFound := registry.GetByReference(ownerSourceID) + if !hostFound || !ownerFound { + t.Fatal("fixture owner missing") + } + if ownerType != "agent" { + if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostID, ResourceB: ownerID, PrimaryID: ownerID}); err != nil { + t.Fatal(err) + } + registry = unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + } + state := newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry)) + for _, scope := range []PatrolScope{ + {ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, Reason: TriggerReasonManual}, + AlertTriggeredPatrolScope(alert.ID, alert.ResourceID, "agent", "disk"), + AlertClearedPatrolScope("resolved-disk-alert", alert.ResourceID, "agent"), + } { + resolved, resolution := resolvePatrolScopeState(state, scope) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("%s admission failed: %+v", scope.Reason, resolution) + } + // Admission and execution each resolve against current inventory. + resolved, resolution = resolvePatrolScopeState(state, resolved) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("%s execution failed: %+v", scope.Reason, resolution) + } + filtered := filterPatrolStateByScopeState(state, resolved) + if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ResourceID != alert.ResourceID || len(filtered.RecentlyResolved) != 1 { + t.Fatalf("lost subject or leaked neighbor evidence: %+v", filtered.ActiveAlerts) + } + if resource := filtered.unifiedResourceProvider.GetAll(); len(resource) != 1 || resource[0].ID != ownerID { + t.Fatalf("scope expanded beyond canonical owner: %+v", resource) + } + if resolved.AlertContext == nil || resolved.AlertContext.ResourceID != alert.ResourceID || resolved.AlertContext.Value != 85 || resolved.AlertContext.Threshold != 85 { + t.Fatalf("lost trusted alert evidence: %+v", resolved.AlertContext) + } + prompt := buildScopeSection(&resolved, resolution.EffectiveResourceIDs) + for _, evidence := range []string{alert.ResourceID, "/docker-data", "/dev/sdb1", "value 85.0, threshold 85.0", alert.Message} { + if !strings.Contains(prompt, evidence) { + t.Fatalf("missing %q in prompt: %s", evidence, prompt) + } + } + } + }) + } +} + +func TestPatrolDiskAlertRejectsUntrustedSubjects(t *testing.T) { + for _, scenario := range []string{"missing-alert", "wrong-alert", "duplicate-alert", "wrong-subject", "removed-owner", "display-name-owner", "malformed-child", "source-collision"} { + t.Run(scenario, func(t *testing.T) { + alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", Type: "disk"} + snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "disk-host-source", Hostname: "disk-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{alert}} + scope := PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, AlertContext: &PatrolAlertContext{Message: "caller-authored"}} + switch scenario { + case "missing-alert": + snapshot.ActiveAlerts = nil + case "wrong-alert": + scope.AlertIdentifier = "different-alert" + case "duplicate-alert": + snapshot.ActiveAlerts = append(snapshot.ActiveAlerts, alert) + case "wrong-subject": + scope.ResourceIDs[0] = "agent:disk-host-source/disk:other" + case "removed-owner": + snapshot.Hosts = nil + case "display-name-owner": + snapshot.ActiveAlerts[0].ResourceID = "agent:disk-host/disk:docker-data" + scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID + case "malformed-child": + snapshot.ActiveAlerts[0].ResourceID += "/nested" + scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID + case "source-collision": + snapshot.Nodes = []models.Node{{ID: "disk-host-source", Name: "unrelated-node", Instance: "pve"}} + } + resolved, resolution := resolvePatrolScopeState(newPatrolRuntimeState(snapshot), scope) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 1 || len(resolution.EffectiveResourceIDs) != 0 || resolved.AlertContext != nil { + t.Fatalf("unsafe scope accepted: %+v, context=%+v", resolution, resolved.AlertContext) + } + }) + } +} diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index db7c3decc2..63250c668e 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -1857,20 +1857,20 @@ func patrolKubernetesScopeName(k models.KubernetesCluster) string { return clusterName } -func collectPatrolScopedActiveAlerts(alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert { +func collectPatrolScopedActiveAlerts(state patrolRuntimeState, alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert { filtered := make([]models.Alert, 0, len(alerts)) for _, alert := range alerts { - if includedResourceIDs[alert.ResourceID] { + if patrolAlertBelongsToScope(state, alert.ResourceID, includedResourceIDs) { filtered = append(filtered, alert) } } return filtered } -func collectPatrolScopedResolvedAlerts(alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert { +func collectPatrolScopedResolvedAlerts(state patrolRuntimeState, alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert { filtered := make([]models.ResolvedAlert, 0, len(alerts)) for _, resolved := range alerts { - if includedResourceIDs[resolved.ResourceID] { + if patrolAlertBelongsToScope(state, resolved.ResourceID, includedResourceIDs) { filtered = append(filtered, resolved) } } @@ -1930,10 +1930,10 @@ func collectPatrolScopedPBSBackups(backups []models.PBSBackup, includedGuestVMID func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState, includedResourceIDs map[string]bool, includedGuestVMIDs map[int]bool) { if len(snap.ActiveAlerts) > 0 { - dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap.ActiveAlerts, includedResourceIDs) + dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap, snap.ActiveAlerts, includedResourceIDs) } if len(snap.RecentlyResolved) > 0 { - dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap.RecentlyResolved, includedResourceIDs) + dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap, snap.RecentlyResolved, includedResourceIDs) } if len(snap.ConnectionHealth) > 0 { dst.ConnectionHealth = collectPatrolScopedConnectionHealth(snap.ConnectionHealth, includedResourceIDs) @@ -1949,6 +1949,9 @@ func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState, } func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) patrolRuntimeState { + if len(snap.ActiveAlerts)+len(snap.RecentlyResolved) > 0 { + snap.alertResourceResolver = patrolAlertReferenceResolver(snap) + } matcher := newPatrolScopeMatcher(scope) filterState := newPatrolScopedFilterState(snap) @@ -2006,6 +2009,9 @@ func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) p // context. filteredUnified, unifiedIDs := collectPatrolScopedUnifiedResources(snap.unifiedResourceProvider, matcher) filterState.includeResourceID(unifiedIDs...) + if len(filteredUnified) > 0 { + filterState.filtered.alertResourceResolver = patrolAlertReferenceResolver(snap) + } copyScopedPatrolMetadata(&filterState.filtered, snap, filterState.includedResourceIDs, filterState.includedGuestVMIDs) diff --git a/internal/ai/patrol_state.go b/internal/ai/patrol_state.go index 4b8b3d9b52..e913cedf29 100644 --- a/internal/ai/patrol_state.go +++ b/internal/ai/patrol_state.go @@ -77,6 +77,7 @@ type patrolRuntimeResourceRecord struct { // It intentionally includes only the fields patrol currently consumes, so the // subsystem can stop mirroring the full global StateSnapshot shape. type patrolRuntimeState struct { + alertResourceResolver unifiedresources.AlertResourceReferenceResolver readState unifiedresources.ReadState unifiedResourceProvider UnifiedResourceProvider Nodes []models.Node @@ -170,7 +171,39 @@ func (s patrolRuntimeState) withDerivedProvidersAndResources(resources []unified registry := unifiedresources.NewRegistry(nil) registry.IngestSnapshot(s.resourceSnapshot()) if len(resources) > 0 { - registry.IngestResources(resources) + // Already-unified rows own their source observations. Re-ingesting a + // linked agent snapshot without its operator link would invent a second + // host beside the canonical guest/node. Keep only supplemental snapshot + // rows whose source observations are not already fully represented. + resolver := patrolAlertReferenceResolver(s) + type sourceKey struct { + source unifiedresources.DataSource + id string + } + covered := make(map[sourceKey]bool) + for _, resource := range resources { + binding, _ := resolver.ResolveAlertResourceReference(resource.ID) + for _, target := range binding.SourceTargets { + covered[sourceKey{target.Source, target.SourceID}] = true + } + } + retained := make([]unifiedresources.Resource, 0) + bindings := registry.AlertResourceReferences() + for _, resource := range registry.List() { + targets := bindings[resource.ID].SourceTargets + fullyCovered := len(targets) > 0 + for _, target := range targets { + if !covered[sourceKey{target.Source, target.SourceID}] { + fullyCovered = false + break + } + } + if !fullyCovered { + retained = append(retained, resource) + } + } + registry = unifiedresources.NewRegistry(nil) + registry.IngestResources(append(retained, resources...)) } s.readState = registry s.unifiedResourceProvider = unifiedresources.NewUnifiedAIAdapter(registry) @@ -540,10 +573,23 @@ func canonicalPatrolRuntimeResourceType(kind patrolRuntimeResourceKind) string { // unique known aliases onto the complete identity set for the matching Patrol // runtime record. It never fuzzy-matches and refuses ambiguous aliases. func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (PatrolScope, PatrolScopeResolution) { + if scope.AlertIdentifier != "" { + scope.AlertContext = nil + state.alertResourceResolver = patrolAlertReferenceResolver(state) + } // Recompute private type evidence from the current runtime snapshot on every // resolution pass; callers may synchronously pre-resolve and the accepted run // resolves again to close collection races. scope.resolvedResourceTypes = nil + alert := patrolScopeAlert(state, scope) + var alertOwner unifiedresources.AlertResourceReference + var alertOwnerIDs []string + if alert != nil { + alertOwner, _ = patrolAlertReferenceResolver(state).ResolveAlertResourceReference(alert.ResourceID) + if alertOwner.ResourceID != "" { + alertOwnerIDs = patrolAlertOwnerIDs(patrolAlertReferenceResolver(state), alertOwner) + } + } resolution := PatrolScopeResolution{} for _, requested := range scope.ResourceIDs { if requested = strings.TrimSpace(requested); requested != "" { @@ -628,21 +674,72 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro } } } - switch len(matches) { - case 0: - resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested) - case 1: - for _, id := range matches[0].ids { - addExpanded(id) - addResolved(id) + if len(matches) == 0 && alert != nil && alertOwner.ResourceID != "" { + requestedAlertIdentity := requested == alert.ResourceID + for _, ownerID := range alertOwnerIDs { + requestedAlertIdentity = requestedAlertIdentity || requested == ownerID + } + if requestedAlertIdentity { + for _, ownerID := range alertOwnerIDs { + for _, record := range records { + if record.idTokens[canonicalPatrolScopeToken(ownerID)] { + matches = append(matches, record) + } + } + if len(matches) > 0 { + break + } + } + } + } + // A linked node may expose both node and host views of one canonical + // resource. Only collected-alert ownership can prove those facets are + // the same identity; unrelated ID/alias collisions remain ambiguous. + sameAlertOwner := alertOwner.ResourceID != "" && len(matches) > 0 + if sameAlertOwner { + for _, record := range matches { + owner, _ := patrolAlertReferenceResolver(state).ResolveAlertResourceReference(record.ids[0]) + if owner.ResourceID != alertOwner.ResourceID { + sameAlertOwner = false + break + } + } + } + switch { + case len(matches) == 0: + resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested) + case len(matches) == 1 || sameAlertOwner: + for _, record := range matches { + for _, id := range record.ids { + addExpanded(id) + addResolved(id) + } + addResolvedType(record.resourceType) + } + if sameAlertOwner { + for _, id := range alertOwnerIDs { + addExpanded(id) + addResolved(id) + } } - addResolvedType(matches[0].resourceType) default: resolution.AmbiguousResourceIDs = append(resolution.AmbiguousResourceIDs, requested) } } scope.ResourceIDs = expanded scope.resolvedIdentityOnly = len(expanded) > 0 + if alert != nil { + included := make(map[string]bool, len(expanded)) + for _, id := range expanded { + included[id] = true + } + if patrolAlertBelongsToScope(state, alert.ResourceID, included) { + scope.AlertContext = patrolAlertContext(alert) + if scope.alertResourceTypeHint { + scope.ResourceTypes = append([]string(nil), scope.resolvedResourceTypes...) + } + } + } if len(expanded) > 0 { filtered := filterPatrolStateByScopeState(state, scope) resolution.EffectiveResourceIDs = patrolRuntimeSortedResourceIDs(filtered) diff --git a/internal/ai/patrol_triggers.go b/internal/ai/patrol_triggers.go index f6043225ac..88b92f0409 100644 --- a/internal/ai/patrol_triggers.go +++ b/internal/ai/patrol_triggers.go @@ -108,6 +108,9 @@ type PatrolScope struct { // scopes. It is used only to reduce the provider tool manifest; it never // broadens collection scope or mutation authority. resolvedResourceTypes []string + // Alert constructors receive the producer's type hint. Current canonical + // ownership can replace that hint when an agent is linked into a guest/node. + alertResourceTypeHint bool } // PatrolScopeResolution records how caller-supplied identities map onto the @@ -123,11 +126,15 @@ type PatrolScopeResolution struct { // PatrolAlertContext describes the alert that triggered a scoped patrol, so the // patrol prompt can focus the investigation on the specific breach. type PatrolAlertContext struct { - AlertType string // cpu, memory, disk, etc. - Level string // warning | critical - Value float64 // observed metric value at fire time - Threshold float64 // threshold that was crossed - Message string // human-readable alert message + ResourceID string + ResourceName string + Mountpoint string + Device string + AlertType string // cpu, memory, disk, etc. + Level string // warning | critical + Value float64 // observed metric value at fire time + Threshold float64 // threshold that was crossed + Message string // human-readable alert message } // PatrolDepth controls how thorough a patrol run should be @@ -693,26 +700,28 @@ func patrolScopeResourceTypes(resourceType string) []string { // AlertTriggeredPatrolScope creates a patrol scope for an alert that fired func AlertTriggeredPatrolScope(alertIdentifier, resourceID, resourceType, alertType string) PatrolScope { return PatrolScope{ - ResourceIDs: []string{resourceID}, - ResourceTypes: patrolScopeResourceTypes(resourceType), - Depth: PatrolDepthQuick, - Reason: TriggerReasonAlertFired, - Context: "Alert: " + alertType, - Priority: triggerPriorityAlertFired, - AlertIdentifier: alertIdentifier, + ResourceIDs: []string{resourceID}, + ResourceTypes: patrolScopeResourceTypes(resourceType), + Depth: PatrolDepthQuick, + Reason: TriggerReasonAlertFired, + Context: "Alert: " + alertType, + Priority: triggerPriorityAlertFired, + AlertIdentifier: alertIdentifier, + alertResourceTypeHint: true, } } // AlertClearedPatrolScope creates a patrol scope for an alert that cleared func AlertClearedPatrolScope(alertIdentifier, resourceID, resourceType string) PatrolScope { return PatrolScope{ - ResourceIDs: []string{resourceID}, - ResourceTypes: patrolScopeResourceTypes(resourceType), - Depth: PatrolDepthQuick, - Reason: TriggerReasonAlertCleared, - Context: "Verify resolution", - Priority: triggerPriorityAlertCleared, - AlertIdentifier: alertIdentifier, + ResourceIDs: []string{resourceID}, + ResourceTypes: patrolScopeResourceTypes(resourceType), + Depth: PatrolDepthQuick, + Reason: TriggerReasonAlertCleared, + Context: "Verify resolution", + Priority: triggerPriorityAlertCleared, + AlertIdentifier: alertIdentifier, + alertResourceTypeHint: true, } } diff --git a/internal/api/patrol_host_disk_scope_test.go b/internal/api/patrol_host_disk_scope_test.go new file mode 100644 index 0000000000..1bbd9c0f32 --- /dev/null +++ b/internal/api/patrol_host_disk_scope_test.go @@ -0,0 +1,197 @@ +package api + +import ( + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Reproduce the alert button's exact request using a disk alert emitted by +// CheckHost. Keep Patrol busy so successful identity admission cannot call a +// model or start a background run. A valid identity must reach the busy guard. +func TestSupportHostDiskInvestigationContract(t *testing.T) { + for _, linked := range []bool{false, true} { + for _, unified := range []bool{false, true} { + name := "snapshot" + if unified { + name = "unified" + } + if linked { + name += "-linked-vm" + } else { + name += "-standalone" + } + t.Run(name, func(t *testing.T) { + host := models.Host{ + ID: "repro-host-source-01", Hostname: "docker-host-ext-01", + DisplayName: "docker-host-ext-01", Status: "online", + MachineID: "repro-machine-01", LastSeen: time.Now(), + Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}}, + } + snapshot := models.StateSnapshot{Hosts: []models.Host{host, { + ID: "unrelated-host", Hostname: "unrelated", MachineID: "unrelated-machine", Status: "online", + }}} + if linked { + host.LinkedVMID = "vm-101" + snapshot.Hosts[0] = host + snapshot.VMs = []models.VM{{ID: "vm-101", Name: host.Hostname, VMID: 101, Node: "pve-1", Instance: "pve", Status: "running"}} + } + manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore()) + t.Cleanup(manager.Stop) + // The production evaluator is unchanged; only temporal delay and + // unrelated default policies are disabled for this immediate proof. + setUnexportedField(t, manager, "config", alerts.AlertConfig{ + Enabled: true, ActivationState: alerts.ActivationActive, + AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}, + Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{}, + }) + manager.CheckHost(host) + active := manager.GetActiveAlerts() + if len(active) != 1 { + t.Fatalf("disk fixture did not produce exactly one alert: %+v", active) + } + alert := active[0] + if alert.ResourceID != "agent:repro-host-source-01/disk:docker-data" || alert.Type != "disk" { + t.Fatalf("unexpected emitted disk identity: %+v", alert) + } + t.Logf("emitted resource_id=%q resource_name=%q hostId=%v mountpoint=%v", alert.ResourceID, alert.ResourceName, alert.Metadata["hostId"], alert.Metadata["mountpoint"]) + snapshot.ActiveAlerts = []models.Alert{hostDiskPatrolStateAlert(alert)} + handler, patrol, _, _ := setupAIHandlerWithPatrol(t) + seedReadyAnthropicPatrolRuntime(t, handler) + handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot}) + parentID := host.ID + if unified { + store := unifiedresources.NewMemoryStore() + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + if linked { + _, hostCanonicalID, hostFound := registry.GetByReference(host.ID) + _, vmCanonicalID, vmFound := registry.GetByReference("vm-101") + if !hostFound || !vmFound { + t.Fatal("manual-link fixture is missing a source") + } + if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostCanonicalID, ResourceB: vmCanonicalID, PrimaryID: vmCanonicalID}); err != nil { + t.Fatal(err) + } + registry = unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, foldedOwner, found := registry.GetByReference(host.ID) + if !found || foldedOwner != vmCanonicalID { + t.Fatalf("fixture host did not merge into VM: %q want %q", foldedOwner, vmCanonicalID) + } + t.Logf("manual-link host=%q folded into VM=%q", hostCanonicalID, foldedOwner) + } + handler.defaultAIService.SetReadState(registry) + patrol.SetUnifiedResourceProvider(unifiedresources.NewUnifiedAIAdapter(registry)) + resource, canonicalID, ok := registry.GetByReference(host.ID) + if !ok { + t.Fatalf("host source identity missing from canonical registry") + } + parentID = canonicalID + t.Logf("canonical owner id=%q type=%q aliases=%+v sources=%+v", canonicalID, resource.Type, resource.Canonical, registry.SourceTargets(canonicalID)) + if _, _, ok := registry.GetByReference(alert.ResourceID); ok { + t.Log("disk alert unexpectedly has a canonical registry resource") + } + } + _, parentResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{parentID}}) + if len(parentResolution.UnmatchedResourceIDs) != 0 || len(parentResolution.AmbiguousResourceIDs) != 0 || len(parentResolution.EffectiveResourceIDs) == 0 { + t.Fatalf("parent control did not resolve: %+v", parentResolution) + } + t.Logf("parent control resolution=%+v", parentResolution) + _, diskResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID}) + t.Logf("disk resolution=%+v", diskResolution) + setUnexportedField(t, patrol, "runInProgress", true) + setUnexportedField(t, patrol, "currentRunID", "repro-busy-sentinel") + request := func(resourceID string) *httptest.ResponseRecorder { + body, err := json.Marshal(map[string]any{"resource_ids": []string{resourceID}, "alert_identifier": alert.ID, "alert_type": alert.Type}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + handler.HandleForcePatrol(rec, newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", bytes.NewReader(body))) + return rec + } + control := request(parentID) + if control.Code != http.StatusConflict || !strings.Contains(control.Body.String(), "patrol_already_running") { + t.Fatalf("known parent did not reach downstream busy guard: %d %s", control.Code, control.Body.String()) + } + rec := request(alert.ResourceID) + t.Logf("disk HTTP status=%d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "patrol_already_running") { + t.Fatalf("disk alert must resolve before busy admission, got %d %s", rec.Code, rec.Body.String()) + } + }) + } + } +} + +// Opt-in loopback backend for scripts/check-host-disk-patrol.mjs. The browser +// exercises the real admission handler against a production-emitted alert. +// Inference is absent, so acceptance is not a claim of completed diagnosis. +func TestPatrolHostDiskBrowserBackend(t *testing.T) { + address := os.Getenv("PULSE_HOST_DISK_BROWSER_ADDRESS") + if address == "" { + t.Skip("opt-in browser admission fixture") + } + hostIP, _, err := net.SplitHostPort(address) + if err != nil || hostIP != "127.0.0.1" { + t.Fatal("browser fixture requires a loopback address") + } + host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Status: "online", LastSeen: time.Now(), Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}}} + manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore()) + t.Cleanup(manager.Stop) + setUnexportedField(t, manager, "config", alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationActive, AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}, Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{}}) + manager.CheckHost(host) + active := manager.GetActiveAlerts() + if len(active) != 1 { + t.Fatalf("fixture alert missing: %+v", active) + } + stop := make(chan struct{}, 1) + mux := http.NewServeMux() + mux.HandleFunc("/proof/alert", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(active[0]) }) + mux.HandleFunc("/proof/stop", func(w http.ResponseWriter, r *http.Request) { stop <- struct{}{} }) + mux.HandleFunc("/api/ai/patrol/run", func(w http.ResponseWriter, r *http.Request) { + handler, patrol, _, _ := setupAIHandlerWithPatrol(t) + seedReadyAnthropicPatrolRuntime(t, handler) + handler.defaultAIService.SetChatService(nil) + snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{hostDiskPatrolStateAlert(active[0])}} + if r.URL.Query().Get("case") == "unresolved" { + snapshot.Hosts = nil + } + handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot}) + if r.URL.Query().Get("case") == "busy" { + setUnexportedField(t, patrol, "runInProgress", true) + setUnexportedField(t, patrol, "currentRunID", "browser-busy") + } + handler.HandleForcePatrol(w, r) + }) + listener, err := net.Listen("tcp", address) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(mux) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + t.Logf("browser admission fixture ready at %s", server.URL) + select { + case <-stop: + case <-time.After(4 * time.Minute): + t.Fatal("browser fixture was not stopped") + } +} + +func hostDiskPatrolStateAlert(alert alerts.Alert) models.Alert { + return models.Alert{ID: alert.ID, Type: alert.Type, Level: string(alert.Level), ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, Message: alert.Message, Value: alert.Value, Threshold: alert.Threshold, StartTime: alert.StartTime, Metadata: alert.Metadata} +} diff --git a/internal/unifiedresources/history_identity.go b/internal/unifiedresources/history_identity.go index 3cb351ee8d..5c9ea41a1d 100644 --- a/internal/unifiedresources/history_identity.go +++ b/internal/unifiedresources/history_identity.go @@ -10,6 +10,76 @@ import ( "time" ) +// AlertResourceReference binds a collected alert subject to current canonical +// inventory. Source targets are durable identities, never display names. +type AlertResourceReference struct { + ResourceID string + ResourceType ResourceType + SourceTargets []SourceTarget +} + +type AlertResourceReferenceResolver interface { + ResolveAlertResourceReference(string) (AlertResourceReference, bool) +} + +// AlertResourceReferences captures current source ownership in one generation. +// Scoped projections use this batch view instead of scanning every source +// mapping once per resource when rebuilding a read-only registry. +func (rr *ResourceRegistry) AlertResourceReferences() map[string]AlertResourceReference { + if rr == nil { + return nil + } + rr.mu.RLock() + defer rr.mu.RUnlock() + targets := rr.cachedSourceTargets + if rr.viewsDirty || targets == nil { + targets = rr.buildSourceTargetsIndexLocked() + } + bindings := make(map[string]AlertResourceReference, len(rr.resources)) + for id, resource := range rr.resources { + bindings[id] = AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: append([]SourceTarget(nil), targets[id]...)} + } + return bindings +} + +// ResolveAlertResourceReference shares current-inventory identity with history +// correlation. It never consults retained history bindings or matches names. +// claimed is true for an inventory conflict as well as a successful binding; +// conflicts have an empty ResourceID and must not fall back to another source. +func (rr *ResourceRegistry) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + if rr == nil { + return AlertResourceReference{}, false + } + rr.mu.RLock() + defer rr.mu.RUnlock() + id, claimed := rr.resolveHistoryReferenceLocked(ref) + if id == "" { + return AlertResourceReference{}, claimed + } + resource := rr.resources[id] + if resource == nil { + return AlertResourceReference{}, true + } + var targets []SourceTarget + if !rr.viewsDirty && rr.cachedSourceTargets != nil { + targets = append([]SourceTarget(nil), rr.cachedSourceTargets[id]...) + } else { + targets = rr.collectSourceTargetsLocked(id, resource.Type) + } + return AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: targets}, true +} + +func (a *UnifiedAIAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + if a == nil || a.registry == nil { + return AlertResourceReference{}, false + } + return a.registry.ResolveAlertResourceReference(ref) +} + +func (a *MonitorAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + return a.currentRegistry().ResolveAlertResourceReference(ref) +} + // legacyDockerHistoryIdentity accepts the source identifier emitted by Docker // alerts only when it contains a complete container ID. Names and short IDs // cannot establish durable identity after inventory removal. @@ -128,7 +198,7 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp if rest, found := strings.CutPrefix(ref, "agent:"); found { hostID, child, _ := strings.Cut(rest, "/") kind, label, _ := strings.Cut(child, ":") - if hostID == "" || label == "" { + if hostID == "" || label == "" || strings.ContainsAny(label, "/\\") { return "", "", false } switch kind { @@ -160,12 +230,19 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp // possibly ambiguously; an ambiguous or conflicting reference resolves to // nothing and must not follow a retained binding. func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID string, claimed bool) { - ref = CanonicalResourceID(ref) - if rr == nil || ref == "" { + if rr == nil { return "", false } rr.mu.RLock() defer rr.mu.RUnlock() + return rr.resolveHistoryReferenceLocked(ref) +} + +func (rr *ResourceRegistry) resolveHistoryReferenceLocked(ref string) (resourceID string, claimed bool) { + ref = CanonicalResourceID(ref) + if ref == "" { + return "", false + } if isDockerHistoryReference(ref) { resourceID = rr.dockerHistoryOwnerLocked(ref) return resourceID, resourceID != "" @@ -181,7 +258,11 @@ func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID stri // conflict: the event keeps its own reference. owners := rr.durableHistoryMatchesLocked(ownerRef) for id := range owners { - if len(owners) != 1 || CanonicalResourceType(rr.resources[id].Type) != ownerType { + owner := rr.resources[id] + // A linked agent retains ownership of its disk/sensor observations + // when its canonical resource is a VM, container or node. + typeMatches := CanonicalResourceType(owner.Type) == ownerType || (ownerType == ResourceTypeAgent && owner.Agent != nil) + if len(owners) != 1 || !typeMatches { return "", true } matches[id] = struct{}{} diff --git a/internal/unifiedresources/history_identity_test.go b/internal/unifiedresources/history_identity_test.go index 39d0ff5ce7..95a303ab0f 100644 --- a/internal/unifiedresources/history_identity_test.go +++ b/internal/unifiedresources/history_identity_test.go @@ -858,3 +858,53 @@ func TestProxmoxPhysicalDiskAlertReferenceShape(t *testing.T) { require.Nil(t, ProxmoxPhysicalDiskAlertIdentifiers(ref), ref) } } + +func TestAlertResourceReferenceRequiresCurrentDurableOwner(t *testing.T) { + snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "host-source", Hostname: "display-name", MachineID: "machine"}}} + registry := NewRegistry(nil) + registry.IngestSnapshot(snapshot) + _, ownerID, found := registry.GetByReference("host-source") + if !found { + t.Fatal("fixture owner missing") + } + for _, resolver := range []AlertResourceReferenceResolver{registry, NewUnifiedAIAdapter(registry), NewMonitorAdapter(registry)} { + binding, claimed := resolver.ResolveAlertResourceReference("agent:host-source/disk:docker-data") + if !claimed || binding.ResourceID != ownerID || binding.ResourceType != ResourceTypeAgent || len(binding.SourceTargets) == 0 { + t.Fatalf("lost canonical owner/source: %+v, claimed=%v", binding, claimed) + } + for _, ref := range []string{"agent:display-name/disk:docker-data", "agent:host-source/disk:", "agent:host-source/disk:data/nested", "agent:host-source/service:sshd", "agent:removed/disk:data"} { + if binding, _ := resolver.ResolveAlertResourceReference(ref); binding.ResourceID != "" { + t.Fatalf("bound untrusted reference %q: %+v", ref, binding) + } + } + } + // A fresh collection without the owner cannot borrow retained history. + removed := NewRegistry(nil) + if binding, _ := removed.ResolveAlertResourceReference("agent:host-source/disk:docker-data"); binding.ResourceID != "" { + t.Fatalf("removed owner resolved: %+v", binding) + } +} + +func TestHistoryIdentityLinkedAgentDiskUsesCanonicalOwner(t *testing.T) { + store := NewMemoryStore() + snapshot := models.StateSnapshot{ + Hosts: []models.Host{{ID: "host-source", MachineID: "machine"}}, + VMs: []models.VM{{ID: "vm-source", VMID: 101, Instance: "pve", Node: "node"}}, + } + registry := NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, hostID, hostFound := registry.GetByReference("host-source") + _, vmID, vmFound := registry.GetByReference("vm-source") + require.True(t, hostFound) + require.True(t, vmFound) + require.NoError(t, store.AddLink(ResourceLink{ResourceA: hostID, ResourceB: vmID, PrimaryID: vmID})) + registry = NewRegistry(store) + registry.IngestSnapshot(snapshot) + ownerID, claimed := registry.resolveHistoryReference("agent:host-source/disk:data") + require.True(t, claimed) + require.Equal(t, vmID, ownerID) + for _, ref := range []string{"agent:host-source/disk:data/nested", "agent:missing/disk:data"} { + ownerID, _ := registry.resolveHistoryReference(ref) + require.Empty(t, ownerID, ref) + } +} diff --git a/scripts/check-host-disk-patrol.mjs b/scripts/check-host-disk-patrol.mjs new file mode 100644 index 0000000000..b2a4d62c76 --- /dev/null +++ b/scripts/check-host-disk-patrol.mjs @@ -0,0 +1,120 @@ +// Admission/UI contract proof with a production-emitted disk alert. +// GET estate/session data is synthetic. Patrol POSTs use the current Go handler +// from TestPatrolHostDiskBrowserBackend. No inference or infrastructure writes. +import { chromium, expect } from '../tests/integration/node_modules/@playwright/test/index.mjs'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; + +const base = process.env.PLAYWRIGHT_BASE_URL; +const backend = process.env.PULSE_HOST_DISK_BROWSER_URL; +if (!base || !backend) throw new Error('Set PLAYWRIGHT_BASE_URL and PULSE_HOST_DISK_BROWSER_URL to isolated loopback servers'); +for (const url of [base, backend]) { + if (new URL(url).hostname !== '127.0.0.1') throw new Error('Proof servers must use loopback'); +} +const output = new URL('../tmp/host-disk-patrol/', import.meta.url); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const results = []; +const errors = []; +const diagnostics = []; +let lastPage; +try { + const request = await browser.newContext(); + const alert = await (await request.request.get(`${backend}/proof/alert`)).json(); + await request.close(); + expect(alert.resourceId).toBe('agent:repro-host-source-01/disk:docker-data'); + expect(alert.value).toBe(85); + expect(alert.threshold).toBe(85); + for (const width of [1440, 390]) { + const context = await browser.newContext({ viewport: { width, height: 1000 } }); + const page = await context.newPage(); + lastPage = page; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') diagnostics.push(message.text()); + }); + page.on('requestfailed', (request) => diagnostics.push(`${request.url()}: ${request.failure()?.errorText}`)); + let mode = 'accepted'; + const admissions = []; + const state = { nodes: [], vms: [], containers: [], dockerHosts: [], hosts: [], storage: [], resources: [], activeAlerts: [alert], recentlyResolved: [], connectionHealth: {}, timestamp: Date.now() }; + await page.routeWebSocket('**/ws**', (socket) => socket.send(JSON.stringify({ type: 'initialState', data: state }))); + await page.route('**/api/**', async (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname; + // Vite also serves source modules below /src/api/. Only stub HTTP API + // endpoints; module requests must load the actual application source. + if (!path.startsWith('/api/')) return route.continue(); + if (path === '/api/ai/patrol/run' && req.method() === 'POST') { + const body = req.postDataJSON(); + expect(body).toEqual({ resource_ids: [alert.resourceId], alert_identifier: alert.id, alert_type: 'disk' }); + const response = await route.fetch({ url: `${backend}${path}?case=${mode}` }); + admissions.push({ mode, body, status: response.status(), response: await response.json() }); + return route.fulfill({ response }); + } + if (req.method() !== 'GET') return route.fulfill({ status: 403, json: { error: 'Other writes disabled in proof' } }); + if (path === '/api/version') return route.fulfill({ json: { version: '6.5.0', build: 'proof', runtime: 'development', isDocker: false, isSourceBuild: true, isDevelopment: true } }); + if (path === '/api/updates/check') return route.fulfill({ json: { available: false, currentVersion: '6.5.0', latestVersion: '6.5.0', releaseNotes: '', downloadUrl: '', isPrerelease: false, isMajorUpgrade: false } }); + if (path === '/api/updates/status') return route.fulfill({ json: { status: 'idle', progress: 0, message: '', updatedAt: new Date().toISOString() } }); + if (path === '/api/security/status') return route.fulfill({ json: { hasAuthentication: true, hasProxyAuth: true, proxyAuthUsername: 'proof-operator', sessionCapabilities: { assistantEnabled: true, settingsRead: true, settingsWrite: true } } }); + if (path === '/api/license/runtime-capabilities') return route.fulfill({ json: { capabilities: ['ai_patrol', 'sso'], limits: [], blocked_capabilities: [], hosted_mode: false } }); + if (path === '/api/orgs') return route.fulfill({ json: [{ id: 'default', displayName: 'Proof' }] }); + if (path === '/api/resources') return route.fulfill({ json: { data: [], aggregations: { platformAdmission: { proxmox: false, docker: false, kubernetes: false, truenas: false, vmware: false, standalone: true } } } }); + if (path === '/api/state') return route.fulfill({ json: state }); + if (path === '/api/alerts/active') return route.fulfill({ json: [alert] }); + if (path === '/api/alerts/config') return route.fulfill({ json: { enabled: true, activationState: 'active', nodeDefaults: {}, guestDefaults: {}, agentDefaults: {}, overrides: {}, timeThresholds: {}, notifications: {} } }); + if (path === '/api/ai/settings') return route.fulfill({ json: { enabled: true, model: 'fixture:admission', control_level: 'read_only' } }); + if (/\/(history|events|delivery-diagnosis|sessions|models|findings|incidents)$/.test(path)) return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto(`${base}/alerts`, { waitUntil: 'domcontentloaded' }); + const button = page.getByTitle('Have Patrol investigate this alert', { exact: true }); + await expect(button).toBeVisible({ timeout: 30000 }); + await button.scrollIntoViewIfNeeded(); + const rect = await button.boundingBox(); + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.width).toBeLessThanOrEqual(width); + await page.screenshot({ path: new URL(`default-${width}.png`, output).pathname, fullPage: true }); + const menu = page.getByRole('button', { name: 'More alert actions', exact: true }); + await menu.click(); + await page.screenshot({ path: new URL(`menu-${width}.png`, output).pathname, fullPage: true }); + await page.keyboard.press('Escape'); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + await menu.click(); + await page.getByText('Active Alerts', { exact: true }).first().click(); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + for (const [scenario, status, text] of [ + ['accepted', 200, `Patrol is investigating ${alert.resourceName}`], + ['busy', 409, 'Patrol is already running.'], + ['unresolved', 422, 'One or more requested resource identities could not be resolved exactly'], + ]) { + mode = scenario; + const before = admissions.length; + await button.focus(); + await page.keyboard.press('Enter'); + await expect.poll(() => admissions.length).toBe(before + 1); + expect(admissions.at(-1).status).toBe(status); + await expect(page.getByText(text, { exact: false }).first()).toBeVisible(); + await page.screenshot({ path: new URL(`${scenario}-${width}.png`, output).pathname, fullPage: true }); + } + mode = 'accepted'; + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(button).toBeVisible(); + await button.click(); + await expect.poll(() => admissions.at(-1).mode).toBe('accepted'); + await expect(page.getByText(`Patrol is investigating ${alert.resourceName}`, { exact: true })).toBeVisible(); + results.push({ width, admissions }); + await context.close(); + } + expect(errors).toEqual([]); + const paths = ['internal/unifiedresources/history_identity.go', 'internal/ai/patrol_alert_scope.go', 'internal/ai/patrol_state.go', 'internal/ai/patrol_run.go', 'internal/ai/patrol_ai.go', 'internal/ai/patrol_triggers.go']; + const content_sha256 = Object.fromEntries(await Promise.all(paths.map(async (path) => [path, createHash('sha256').update(await readFile(new URL(`../${path}`, import.meta.url))).digest('hex')]))); + await writeFile(new URL('result.json', output), JSON.stringify({ result: 'passed', verified_at: new Date().toISOString(), frontend_base_sha: process.env.PROOF_BASE_SHA, content_sha256, routes: ['/alerts'], viewports: [1440, 390], states: ['default', 'menu open/closed', 'accepted', 'busy', 'unresolved owner', 'reload'], interactions: ['pointer', 'Enter', 'Escape', 'outside click', 'reload then re-submit'], results, errors, limits: 'Real API admission, synthetic estate/session, no inference or diagnosis qualification' }, null, 2)); +} catch (error) { + if (lastPage && !lastPage.isClosed()) { + await lastPage.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true }); + await writeFile(new URL('failure.txt', output), `${error.stack}\nPage errors: ${JSON.stringify(errors)}\nDiagnostics: ${JSON.stringify(diagnostics)}\nURL: ${lastPage.url()}\n${await lastPage.locator('body').innerText()}`); + } + throw error; +} finally { + await browser.close(); +}