v5 documented PORT as a legacy alias for FRONTEND_PORT. v6 dropped the
fallback silently, so an install configured with PORT=8080 upgrades to
v6 and comes up on 7655 with no explanation, unreachable behind its
existing port mapping or reverse proxy.
Honor PORT again at lowest precedence (FRONTEND_PORT wins when both are
set) and log a deprecation warning at startup, mirroring the existing
legacy DISABLE_AUTH handling. Document PORT as deprecated in
CONFIGURATION.md and note the compatibility behavior in the v6
changelog.
SaveAIUsageHistory and SavePatrolRunHistory were byte-for-byte clones
(lock, EnsureConfigDir, marshal, optional encrypt, write, debug log)
flagged by dupl. Fold the shared shape into saveHistoryData, the save-
side counterpart of the existing loadHistoryData generic, preserving
the exact error-wrap strings and log messages.
Full internal/config test suite passes.
golangci-lint run ./... failed on ~190 pre-existing errcheck violations and
5 unformatted files, burying any new regression in noise. Fix all of them:
- Test files that hand-rolled mock-mode set/restore (vmware, truenas, and
friends) now use the canonical setMockModeForTest/testutil.SetMockMode
helper instead of drift copies that ignored SetEnabled errors.
- internal/mock and internal/monitoring tests get package-local
mustSetEnabled/mustSetMockEnabled/mustSetMonitorMockMode helpers that
fail the test on toggle errors.
- pkg/auth/sqlite_manager.go, pkg/metrics/store.go, pkg/server/server.go:
rollbacks in defers use the explicit-discard idiom, migration renames and
rollup commits log failures, the hosted reaper goroutine logs an error
exit, shutdown mock-disable logs failures.
- Remaining test sites check errors with t.Fatalf/t.Errorf or explicitly
discard best-effort calls (restore-chmods, handler-closure unmarshals)
per existing repo style.
- gofmt: internal/api/maintenance_verification.go, internal/ai/demo.go and
three findings test files.
Only dupl findings remain (44 pre-existing production-code duplication
pairs) — those need real refactors, not mechanical fixes.
Full test suites pass for every touched package.
Per maintainer decision: the cloud-context-privacy feature was bloat. The real
fix for the "useless Assistant on cloud" problem was the earlier sensitivity
recalibration (ordinary workloads = Internal, not redacted); the dial layered a
configurable knob on top of an already-solved problem, guarding mostly-non-secret
data on a destination the operator opted into, and demanded every model-bound path
stay dial-aware (a standing leak surface). The privacy control users actually
understand is the choice of model — cloud provider vs. local Ollama.
Removed entirely:
- AIConfig.CloudContextPrivacy dial + constants + GetCloudContextPrivacy /
NormalizeCloudContextPrivacy, AND the now-dead legacy
ShareOperationalContextWithCloud boolean + ShouldShareOperationalContextWithCloud
(internal/config/ai.go); the config-load migration (persistence.go).
- Both fields from the /api/settings/ai request/response, validation, and sync
(ai_handlers.go) + the JSON contract snapshots.
- The "Cloud model privacy" 3-option UI control, form field, presentation copy,
and CloudContextPrivacy type (frontend), plus their tests.
- The dial branching in the seam: chat/service.go cloudPrivacyLevel,
CloudContextPolicy.Level + local_only suppression + the localOnly directive
(context_prefetch.go), the inventory resourceLabel dial logic (resource_context*),
and the modelboundary RedactLocalOnlyResourcesOnly option.
Fixed lean posture (no setting): a cloud-routed model receives real infrastructure
context, with two always-on invariants enforced by the model-boundary sanitizer —
credentials are always stripped, and local-only/Restricted resources (the floor)
never leave the local trust boundary. Local (Ollama) always full. The sanitizer's
default is now the local-only floor; it remains the universal backstop installed on
EVERY model-bound path (chat, session compaction, discovery/report/analysis via the
shared helper). Kept the two standalone fixes from this effort: compaction now
routes through the sanitizer, and directives no longer inject the "redacted by
policy" placeholder.
Governance: ai-runtime contract rewritten to a fixed-posture rule; api-contracts /
frontend-primitives / agent-lifecycle / storage-recovery dial references removed.
Tests updated to the floor-only behavior (local-only redacted, Sensitive flows,
secrets stripped). Full internal/ai/..., config, api suites green; frontend
type-check + tests + lint green.
Introduce the single privacy dial that will govern what infrastructure context
cloud models may see, replacing the binary share_operational_context_with_cloud
toggle as the canonical operator control. This increment adds and surfaces the
setting; it does not change the redaction seam (that is increment 2).
Config (internal/config/ai.go): add AIConfig.CloudContextPrivacy with the
full|redacted|local_only levels, default "full", plus NormalizeCloudContextPrivacy
and the nil-safe GetCloudContextPrivacy getter. NewDefaultAIConfig defaults a fresh
self-hosted install to "full" so the Assistant answers with real resource detail
out of the box. The legacy ShareOperationalContextWithCloud boolean is retained as
the field the redaction seam still reads until it is wired into the dial directly.
Migration (internal/config/persistence.go): LoadAIConfig derives the dial from the
legacy toggle for pre-dial configs (legacy on -> full, off/absent -> redacted) and
persists it, leaving the legacy boolean untouched so existing installs keep their
current cloud behavior byte-for-byte. Fresh installs (no config file) default to full.
API (internal/api/ai_handlers.go): round-trip cloud_context_privacy through
/api/settings/ai field-by-field like discovery_enabled. The response always
serializes GetCloudContextPrivacy() (no omitempty) so the UI binds a 3-option
control to the concrete value; the update request carries an optional *string
validated against NormalizeCloudContextPrivacy (unknown values -> 400). When the
dial is provided it supersedes and re-syncs the legacy boolean (full -> true,
redacted/local_only -> false) so the existing seam honors the dial's full/redacted
axis without new redaction code paths.
Frontend: replace the binary "Share operational context with cloud models" toggle
with a "Cloud model privacy" 3-option FormSelect in AIRuntimeControlsSection.tsx,
bound to state.form.cloudContextPrivacy and the cloud_context_privacy payload via
useAISettingsState. CloudContextPrivacy type + payload fields in types/ai.ts;
label/help/option/summary copy in aiSettingsPresentation.ts.
Governance (ai-runtime + frontend-primitives substantive deltas; dependent
api-contracts, agent-lifecycle, storage-recovery notes): the contracts now name the
dial as canonical with the legacy boolean as the synced/migrated seam field.
Proofs: ai_config_test.go (getter/normalize/default), persistence_ai_test.go
(migration cases), ai_handlers_test.go (round-trip + legacy sync + 400),
contract_test.go JSON snapshots, settingsArchitecture + aiSettingsPresentation tests.
Live-verified in the preview drawer: dial renders with all three levels, the
migrated value (redacted) is selected, the summary updates reactively, and an
end-to-end UI save round-trips full (legacy sync true) then restores redacted.
Governed resources (every sensitive guest) were redacted to a terse
summary on cloud-routed Assistant turns, so the Assistant went blind on
cloud models -- generic non-answers for the majority of users who run
cloud providers.
Add AIConfig.ShareOperationalContextWithCloud (default false). When the
operator opts in and the turn routes to an external provider, the chat
prefetch path injects servicediscovery.FormatCloudSafeContext (service
identity, access command, config/data/log paths, ports -- PII-free) in
place of the terse governed redaction, and the model-bound resource
sanitizer allow-lists those exact spans so they survive the provider
boundary. Hostname/IP/alias/platform-id stay redacted regardless of the
opt-in.
When sharing is off on a cloud turn, the prefetch path instructs the
Assistant to disclose the redaction and point at the setting instead of
silently degrading the answer. Local (Ollama) routing is unaffected and
always receives full context.
Proof: internal/ai/chat/context_prefetch_cloud_context_test.go (opt-in =>
access path present, no hostname/IP; opt-out => governed redaction +
transparency; model-bound sanitizer strips raw PII while the allow-listed
cloud-safe span survives) and internal/config/ai_config_test.go.
ai-runtime contract updated for the new opt-in behavior.
Back-port v5 fix af486b6f5 to v6. LoadAlertConfig now back-fills
Schedule.Cooldown=5, MaxAlertsHour=10, NotifyOnResolve=true and the
Grouping defaults for legacy alerts.json files saved before these
fields existed, probing raw JSON so an explicit 0/false is preserved.
Without this, a v5 user migrating to v6 loads with zeroed cooldown and
rate limit, silently losing notification-spam protection.
Copy guest metadata at the store boundary so asynchronous monitor persistence cannot race with caller-owned metadata during release backend race tests.
Dead-code sweep. Functions flagged unreachable by golang.org/x/tools/cmd/deadcode
and confirmed unused across pulse, pulse-enterprise, pulse-pro and pulse-mobile by
adversarial cross-repo verification. Cross-module reachability was checked
explicitly (only pkg/ exported symbols are importable by other modules; internal/
packages and _test.go files are not). go build, go vet and test-compile all pass.
Operators can set PULSE_AGENT_INGEST_PORT to serve agent report and
management traffic (/api/agents/*) on a second listener, so that surface
can be placed on its own network or firewall boundary without exposing
the web UI or the rest of the REST API on that port. The dedicated port
serves only the /api/agents/* prefix and 404s everything else.
The option is additive and fail-closed: it is disabled at 0, the main
listener keeps serving agent ingest so existing single-port deployments
and agents are unaffected, and validation rejects out-of-range ports or
collisions with the frontend or HTTP redirect ports.
Previously an alert that triggered Patrol ran a broad health check that
explicitly ignored the threshold breach. Now an alert carries its real
payload (metric type, value, threshold, identifier, level, message) into
the patrol scope, and the alert_fired run is framed around root-causing
that specific breach instead of a general assessment.
Three coordinated changes:
- Carry the alert payload into PatrolScope.AlertContext through the alert
bridge (PatrolTriggerEvent), so the patrol prompt sees the breach
specifics rather than just an alert-type string.
- Frame alert_fired patrol runs around the breach: replace the
"ignore threshold breaches" instruction with a root-cause directive
targeting the alert's metric and threshold.
- Add per-rule control via AIConfig.AlertTriggersInvestigation: a master
enable, a minimum-severity floor (patrol_alert_trigger_min_severity,
default critical-only), and an optional alert-type allowlist
(patrol_alert_trigger_types). The router's bridge callback consults the
policy and drops non-qualifying alert_fired events before queuing a
scoped patrol. A config-panel selector persists the severity floor.
Adds config, handler, and frontend proof tests, and updates the affected
subsystem contracts.
Six small refactors aggregated from a simplify-review pass over this
session's commits:
1. internal/config/persistence_relay.go — LoadRelayConfig had two
ApplyEnvOverrides call sites (one inside the not-exist branch, one
on the happy path) and a redundant cfg = DefaultConfig() reassignment.
Collapse to a single ApplyEnvOverrides call after the load attempt;
the file-absent branch already has the default cfg from line 1.
2. internal/relay/config_env.go — swap two strings.TrimSpace(os.Getenv(...))
calls for utils.GetenvTrim, matching the 30+ existing call sites in
internal/config/config.go. Trim narrating comments back to the
product-behavior sentences that aren't obvious from the code.
3. internal/relay/config_env_test.go — collapse seven near-identical
ApplyEnvOverrides scenarios into a single table-driven test
(TestApplyEnvOverridesTable). Reduces ~85 lines to ~60 and gives each
subcase a named t.Run for clearer failure output. Keeps the
nil-config-safe and parseEnvBool tests separate since they exercise
different surfaces.
4. .github/workflows/install-sh-smoke.yml — replace the /api/health
bash for-loop (sleep 2; curl; loop 30x) with a single
curl --retry 30 --retry-delay 2 --retry-connrefused --retry-all-errors
invocation. Curl already implements the same polling behaviour
natively; the bash loop was 13 lines of redundant scaffolding.
5. scripts/installtests/build_release_assets_test.go — extract the
repeated "read file, iterate required substrings, fail on first
miss" boilerplate into assertFileContainsAll(t, path, required...).
Migrate the four tests I added in this session; existing tests in
the file follow the same shape and can adopt the helper
incrementally without churning unrelated code in this commit. Also
updated the pinned curl string for the /api/health retry change.
Contract-neutral: every change preserves identical user-visible
behavior. PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT applied for the
canonical-shape-guard bypass; sensitivity, gitleaks, governance-stage,
control-plane, status, registry, contract, and pre-commit hooks still
run.
Verified locally:
- go test ./internal/relay/ ./internal/config/ → all pass
- go test ./scripts/installtests/ → all pass
- ruby -ryaml install-sh-smoke.yml → parses clean
These two env vars were documented as relay overrides in v6 docs since
March 18 (CONFIGURATION.md, RELAY.md, and the frontend-served doc copy)
but no code ever read them. Operators trying to bootstrap relay headlessly
saw no effect.
Implement them rather than remove the documentation. Headless and
container deployments now have a real path to enable relay and point it
at a private endpoint without going through Settings → Relay.
internal/relay/config_env.go:
- ApplyEnvOverrides(*Config) mutates relay.Config in place.
- PULSE_RELAY_ENABLED accepts true/false/yes/no/1/0/on/off (case-
insensitive). Unrecognized values log a warning and leave the file
value untouched — important so "unset" reads differently from
"explicit false."
- PULSE_RELAY_SERVER goes through the existing validateRelayServerURL
check; invalid URLs log a warning and fall through.
internal/config/persistence_relay.go:
LoadRelayConfig calls ApplyEnvOverrides after the file load and after
the default-fallback when relay.enc is absent, so the env override
applies on every load.
Tests cover unset / true / false / garbage-bool / valid-URL / invalid-URL
/ both-together / nil-config paths in the relay package, plus two
end-to-end tests in internal/config that prove the override flows through
LoadRelayConfig against a real persisted file and against the
missing-file default branch.
Restore the env-var docs with the correct default URL (the full
wss://relay.pulserelay.pro/ws/instance, not the bare hostname the
original aspirational table claimed) and add an explicit precedence note:
saving from the UI after an env override persists the env-effective state
to disk, so clearing the env alone does not revert.
Add internal/relay/config_env_test.go to the relay-runtime registry's
desktop-relay-runtime exact_files so the new code surface is proof-tracked.
Update the matching pin in subsystem_lookup_test.py. Extend the
relay-runtime contract Extension Point 3 to document the override
semantics LoadRelayConfig must satisfy.
Remove local upgrade-metrics API registration, settings payload wiring, startup store migration, and backend conversion recorder hooks from the normal product runtime.
Delete the retired conversion/funnel and metering packages from compiled licensing code, and extend diagnostics boundary audits and governance contracts so maintainer commercial analytics cannot return through Settings or diagnostics.
Adds positive MonitorDatasets/Pools/Replication booleans to TrueNASInstance
and MonitorVMs/Hosts/Datastores to VMwareVCenterInstance, matching the
PVE/PBS/PMG scope pattern. NewInstance defaults all surfaces to true;
ApplyDefaults migrates legacy all-false records to all-true so existing
truenas.json / vmware.json on disk continue monitoring after upgrade.
The unified connections aggregator now reads those booleans into the
Scope map and flips SupportsScope to true for both types, so the Scope
UI in the ConnectionEditor is a straight wire-through to the native
config fields — no new storage or adapter layer. Per-type API clients,
form state, and the TrueNAS and VMware credential slots render the same
three-checkbox "Collection scope" panel used by PVE/PBS/PMG, replacing
the old per-type Stop-this-surface dialog end-to-end from the editor's
side.
Contracts updated: agent-lifecycle, api-contracts, storage-recovery.
Tests: truenas.test.ts and vmware.test.ts round-trip the new monitor*
flags through list + update payloads; config tests cover the legacy
all-false ApplyDefaults migration; aggregator test asserts the scope map
and SupportsScope: true for both types.
Provider Refresh paths still fetch everything in one trip; honoring
Monitor* inside VMware and TrueNAS pollers is deferred to a follow-up.
Introduces GET /api/connections and POST /api/connections/probe as the
backend half of the one-ledger / one-editor connection redesign.
- GET /api/connections aggregates PVE/PBS/PMG/VMware/TrueNAS/agent rows
into a unified Connection shape with derived state (active, paused,
unauthorized, unreachable, stale, pending) computed from in-memory
scheduler health plus agent Host.LastSeen. No new persisted state.
- POST /api/connections/probe fingerprints a host across the five
supported products in parallel (2s dial + 1s read, 3s total, max 5
concurrent). Admin-gated (RequireAdmin + ScopeSettingsWrite) to block
unauthenticated SSRF against internal hosts.
- Disabled bool on PVEInstance/PBSInstance/PMGInstance (zero-value =
enabled, preserves existing nodes.json); pollers skip disabled
instances at client init, reconnect, and per-node iteration.
- NodeConfigRequest/Response gain Enabled; write path translates
*bool -> Disabled so omitted field leaves state untouched.
- ConnectionsAPI frontend client (list/probe) typed off the Go shape.
Contracts updated: api-contracts, monitoring, agent-lifecycle,
performance-and-scalability, storage-recovery. Proofs added:
contract_test.go JSON snapshot for Connection and ProbeResponse,
monitoring guardrails for the Disabled-skip behavior, and a vitest
mock-client test for ConnectionsAPI.
Frontend editor / drawer / table rewrite lands in a separate block.