Stop counting the Pulse UI as external agent use (#3492)

## Why

`pulse_intelligence_external_agent_used_30d` is meant to show installs
where an external agent or integration calls the agent capability
routes. Pulse's own UI was being counted too:

- The setup wizard creates a wildcard admin API token and keeps the
browser tab on it (`SecurityStep.tsx`: "The admin token is already
active").
- Kiosk links authenticate with `?token=`.
- The UI calls `GET /api/config/nodes` and `GET
/api/ai/patrol/findings`, both of them manifest capabilities, so normal
browsing recorded provisioning and finding activity.

In fleet telemetry for the week of 22 Oct, 33-49% of installs under 30
days old reported external-agent use, against 2-3% of older installs,
and 97% of reporting installs showed node-list or finding-list activity.
The counter's fall from 1,541 to 1,081 installs since September followed
fewer new installs, not agents leaving.

## Change

`recordExternalAgentActivity` now skips any request the browser marks
`Sec-Fetch-Site: same-origin`. Only a page served by Pulse itself can
produce that value, and page scripts cannot set it. Requests from
scripts, MCP adapters (`pulse_mcp`) and dashboards on other origins
still count.

This deliberately changes telemetry semantics. Expect the counter to
drop on installs that upgrade. The interpretation note in
`pulse-pro/docs/TELEMETRY_SIGNALS.md` will record the break.

## Proof

`TestExternalAgentActivityIgnoresFirstPartyBrowserRequests` checks that
same-origin requests are skipped and that requests with no marker,
`cross-site`, `same-site` or `none` are still recorded.
This commit is contained in:
rcourtman
2026-10-29 08:32:46 +00:00
committed by GitHub
5 changed files with 72 additions and 0 deletions
@@ -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.
@@ -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
@@ -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.
+14
View File
@@ -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
@@ -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)
}
}
}