diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 44f7ec9c1f..ac884a0ad1 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -10626,3 +10626,9 @@ org/authentication/licence boundaries remain. This changes no agent token, enrolment, credential or runtime operation. The API contract and `TestContractRBACIntegrityQueryAdmission` cover the registered fallback; `rbac_integrity_query_test.go` covers rejected and supported selections. + +External-agent activity telemetry no longer records requests from a page Pulse +itself served (`Sec-Fetch-Site: same-origin`), so the Pulse UI running on the +setup admin token stops reading as external-agent use. This changes no agent +token, enrolment, credential scope, authentication or runtime operation; only +which requests write the content-free activity marker. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 13bd1ff6f2..67ce1881bd 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7145,6 +7145,14 @@ scope set. Read-only external agents using `monitoring:read` must therefore count for context/event-stream activity without being treated as action, settings, or approval-capable callers. Generic API token last-use timestamps are not canonical proof that the external-agent surface was used. +Requests from a page Pulse itself served (browser `Sec-Fetch-Site: +same-origin`) never record the marker, whatever token authenticated them: the +setup wizard leaves the Pulse UI on the wildcard admin token and kiosk links +authenticate with `?token=`, so the UI's own node-list and finding-list +fetches otherwise counted as external-agent use and made the counter track +new-install volume. Scripts, MCP adapters and dashboards on another origin +still count. `TestExternalAgentActivityIgnoresFirstPartyBrowserRequests` +covers both sides. The `pulse-mcp` adapter may stamp the content-free `X-Pulse-Agent-Surface: pulse_mcp` header on those same capability requests so the telemetry snapshot can expose adapter-specific recent use while keeping the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8b35971e36..db2c414ea1 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -8343,3 +8343,8 @@ protect grouped metadata, responsive columns and bounded rendering. Browser proof must exercise the consuming list at desktop/narrow widths, inspect actual pixels, identity, state/age fit, touch/keyboard disclosure, filters and live updates. This is safer evidence inspection, not a restore or installed recovery. + +External-agent activity telemetry now skips first-party browser requests +(`Sec-Fetch-Site: same-origin`) before writing `external_agent_activity.json`. +It writes fewer records to the same file and opens no new storage, backup or +recovery path. diff --git a/internal/api/agent_activity_telemetry.go b/internal/api/agent_activity_telemetry.go index c5638152a6..127d0956f6 100644 --- a/internal/api/agent_activity_telemetry.go +++ b/internal/api/agent_activity_telemetry.go @@ -93,6 +93,9 @@ func (r *Router) recordExternalAgentActivity(req *http.Request, activity string, if r == nil || req == nil || strings.TrimSpace(activity) == "" { return } + if isFirstPartyBrowserRequest(req) { + return + } token := getAPITokenRecordFromRequest(req) if !apiTokenCoversExternalAgentSurface(token, time.Now().UTC(), requiredScopes...) { return @@ -202,6 +205,17 @@ func externalAgentActivitySurface(req *http.Request) string { } } +// isFirstPartyBrowserRequest reports whether the request came from a page +// Pulse itself served. The setup wizard leaves the browser authenticated with +// the wildcard admin token, and kiosk links authenticate with ?token=, so the +// Pulse UI's own node and finding fetches would otherwise count as external +// agent use. Browsers set Sec-Fetch-Site and page scripts cannot override it; +// scripts, MCP adapters and dashboards on another origin never send +// same-origin. +func isFirstPartyBrowserRequest(req *http.Request) bool { + return strings.EqualFold(strings.TrimSpace(req.Header.Get("Sec-Fetch-Site")), "same-origin") +} + func apiTokenCoversExternalAgentSurface(token *config.APITokenRecord, now time.Time, requiredScopes ...string) bool { if token == nil { return false diff --git a/internal/api/telemetry_history_reads_test.go b/internal/api/telemetry_history_reads_test.go index 030877e0ea..b603ea2fe2 100644 --- a/internal/api/telemetry_history_reads_test.go +++ b/internal/api/telemetry_history_reads_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/testutil" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" @@ -53,3 +54,41 @@ func TestMetricsHistoryTelemetryRequiresServedExtendedData(t *testing.T) { }) } } + +// The setup wizard leaves the Pulse UI authenticated with the wildcard admin +// token, so its own node and finding fetches must not read as external agent +// use. Requests without a same-origin fetch marker still count. +func TestExternalAgentActivityIgnoresFirstPartyBrowserRequests(t *testing.T) { + persistence := config.NewConfigPersistence(t.TempDir()) + router := &Router{persistence: persistence} + adminToken := &config.APITokenRecord{Scopes: []string{config.ScopeWildcard}} + + for _, site := range []string{"same-origin", "Same-Origin"} { + uiReq := httptest.NewRequest(http.MethodGet, "/api/config/nodes", nil) + uiReq.Header.Set("Sec-Fetch-Site", site) + attachAPITokenRecord(uiReq, adminToken) + router.recordExternalAgentCapabilityActivity(uiReq, agentcapabilities.ListNodesCapabilityName) + } + + for _, site := range []string{"", "cross-site", "same-site", "none"} { + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/findings", nil) + if site != "" { + req.Header.Set("Sec-Fetch-Site", site) + } + attachAPITokenRecord(req, adminToken) + router.recordExternalAgentCapabilityActivity(req, agentcapabilities.ListFindingsCapabilityName) + } + + history, err := persistence.LoadExternalAgentActivityHistory() + if err != nil { + t.Fatalf("LoadExternalAgentActivityHistory: %v", err) + } + if len(history.Events) != 4 { + t.Fatalf("expected only the four non-first-party requests to count, got %d: %+v", len(history.Events), history.Events) + } + for _, event := range history.Events { + if event.Activity != config.ExternalAgentActivityFindingList { + t.Fatalf("first-party UI request was counted: %+v", event) + } + } +}