Dead-code audit batch 3: hasPrefix in internal/ai/service_test.go and
the staticExternalAgentActivityProvider test double in
internal/api/agent_resource_context_test.go have no callers in any
test or production code.
Skipped from the audit list after re-verification:
createTestEncryptionKey in cmd/pulse/test_helpers_test.go (called nine
times by commands_integration_test.go under the integration build tag).
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.
Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
approved human decision fail CLOSED on unknown lock state and
surface "remediation lock state unknown; operator approval
required". Human-approved dispatches keep the historical fail-open
behavior with a warning log. A confirmed lock still refuses even
approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
remediation_lock_state_unknown: / resource_remediation_locked:
ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.
Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.
Note: commit c21693489 carries this same message by mistake; it actually
contains concurrent OIDC/SAML mapping work from a parallel session that
was staged when the shared index raced. This commit is the real change.
The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.
The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.
Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.
The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.
Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
The embedded frontend file server had no content type for .md, so every
in-app "Full details" / "Terms of Service" / security-guide link to
/docs/*.md answered application/octet-stream and the browser downloaded
the file instead of showing it. Shipped docs now serve as
text/plain; charset=utf-8 and open readable in the tab the app targets.
An OIDC provider upgraded from v5 keeps its v5 redirect URL
(/api/oidc/callback, the DefaultOIDCCallbackPath), so the IdP redirects
the browser back to the 3-segment legacy path carrying only code and
state, with no session cookie and no API token yet. The global auth
middleware only marked the 4-segment per-provider path public, so in
API-token-only mode (AuthUser=="" && AuthPass=="" && HasAPITokens())
the callback was rejected with "API token required via Authorization
header or X-API-Token header", and the route dispatcher 404ed the same
path.
The handler layer (extractOIDCProviderID) already maps the legacy
/api/oidc/login and /api/oidc/callback paths to the migrated legacy
provider, so only the public-path allowlist and the route dispatcher
needed to recognise the 3-segment form. Adds a regression test covering
both legacy paths in API-token-only mode, the exact configuration that
emitted the error.
Refs #1533
Empty allowedGroups, allowedDomains, allowedEmails, groupsClaim and
groupRoleMappings in a provider PUT were silently restored from the
existing config, so an admin could never clear them. The guards
shielded against lossy round-trips that no longer exist: the detail
GET and the flat list response both carry these fields, so an empty
value is an intentional clear. Nested OIDC/SAML config and the client
secret stay preserved since toggle payloads omit them and secrets are
never echoed in reads.
Also expose groupsClaim and groupRoleMappings in the shared extensions
list response so the enterprise list matches the core one, and make
the Settings enable/disable toggle send only writable fields; the old
list-item spread included computed response fields that the enterprise
strict PUT decoder rejects.
Keep Proxmox setup tokens node-scoped so cluster installs do not rotate one shared Pulse API token.
Suppress command-enable config for tokens that cannot register command channels and keep reusable installer tokens out of agent:exec.
Seed the hidden demo fixture entitlement during stable demo updates so release builds can enable governed mock resources after runtime configuration is restored.
Keep the deployment contract and release policy checks aligned with the release-build entitlement gate.
get_fleet_context returned the whole registry with no way to narrow it,
so a large fleet produced a payload that exceeded the agent harness's
50KB cap and forced agents to receive or page through healthy resources
that are pure noise from a triage standpoint.
Add optional additive query-param filters — hasFindings, severity,
technology, resourceType — that compose by intersection. All optional
(omitting every filter returns the full fleet, backward compatible);
unknown/unmatched values return 200 with resources: [] (a valid triage
answer, not an error). The hasFindings=true filter is the headline
triage case: show me only what needs attention.
The MCP adapter layer had no GET query-param transport — ProjectCapabilityCall
short-circuited GET after path substitution and dropped all other args.
Close that gap generically: ProjectedCall gains a Query url.Values field
populated from leftover non-path public args for GET/DELETE, and
BuildCapabilityHTTPRequest encodes it onto the request URL. Benefits any
future GET capability with filter args, not just fleet-context. The
resources/list adapter calls fleet-context with empty args and continues
to receive the unfiltered fleet.
The manifest now declares the filters via inputSchema so they are
discoverable through the capability surface the same way add_node's
params are.
- ProjectedCall.Query + GET query-param forwarding (projection.go, http.go)
- fleetContextFilter parsing/matching in HandleFleetContext
- fleetContextInputSchema + InputSchema on the capability
- Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs
- Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery
- Tests: adapter query-forwarding unit tests, 7 filter handler tests
(hasFindings/severity/technology/resourceType/no-filter/unknown/compose),
contract pinning inputSchema + end-to-end query forwarding
Agents had no way to discover which governed capabilities a resource
advertises before calling plan_action. The data existed (Resource.Capabilities
with full param schemas) but was only surfaced as count-limited prose
facts inside get_resource_context, which deliberately omits parameter
schemas. This forced agents to guess capabilityName and params.
Add a dedicated structured surface: GET /api/agent/resource-capabilities/{id}
returns the canonical ResourceCapability list (name, type, approval level,
platform, full parameter schemas) for a single resource. A resource with
no advertised capabilities returns 200 with an empty array, the signal
to skip plan_action.
Internal plumbing already existed (registry.Get + Resource.Capabilities,
the same path plan_action validates against); this is the wiring work at
the canonical agent-surface layer. The tools/call dispatch is fully
generic so no mcp.go changes were needed.
Companion to get_resource_context: that tool remains the human-readable
prose summary; this is the structured schema surface for action planning.
- Manifest capability, name/path constants, output schema helper
- Handler mirroring HandleResourceContext error/scope patterns
- Route registration behind monitoring:read
- Activity telemetry mapping to resource_context class
- Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs
- Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery
- Tests: manifest ownership/addressing/table-test, handler happy/empty/404/405,
contract pinning capability presence/scope/wire-shape
handleLogin rejected all credentials when ALLOW_ADMIN_BYPASS=1 because
it validated against config AuthUser/AuthPass directly without checking
the bypass flag. This made dev-mode browser testing impossible after a
backend restart with bypass env vars — the API middleware accepted all
requests but the login page could never obtain a session cookie.
When bypass is enabled, accept any credentials and create a session as
'admin'.
1. Add Impact field to findingView struct — was completely missing from
the unified findings API response serialization.
2. Expand generateImpact() to cover all ~25 alert types the system
produces. Previously only 7 had curated impact text.
3. Fix naming mismatches: poweredOff/nodeOffline never matched actual
alert types powered-off/host-offline.
4. Expand TypeCategoryMap with all missing alert types.
TCP probes to fast local-network services can complete in under 1ms,
causing latency.Milliseconds() to return 0. Combined with omitempty on
the poller status struct, this made some probes show 'Online' with no
latency in the settings panel. Now successful probes always report at
least 1ms.
When a monitor reload was triggered by node auto-registration, the
reloadFunc in server.go recreated the monitor (and its notification
manager) but never re-applied system settings. The new notification
manager started with an empty webhook private CIDR allowlist, causing
webhook notifications to private IPs to fail until the allowlist was
manually re-saved in Settings.
Fix: call router.ReloadSystemSettings() at the end of reloadFunc,
after the new monitor references are set. This re-applies all
persisted system settings — including the webhook CIDR allowlist —
to the freshly created notification manager.
Refs #1507
The /api/health endpoint gated scheduler health on
DeadLetterCount()==0, causing a 503 whenever any single monitored
node became unreachable. Dead-lettered tasks are the scheduler
working as designed — after repeated failures the task is moved to
the dead-letter queue to prevent infinite retries.
In the live lab this manifested as a persistent 503 on /api/health
because the Pi node (192.168.0.2) times out on every poll cycle,
dead-lettering its task while the other 20+ tasks continue normally.
The scheduler is healthy when the monitor is running. Dead-letter
counts remain available as diagnostic info via the
/api/monitoring/scheduler/health endpoint.
Agentless availability checks (ICMP/TCP/HTTP) were always minting
standalone network-endpoint resources, leaving them disconnected from
the known Proxmox/Docker guest they actually monitor. This made
availability evidence invisible on the platform resource row where the
user expects it, per the performance-and-scalability bounded-row
contract.
Backend (unified-resources ingest):
- Add LinkedResourceID field to AvailabilityData and AvailabilityTarget
- resolveAvailabilityLink: explicit link first, then exact-IP unambiguous
correlation; skip hostname-only (lossy); guard against overwriting a
different target's facet
- Unlinked/unmatched probes still mint network-endpoint (fallback)
Frontend:
- Relax getAvailabilityProbePresentation for any resource with availability
- Add compact protocol badge to UnifiedResourceHostTableCard name cell
- Add optional 'Link to resource' field to availability target form
- Add linkedResourceId to frontend types
Contracts: api-contracts, unified-resources, monitoring,
performance-and-scalability, storage-recovery.
Governance: coverage_gap + candidate_lane in status.json.