Commit Graph

12775 Commits

Author SHA1 Message Date
rcourtman 3fd8dc8734 Open an issue when the weekly signing preflight fails
GitHub only emails whoever last edited a workflow's cron line about a
failed scheduled run, and only if their notification settings allow it.
If a bot edits the file later, the alerts go to the bot. Keep one open
issue while the weekly check fails, comment on it for each further
failure, and close it when the check passes again.
2026-10-06 13:59:33 +01:00
rcourtman d4315cef51 Run the release signing preflight every Monday (#2568)
## Why

The nightly release rehearsal dispatched `release-signing-preflight.yml`
until it was switched off with the release steward on 4 October 2026, so
nothing now runs it before a release. A lapsed Apple developer
agreement, a revoked notary key or an expiring Developer ID certificate
would first show up as a failed release candidate. That is what happened
to v6.4.6-rc.1 on 2 October.

## What changes

- A `schedule` trigger runs the preflight every Monday at 06:23 UTC.
Manual dispatch from `main` still works.
- The certificate check already fails 30 days before expiry, so a weekly
run leaves weeks to renew.
- A scheduled failure opens an issue titled "Release signing preflight
is failing", or comments on the open one, and the next passing scheduled
run closes it. GitHub's own email only reaches whoever last edited the
cron line. The issue links to the run and contains no credential
details.
- The workflow comment and `.github/workflows/README.md` describe both.

The preflight signs only a throwaway probe binary. It builds, notarizes,
uploads and publishes nothing. `scripts/check_workflow_trust.py` passes.
2026-10-06 13:59:12 +01:00
rcourtman 401c22db1d Run the release signing preflight every Monday
The nightly release rehearsal dispatched this check until it was switched
off with the release steward on 4 October 2026, so nothing now runs it
before a release. A lapsed Apple developer agreement, a revoked notary key
or an expiring Developer ID certificate would first show up as a failed
release candidate, as it did for v6.4.6-rc.1 on 2 October. A weekly run
finds them first, and the certificate check's 30-day margin leaves weeks
to renew. It builds and publishes nothing.
2026-10-06 13:40:39 +01:00
rcourtman 92d946d057 Show the vCenter column only when several are connected (#2566)
With one vCenter, which most installs have, the vSphere hosts,
health-signal and activity tables repeated its name on every row. The
column told the user nothing and took room from the signal and activity
text. On a phone, the health-signal table spent 66px on a clipped "Lab
vC…".

**What changes**
- **When the column shows.** It appears only when the page's vSphere
inventory spans more than one vCenter connection, and the header and
cell drop together.
- **How connections are counted.** From the page's whole vSphere
inventory, never from the rows a search leaves. On Overview that
includes the datastores and networks its health signals cover. A
resource without a connection ID is matched to its connection by name or
host, so one vCenter is never counted twice.
- **Nothing lost.** Every row drawer still names the vCenter.
- **Hosts table.** It now reads the connection name, as the other two
tables do, instead of the raw vCenter host, which moves to the hover
text.

**Verification**
- **One vCenter (the mock), live:** the column is gone from the Overview
signals and hosts, the Health tab and Activity, and the Activity drawer
still names the vCenter.
- **Two vCenters:** I rewrote the resource API responses in the browser
with the websocket held, which moved the Edge DC inventory to an "Edge
vCenter" connection. The column returns with "Lab vCenter" and "Edge
vCenter".
- **Clip sweep against the pre-change run:** the health-signal table
clips less at every narrow width. At 390px, Signal overflows by 92px
instead of 116 and Started by 15px instead of 31. At 768px, Started
overflows by 12px instead of 29. Activity gains about 50px for its
sentences at 1440.
- **Tests:** 1,811 frontend tests pass, including new single- and
multi-vCenter tests for each table and for the connection count.
- **Review:** a second reviewer found three issues:
  - Overview counting only hosts and VMs: fixed.
- One vCenter counted twice when a resource had its name but not its ID:
fixed.
- An open drawer keeping the old column span if a vCenter is added or
removed while it is open: left alone. It needs a change to the shared
drawer-row component, which only re-measures on mount and resize, and
the trigger is rare.
2026-10-06 12:45:27 +01:00
rcourtman f373beae27 Describe releases as the fixed release train everywhere (#2565)
## Why

Releases have run on a fixed train since 4 October 2026, but most
release documentation still described the model it replaced. Readers and
agents got different answers depending on which page they read.

## What changes

- **Shipped docs** (`docs/` and the `frontend-modern/public/docs/`
mirror):
- `RELEASE_PROCESS.md` describes the train: a cut from `main` 14 days
after the last stable (only when `main` has changed), or earlier on
request; a 24-hour Preview soak; promotion of the unchanged candidate.
  - Consecutive minor versions, with no scheduled patch releases.
- A `release-blocker` issue is closed once its fix is on `main`, and
closing the last one cuts a fresh candidate.
- `AI_TRANSPARENCY.md` states the same rules and says no model chooses
release scope or timing.
- **Promotion policy:** a new **Release Train** section is the single
statement of the rules (cut, versions, soak, promote, stop, failures,
workflow checks), checked against the train's implementation. It covers:
  - when an early-cut request takes effect;
  - `rc.N` numbering;
  - the 72-hour held alert;
  - the Pro source-pair failure;
  - the 12-hour retry.

The earlier release models move under History. The stable patch path
stays because the workflow still supports it, but nothing schedules it.
- **Checklist, source of truth, development protocol, control plane,
workflows README, RC-to-GA template and the deployment-installability
contract:** now consistent with that section.
- No 72-hour soak, no steward admission, no severity-scheduled patches.
- v6 resolves to `main`, and only 6.3.2 and 6.4.3 to 6.4.6 stay bound to
release lines.
  - Helm charts are packaged from the validated release tag.
  - The stable patch dry run is optional, as the policy already said.
- **Workflow comments:** `release-signing-preflight.yml` no longer
claims a nightly dispatch that was switched off, and `helm-pages.yml` no
longer names the retired steward.
- **`status.json`:** the release-train coverage gap becomes a resolved
decision.
- **Scripts:** removed
`scripts/release_control/internal/resolve_release_promotion.py` and its
test. It was an unused duplicate of the live resolver and still enforced
a 72-hour soak. The live resolver's comment and error text now name the
train.

## Checks

- Docs mirror check passes.
- `release_promotion_policy_test` and `resolve_release_promotion_test`
pass (108 tests).
- Registry, contract and status audits pass, as does
`subsystem_contracts_test`.
- The canonical completion guard passes in its CI form.
2026-10-06 12:34:16 +01:00
rcourtman 54a9a6451b Link alert cards to their platform from incident metadata (#2564)
The alert card chose its platform page by resource type, then by
searching the message for "vmware". Provider incidents sit on canonical
`vm`, `storage`, `network` and `agent` types, so that only worked while
a summary happened to name the platform. #2559 added an
`incidentProvider === 'vmware'` check for vSphere alone. On main, other
platforms still fall through:

- **TrueNAS pool incidents** link to the Proxmox overview, and Alerts ›
Overview › More › Monitoring says "Proxmox remains the inventory owner".
- **Agent alerts** link to `/machines`. No such route exists, so the
link opens "Page not found: No route matched /machines".
- **TrueNAS metric alerts** (`truenas-pool`, `truenas-dataset`, …) fall
through to Proxmox the same way.

**What changes**
- The card reads the platform from an explicit `platformType`, then the
incident's `incidentProvider`, then the resource's `resourceSources`,
through the shared `resolvePlatformTypeFromSources`. A PBS incident that
Pulse raises itself (provider `pulse`) resolves by its `pbs` source.
- That platform maps to its page through
`PRIMARY_PLATFORM_NAV_SCOPE_IDS` and the `routing/resourceLinks.ts` path
builders, and the same canonical id goes to the Monitoring policy menu,
so the link and the named owner agree. Raw route strings in the card are
gone.
- The resource-type and message fallbacks remain only for alerts whose
metadata names no platform, now with `truenas-` types, and with agent
alerts sent to `/standalone/machines`.
- vSphere links no longer depend on the alarm wording at all. #2559
already made them right on main by the provider check; this replaces
that special case with the general resolver.

**Verification**
- `AlertOverviewAlertCard.system-scope.test.tsx`: a vSphere VM alarm
whose message lacks "VMware", a TrueNAS pool incident, a PBS incident
with provider `pulse`, and agent and TrueNAS metric alerts without
incident metadata. Each asserts the link and the platform passed to the
Monitoring menu. All five fail against main's card; against the card
from before #2559, the vSphere VM alarm linked to `/proxmox/overview`.
- Private mock stack at main `af4e6a493c`, 1440 and 390: a vCenter alarm
on the mock VM client-portal-01 (message "Virtual machine memory usage")
and an agent-host CPU alert, appended to the live websocket
`activeAlerts` with Playwright `routeWebSocket`, plus the mock's own
TrueNAS pool incident. Each link was followed to the vSphere overview,
TrueNAS overview and Machines, and each menu named VMware vSphere,
TrueNAS and Pulse agent. With main's card swapped back in under Vite,
the TrueNAS card went to Proxmox with Proxmox as owner and the agent
card hit Page not found.
- Receipt: `frontend-modern/browser-verification/977a32825e65be8f.json`.
2026-10-06 12:15:15 +01:00
rcourtman a51c736768 Show the vCenter column only when several are connected
With one vCenter, which most installs have, the vSphere hosts,
health-signal and activity tables repeated its name on every row. The
column told the user nothing and took room from the signal and activity
text. On a phone, the health-signal table spent 66px on a clipped "Lab
vC...".

The column now shows only when the page's vSphere inventory spans more
than one connection. That is decided from the whole inventory, not from
the rows a search leaves, and a resource without a connection ID is
matched to its connection by name or host, so one vCenter is never
counted twice. Each row drawer still names the vCenter. The hosts table
now reads the connection name, as the other two do, with the vCenter
host on hover.
2026-10-06 12:07:13 +01:00
rcourtman 478a03b9ed Describe releases as the fixed release train everywhere
Since 4 October 2026 releases are mechanical: the release train cuts the
next minor from main 14 days after the last stable (or earlier on
request), soaks the release candidate on Preview for 24 hours, and
promotes it unchanged unless an open release-blocker issue stops it.

The shipped release docs, the promotion policy, the checklist, the source
of truth, several governance pages and two workflow comments still
described the retired model: release lines, steward judgment, 72-hour
soaks, a nightly rehearsal and patch releases scheduled by severity.
State the train's rules once, in the Release Train section of the
promotion policy, checked against the train itself, and point the rest
at it. Remove the unused internal copy of the promotion resolver, which
still enforced the old 72-hour soak.
2026-10-06 12:06:18 +01:00
rcourtman 3054621cd8 Read vSphere activity as vCenter's own sentences (#2563)
The vSphere Activity tab titled every vCenter event with its class name
("HostConnectedEvent", "HostProfileAppliedEvent"). Its When column
clipped the time on every row at 390, 768, 1024, 1280 and 1440px.

**Why the events read as class names**
Pulse never asked vCenter for the readable text. `QueryEvents` leaves
out each event's `fullFormattedMessage` unless the filter sets
`disableFullMessage` ([Broadcom VI JSON
reference](https://developer.broadcom.com/xapis/virtual-infrastructure-json-api/latest/data-structures/EventFilterSpec/):
"If the property is not set, the collected events do not include the
full formatted message"). On a real vCenter, the drawer's Message row
was therefore always empty too.

**What changes**
- **Readable events.** The client now sends `disableFullMessage: false`.
The provider titles an event change with vCenter's message, falling back
to the class when vCenter sends none, so Patrol's change context gets a
readable reason as well.
- **Stored records.** Events stored before this change still carry the
class as their title, so the page model prefers the message when it has
one.
- **Nothing lost.** The class stays in `vmwareEventType`. Activity
search still finds it, and the drawer shows it as a new Event type row.
The drawer no longer repeats the title in its Message row.
- **When.** It now reads as an age ("1d ago"), as Kubernetes events do,
with the exact time on hover and in the drawer. A vCenter time before
2000, vCenter's marker for an unset time, still shows "-".
- **Registry.** The Activity table moves from the date-time
shared-template rule to the relative-time rule, and the guardrail tests
follow.

**Verification**
- **Private mock stack, before and after.** Event rows read vCenter's
sentence, including records stored before the change. When clips at no
width, where it clipped at five of seven.
- **Trade-off.** The longer sentences truncate on phones and tablets,
with the full text on hover and in the drawer.
- **Drawer.** Checked in light and dark mode.
- **After rebasing onto main** (which now has #2559 and #2560): the
Activity sweep is unchanged, and the Datastores and Networks Health
columns measure exactly as verified in #2559.
- **Tests:** 1,802 frontend tests, the `internal/vmware` package, the
shared-template audit and the completion guard pass.
- **New tests cover:**
  - the request flag;
  - message-or-class titling;
  - the stored-record fallback;
  - search by class;
  - the drawer rows;
  - the relative When and its exact-time hover;
  - the pre-2000 guard.
- **Review.** A second reviewer read the change twice and found one
stale assertion, which is fixed. Its first read is what surfaced the
missing `disableFullMessage`.
2026-10-06 12:06:16 +01:00
rcourtman 4d8bede12e Collect LXC filesystems without pmxcfs or LXC socket IPC (#2562)
Fixes #2511.

**Problem.** The typed helper runs with `PrivateNetwork=true`. `pct
list`, `pct df` and `lxc-info` reach pmxcfs and the LXC monitor through
abstract Unix sockets, which belong to the network namespace, so inside
the helper they always fail. Least-privilege installs using the helper
therefore report `provider_unavailable` for `proxmox.lxc_filesystems` on
every report, and never get per-mount-point guest usage. The Proxmox API
only reports the root filesystem. Reproduced on PVE 9.2.18: `pct list`
works normally and fails with `ipcc_send_rec ... Connection refused`
under `PrivateNetwork=yes`.

The fix keeps the helper's hardening exactly as it is.

**Discovery without commands**
(`internal/hostagent/proxmox_lxc_running.go`)
- Lists `/etc/pve/lxc/*.conf` through the pmxcfs mount, which is a file
read rather than IPC.
- Finds each guest's init in `/sys/fs/cgroup/lxc/<vmid>`: `NSpid`
exactly `<pid> 1`, and the process's own cgroup inside `lxc/<vmid>`.
- Names guests by `hostname` or `CT<vmid>`, as `pct list` and the API
do, so monitoring's exact name match keeps working.
- Hands the whole node back to `pct list` whenever it cannot establish a
guest's state: no cgroup v2, an unreadable listing, config, cgroup or
process, a search limit, or cancellation. A guest it could not read
never silently disappears from a healthy-looking inventory.

**Reads through the shared filesystem prober**
(`internal/filesystemprobe`), replacing the separate plain-`statfs`
path.
- New `lxc` requests. The prober pins `/proc/<pid>`, requires the exact
`lxc/<vmid>` cgroup and the guest's init before and after reading, so
the PID 1 of a namespace nested inside the guest (Docker in LXC) cannot
stand in. It resolves every mount inside the guest root.
- `MountsOnly` reports a declared mount that isn't mounted (same device
as its parent) as `ErrNotMounted`, instead of copying the parent's
numbers.
- One bounded probe per guest is kept across the process, so a guest's
hung FUSE mount cannot stack probes in the root helper.
- The helper's `RestrictSUIDSGID=true` makes systemd answer `openat2`
with ENOSYS, because seccomp cannot inspect its flags. The prober now
falls back to a component-by-component walk with `O_NOFOLLOW` that
refuses every symbolic link instead of resolving it. Docker and Podman
reads get the same fallback.

**Real-node verification.** On a throwaway PVE 9.2.18 node, run under
the helper unit's exact sandbox properties (copied from `install.sh`):
- An unprivileged guest (rootfs plus `mp0`) and a privileged guest
returned readings matching `pct df`.
- A stopped guest was omitted.
- The result was not degraded.
- The prober's Linux tests passed as root on the node.

A first version that resolved paths only with `openat2` failed inside
the sandbox; that is what found the ENOSYS case. The throwaway
containers, template and binaries were removed afterwards. A live nested
PID namespace could not be reproduced in that guest, so that case is
covered by unit tests only.

**Tests**
- `TestHelperProxmoxLXCFilesystemsNeedNoPctOrLXCSockets`: the collector
the helper's provider runs, with every command failing, must return a
complete inventory. It fails without this change.
- `TestDiscoverRunningProxmoxLXCContainersFindsInitWithoutSockets`:
covers a nested init listed first, systemd guests and stopped guests.
-
`TestDiscoverRunningProxmoxLXCContainersLeavesUnestablishedStateToPct`:
covers cgroup v1, an unreadable config, a reused PID, an unreadable
`cgroup.procs`, a cgroup tree beyond the search limit, and cancellation.
- `TestProxmoxLXCIdentityIsExact`,
`TestObserverRejectsInexactProxmoxVMIDs`,
`TestMountsOnlyDetectsUnmountedPathsWithinRoot`,
`TestNonInitProcessCannotSupplyProxmoxGuestCapacity` and
`TestComponentWalkRefusesSymlinksAndStaysBeneathRoot`: cover the
prober's new identity, mount and fallback rules.
- `go test ./internal/hostagent ./internal/filesystemprobe
./internal/dockeragent ./cmd/pulse-agent-helper` passes, and `go vet` is
clean for Linux.

The `AGENT_SECURITY.md` capability row and the agent-lifecycle and
monitoring contracts are updated. The row still says this is not full
qualification: one PVE version, and no timeout or helper-loss evidence.

**Overlap with #2551.** #2551 touches the same collector,
`privilege_helper_client*.go` and the same three docs. It hardens `pct
list` parsing and adds v2 partial inventories (#2513), but still starts
from `pct list`, so inside the helper it would keep failing at that
first command. The two are complementary:
- #2551's stricter `pct list` parsing applies to the fallback path here.
- Its partial-inventory reporting wraps the same collection loop.

Whichever lands second needs a textual rebase in
`collectProxmoxLXCFilesystemsResult` and the docs.
2026-10-06 11:56:04 +01:00
rcourtman bd0ef3985b Link alert cards to their platform from incident metadata
The alert card chose its platform page by resource type, then by
searching the message for "vmware". Provider incidents sit on canonical
vm, storage, network and agent types, so that only worked while a
summary happened to name the platform. #2559 added an incidentProvider
check for vSphere alone; other platforms still fell through:

- TrueNAS pool incidents linked to the Proxmox overview, and their
  Monitoring menu said "Proxmox remains the inventory owner".
- Agent alerts linked to /machines, a route that does not exist, so the
  link opened "Page not found".
- TrueNAS metric alerts (truenas-pool, truenas-dataset, ...) fell
  through to Proxmox the same way.

The card now reads the platform from an explicit platformType, then the
incident provider, then the resource's sources, through the shared
resolvePlatformTypeFromSources. It maps that platform to its page via
the navigation model's scope ids and the resourceLinks path builders,
and passes the same canonical id to the Monitoring policy menu, so the
link and the named owner agree. The resource-type and message fallbacks
remain only for alerts whose metadata names no platform, now with
truenas- types and with agent alerts sent to /standalone/machines.
2026-10-06 11:44:21 +01:00
rcourtman b5444cf8bf Read vSphere activity as vCenter's own sentences
The vSphere Activity tab titled every vCenter event with its class name
("HostConnectedEvent", "HostProfileAppliedEvent"), and its When column
clipped the time on every row at most widths.

Pulse never asked vCenter for the readable message: QueryEvents leaves
out fullFormattedMessage unless the filter sets disableFullMessage, so
on a real vCenter the drawer's Message row was always empty too. The
client now sends disableFullMessage: false, and the provider titles an
event change with that message, falling back to the class. That also
gives Patrol's change context a readable reason. The page model prefers
the message for rows stored before this change, and keeps the class as
an Event type row in the drawer and in search.

When now reads as an age ("1d ago"), as Kubernetes events do, with the
exact time on hover and in the drawer. A vCenter time before 2000 (its
unset marker) still shows as "-". The Activity table moves from the
date-time registry rule to the relative-time rule.
2026-10-06 11:34:56 +01:00
rcourtman af4e6a493c Keep parent node identity out of Proxmox child aliases (#2561)
Guests and storage copy their node's NodeIdentity, NodeName and
NodeAliases into ProxmoxData so search and filters agree after a node
rename. canonicalAliases then listed those values as aliases of the
child itself, so every guest answered to the node's alert resource ID
(for example homelab-minipc). The workloads table matches alerts against
canonical aliases, so a single node temperature or memory alert painted
every guest on that node yellow and showed up in each guest's Needs
Attention panel. The shared alias also made the node's own alias lookup
ambiguous in the registry index.

Only the node resource now lists its node identity as canonical aliases.
Children keep the inherited fields in ProxmoxData, which is what search,
storage and backup consumers already read.
2026-10-06 11:29:03 +01:00
rcourtman 88c2d2f91f Name vSphere, not Proxmox, as the owner of vSphere VM records (#2560)
A vCenter alarm on a vSphere VM opened a Monitoring menu (Alerts ›
Overview › More › Monitoring) saying "Proxmox remains the inventory
owner", and the vSphere VM drawer's Manage tab said "Proxmox owns this
inventory record … without deleting it from Proxmox".

`describeResourceInventoryOwnership` checked the resource-type list
(`vm`, `system-container`, …) before the platform, so every VM mapped to
Proxmox whatever platform the caller passed. It also matched only the
bare `proxmox` / `vmware` families, while the resource drawers pass
canonical keys (`proxmox-pve`, `vmware-vsphere`, `truenas`).

An explicit platform now decides the owner (proxmox and proxmox-\*,
vmware and vmware-\*, kubernetes, docker, truenas, agent). The
resource-type guess is only a fallback when the platform is empty or
unrecognised, so `('vm', undefined)` still maps to Proxmox.

Verified on a mock stack with a vSphere VM alarm injected into the live
websocket snapshot, at 1440 and 390, and on a real mock vSphere VM
drawer, against main's helper in the same session. Receipt:
`frontend-modern/browser-verification/b1c21df0e17ba92c.json`.
2026-10-06 11:25:56 +01:00
rcourtman 6b1fc6eaaf Collect LXC filesystems without pmxcfs or LXC socket IPC
The typed helper runs with PrivateNetwork=true. pct list, pct df and
lxc-info reach pmxcfs and the LXC monitor through abstract Unix
sockets, which belong to the network namespace, so inside the helper
they always fail. Least-privilege installs using the helper therefore
reported provider_unavailable for proxmox.lxc_filesystems on every
report and never got per-mount guest usage (#2511).

Discover running guests without commands: list /etc/pve/lxc through the
pmxcfs mount, find each guest's init in /sys/fs/cgroup/lxc/<vmid> (NSpid
exactly "<pid> 1", cgroup inside lxc/<vmid>), and name it by hostname or
CT<vmid> as pct list does. Discovery hands the whole node back to pct
whenever a guest's state cannot be established, so a guest it could not
read never disappears from a healthy-looking inventory.

Read usage through the shared filesystem prober instead of a second,
plain statfs path. It now accepts lxc requests: it pins the process,
requires the guest's exact cgroup and init (a nested namespace's PID 1
cannot stand in), skips declared mounts that are not mounted, and keeps
one bounded probe per guest. The helper's RestrictSUIDSGID makes
systemd answer openat2 with ENOSYS, so the prober falls back to a
component walk that refuses every symbolic link rather than resolving
it; Docker and Podman reads gain the same fallback.

Exercised on a disposable PVE 9.2.18 node under the helper unit's
sandbox properties: unprivileged and privileged guests matched pct df,
a stopped guest was omitted, and the prober's Linux tests passed as
root. A second opinion reviewed the design and the first draft; this
version addresses its findings.
2026-10-06 11:14:21 +01:00
rcourtman fac0cf1652 Say why vSphere datastores and networks need attention (#2559)
An amber vSphere datastore or network gave no reason on its row. The
vCenter alarm behind it was one click away in the drawer, or one tab
away on the Overview. The alarm text itself led with boilerplate,
"Network network-302 has VMware alarm Network packet loss above thres…",
so the Overview cut off the part that mattered.

**What changes**
- **Wording.** A vCenter incident summary is now what vCenter flagged:
the alarm's own name ("Network packet loss above threshold"), or
"vCenter health is yellow" when there is no alarm. Every surface that
shows it already names the resource and severity: alert cards, email and
Apprise titles, every webhook template, the Overview and Health signal
tables, the drawer and Patrol titles.
- **Patrol.** Titles key off the alert's `incidentCode` instead of
parsing the sentence, and still read records stored in the old wording.
Each alert line in Patrol's seed context now names its resource, so two
resources with the same alarm stay distinguishable.
- **Alert links.** The alert card used to find vSphere by searching the
message for "vmware". It now routes on the incident provider. ESXi host
alarms now link to vSphere too; on main they linked to Machines, which
does not list hosts. The card's test is now a registered alerts proof.
- **Rows that explain their dot.** vSphere Datastores and Networks get
the exception-first Health column that TrueNAS storage has. The cell
moves into a shared `PlatformIssueReason`, and TrueNAS now uses it with
identical markup, classes, colours and sizes in light and dark. A row's
dot and its reason come from one classification. An inaccessible
datastore in maintenance stays red and gives both reasons.
- **Layout.** Hosts shows only from a 72rem container, where it reads
instead of truncating; the drawer keeps it at every width. The tablet
band splits columns by weight.

**Verification**
- **Private mock stack, before and after.**
  - Column sweep from 390 to 1920px: names never clip.
- Hosts no longer clips at 600–1024, where main clipped 7–8 rows, and
clips less than main at 1280 and 1440.
- Only the longest alarm names truncate in the tablet band, with the
full text on hover.
- **Exercised live:** Health sort, the Attention filter, row drawers,
dark mode, the Alerts overview links, Patrol's full inbox and the
vSphere Health tab.
- **Tests:** 3,305 frontend tests and the `internal/ai`,
`internal/vmware`, `internal/unifiedresources`, alerts and monitoring
packages pass, along with the shared-template audit and the completion
guard.
- **Review:** a second reviewer found eight issues over three passes,
and all are fixed with tests:
  - the vSphere alert link;
  - the seed prompt identity;
  - maintenance masking an inaccessible datastore;
  - the dot disagreeing with the reason;
  - stored old-format titles;
  - the ESXi host link;
  - two title-trimming edge cases.

One suggestion was left on purpose: acknowledged cards hide severity for
every alert type by design.
2026-10-06 11:11:52 +01:00
rcourtman 108b113383 Keep parent node identity out of Proxmox child aliases
Guests and storage copy their node's NodeIdentity, NodeName and
NodeAliases into ProxmoxData so search and filters agree after a node
rename. canonicalAliases then listed those values as aliases of the
child itself, so every guest answered to the node's alert resource ID
(for example homelab-minipc). The workloads table matches alerts against
canonical aliases, so a single node temperature or memory alert painted
every guest on that node yellow and showed up in each guest's Needs
Attention panel. The shared alias also made the node's own alias lookup
ambiguous in the registry index.

Only the node resource now lists its node identity as canonical aliases.
Children keep the inherited fields in ProxmoxData, which is what search,
storage and backup consumers already read.
2026-10-06 11:03:01 +01:00
rcourtman c101b9486a Name vSphere, not Proxmox, as the owner of vSphere VM records
describeResourceInventoryOwnership checked the resource-type list
('vm', 'system-container', ...) before the platform, so any VM
mapped to Proxmox whatever platform the caller passed. A vCenter
alarm on a vSphere VM opened a Monitoring menu saying "Proxmox
remains the inventory owner", and the vSphere VM drawer's Manage
tab said "Proxmox owns this inventory record ... without deleting
it from Proxmox". Both are wrong for a user deciding whether
retiring the VM touches vCenter.

The helper also matched only the bare 'proxmox' and 'vmware'
families, while the resource drawers pass canonical platform keys
('proxmox-pve', 'vmware-vsphere', 'truenas'), so those never matched
a platform at all.

An explicit platform now decides the owner: proxmox and proxmox-*,
vmware and vmware-*, kubernetes, docker, truenas (newly named) and
agent. The resource-type guess is only a fallback when the platform
is empty or unrecognised, so ('vm', undefined) still maps to Proxmox.
2026-10-06 10:55:55 +01:00
rcourtman 7bbeddd5b0 Say why vSphere datastores and networks need attention
An amber vSphere datastore or network gave no reason on its row: the
vCenter alarm behind it sat one click away in the drawer or one tab
away on the Overview. The alarm text itself led with boilerplate
("Network network-302 has VMware alarm ... (yellow)"), so the Overview
truncated the part that mattered.

A vCenter incident summary is now what vCenter flagged: the alarm's
own name, or "vCenter health is <colour>". Every surface that shows
it already names the resource and severity. Alert cards, notification
templates, and Patrol titles carry the resource name. Patrol's seed
context now names the resource on each alert line, and Patrol titles
key off the incident code instead of parsing the wording, while still
reading records persisted in the old form.

The alert card used to find vSphere by looking for "vmware" in the
message. It now routes on the incident provider, which also sends ESXi
host alarms to vSphere instead of to Machines, where hosts are not
listed. The card's own test becomes a registered alerts proof.

The Datastores and Networks tables gain the exception-first Health
column that TrueNAS storage uses. The cell moves into a shared
PlatformIssueReason, so the two platforms cannot drift; TrueNAS
renders the same. A row's dot and its reason now come from one
classification. Hosts shows only from a 72rem container, where it
reads instead of truncating, and the tablet band splits columns by
weight.
2026-10-06 10:45:16 +01:00
rcourtman 25f4131720 Close the harness command client before Windows removes its state dir (#2558)
Since #2550 merged, `main`'s Windows x64 job fails three
`internal/hostagent` tests, and so does every PR whose diff triggers
that job. The check isn't required, so #2550 and later PRs merged while
it was red:

```
--- FAIL: TestObserverAckNeverReleasesOrNamesTheCommandChannel
    testing.go:1464: TempDir RemoveAll cleanup: unlinkat ...\operation-receipts.db: The process cannot access the file because it is being used by another process.
--- FAIL: TestSupersededCommandClientCannotOccupyTheRunSlot   (same)
--- FAIL: TestReplayedReportAckNeverNegotiatesTheCurrentChannel   (same)
```

**Cause:** these three tests build the command identity harness but
never call `run`. Nothing closes the command client that `New` created,
so its receipts database stays open, and Windows refuses to delete an
open file. Other platforms delete it anyway, which is why only Windows
fails.

**Change:** the harness now stops and closes the command client in a
`t.Cleanup`. That cleanup is registered after the state `TempDir`, so it
runs first. `CommandClient.Close` is idempotent, so the tests that do
`run` the agent are unaffected.

This is a test-only change. Production already closes the client when
`Run` exits or a new identity supersedes it.

**Verification:** I can't run Windows locally. On macOS, a throwaway
check (not committed) confirmed that without this change the harness's
client is still open after all the test's cleanups, and with it the
client is closed. `go test ./internal/hostagent -count=3` passes. This
PR's own Windows x64 job is the real proof.
2026-10-06 10:42:03 +01:00
rcourtman b15bfb1591 Keep Docker update alerts open when the local digest is unknown (#2557)
Refs #2353.

Docker update alerts resolved and re-fired about a day later, with the
resolution attributed to `legacy-alert-recovery-adapter` and no fresh
registry check in between. That label is the default for any clear
without explicit recovery evidence, so on its own it doesn't say what
cleared the alert.

The daily tracking-expiry and restart paths were fixed in f7a43d2794 and
ed8415ea6a, both in v6.5.0. This PR closes one more path that produces
the same symptom.

**Cause**
- Image inspect shares the 15-second per-container collection budget
with the sized container inspect, the one-shot stats call and filesystem
collection, so on a busy host it can time out.
- The agent then had an empty local digest and no platform. Comparing an
empty digest reads as "not different", so the agent reported
`UpdateAvailable: false` with no error and the registry's latest digest
attached.
- The server treated that as recovery: it resolved the alert and
restarted the delay, so the alert re-fired a full delay later.

**Fix**
- **Agent:** `localImageDigestsForUpdateCheck` falls back to the last
successful inspect of the same image ID. Image IDs name immutable
content, and the memo is pruned each cycle to the images in use. With no
earlier inspect, the container carries no update status that cycle,
which the server already treats as unknown.
- **Server:** a no-update status without a current digest isn't a
comparison, so it's now handled like an absent or failed check:
`LastSeen` refreshes, and the occurrence and its pending age are kept.
This also protects installs whose agents predate the fix; the reporter
runs v6.3.2 agents. Only the agent produces these statuses, and it
always sends the digest it compared. Locally built images, which have no
digest, never assert an update.

**Tests**
- `TestCollectContainerKeepsUpdateStatusWhenImageInspectFails` covers
both the fallback and the unknown case, with the registry answer seeded
offline. On the previous code it reproduces `{UpdateAvailable:false,
CurrentDigest:"", LatestDigest:"sha256:new", Error:""}`.
- The new "no update without a current digest" case of
`TestCheckDockerContainerImageUpdate` checks that the alert and its
pending age survive, and that a real comparison still resolves it. It
fails without the guard.
- The three retention and restart tests built their "affirmative clear"
without a current digest, which no real agent sends. They now carry one.
- `go test ./internal/alerts ./internal/dockeragent` passes.

I could not reproduce the reporter's own host, so this is code-level
evidence, not field confirmation.
2026-10-06 10:29:23 +01:00
rcourtman d52131e0d1 Close the harness command client before Windows removes its state dir
Since #2550, three command identity tests fail on Windows x64:
TempDir cleanup cannot delete operation-receipts.db because the agent's
command client still holds it open. Those tests never call run, so
nothing closes the client that New built, and Windows refuses to delete
an open file. Other platforms delete it anyway.

Stop and close the command client in a harness cleanup. It is
registered after the state TempDir, so it runs first, and Close is
idempotent for the tests that do run the agent. Production already
closes the client when Run exits or an identity supersedes it.
2026-10-06 10:21:05 +01:00
rcourtman 2bd9000faf Keep Docker update alerts open when the local digest is unknown
Docker update alerts resolved and re-fired a full delay later with no
fresh registry check in between (#2353). Image inspect shares the
15-second per-container collection budget with the sized container
inspect, stats and filesystem calls, so on a busy host it can time out.
The agent then compared an empty local digest, which reads as "not
different", and reported UpdateAvailable false with no error. The server
took that as recovery, resolved the alert and restarted the delay.

Agent: fall back to the last successful inspect of the same image ID
(an image ID names immutable content), pruned to the images in use. With
no earlier inspect, send no update status, which the server already
treats as unknown.

Server: a no-update status without a current digest is not a comparison,
so treat it like an absent or failed check. This also covers agents that
predate the fix. Only the agent produces these statuses and it always
sends the digest it compared; locally built images never assert an
update. The retention tests' clear fixtures now carry a current digest,
as real agents do.
2026-10-06 10:01:28 +01:00
rcourtman 1896f1f2ba Stop the Restarts and Lifecycle headers clipping at tablet widths (#2555)
Two column headers were clipped at tablet widths.

**Docker containers "Restarts".** The header clipped to "RES…" between
600 and 800px, and widening the column clipped "Memory" instead.
- Below a 720px table the restart count now lives in the row drawer, as
it already did on phones.
- So the crash-loop warning stays visible on narrow screens, a running
container that has restarted more than five times shows "7 restarts" in
amber in its State cell there.
- Sorting by State uses that text.

**Kubernetes Configuration "Lifecycle / trust".** The header clipped to
"LIFECYCLE / …" through the tablet band.
- The shared responsive header label gains `compactInBand`, which keeps
the short label it already shows on phones ("Status") through that band.
- Other headers are unchanged.

Verification:
- Header sweep from 390 to 1920px against main: no clipped header at any
width, and no column clips more cells than before.
- Rebased onto the Docker drawer health change (#2534) and re-verified.
- Sol's two findings are fixed: the lost crash-loop signal, and State
sorting.
- Tests and guardrails added, receipt included.
2026-10-06 09:47:22 +01:00
rcourtman 8373b52449 Resolve masked webhook values per key and keep responses masked (#2556)
Follow-up to #2545.

`GET /api/notifications/webhooks` masks every header value, custom field
value and signing secret as `***REDACTED***`. The editor sends those
masks back for anything the user did not retype. Three paths mishandled
them:

1. **An edit could silently drop new values.** Update restored the
*whole* saved header (or custom-field) map whenever any value was
masked. So a header added in the same edit, such as an `Authorization`
token, was discarded with no message.
2. **Responses returned secrets.** Update echoed the restored saved
custom fields, so a Pushover token and user key came back in the
response even though the list masks them. This breaches the
api-contracts clause: "Webhook list/create/update responses must return
the normalized routing state without exposing masked headers, custom
fields, or signing secrets". Create echoed the request, and could store
a masked value verbatim.
3. **Testing an edit sent the mask.** The editor's Test button posts the
whole form. After a reload, testing an edited Pushover webhook sent the
literal `***REDACTED***` as its token. Any secret header behaved the
same way.

**Change**
- Each masked value now resolves to the saved value of the same key.
This applies on `PUT /webhooks/{id}` and on `POST /webhooks/test` when
the body carries a saved webhook's `id`. Values typed in the same
request are kept. A mask with no saved value is dropped, so the mask is
never stored or sent. Both routes already require `settings:write`, so
the test route reaches no secret an editor cannot already keep.
- List, create and update responses are all built from one masked shape
(`maskedWebhookResponse`).
- `NormalizeWebhookConfig` drops headers with a blank name or value.
Clearing a header now removes it instead of sending an empty
`Content-Type`. Generic receivers could not parse that, which is the
generic-webhook version of #2540; #2545 already covers built-in
services.

**Tests**
- `TestWebhookMaskedValuesResolvePerKey` covers update with typed and
masked values, a cleared header, create, and the form test for saved and
unsaved webhooks. All five subtests fail on the previous code.
- `TestGenericWebhookBlankStoredHeaderKeepsDefaultContentType` covers
delivery for a generic webhook with a blank stored header.
- The two Pushover canonicalisation tests now assert canonical keys with
masked values.
- `go test ./internal/notifications ./internal/api/alerting` passes, as
do the `internal/api` notification contract tests and the
`internal/config` webhook tests.

The UI is unchanged and I did not exercise this flow in a browser. The
tests drive the handlers with the payload shape the editor sends (full
form state, including `id`, with masked values for untouched secrets).
2026-10-06 09:39:47 +01:00
rcourtman f279dc0b07 Resolve masked webhook values per key and keep responses masked
The webhook editor sends back the ***REDACTED*** values the list returns
for every header, custom field and signing secret the user did not
retype. Three paths mishandled them:

- Update restored the whole saved header or custom-field map whenever any
  value was masked, silently dropping a header or field added in the same
  edit.
- Update echoed the restored saved custom fields, so a Pushover token and
  user key came back in the response even though the list masks them, in
  breach of the api-contracts webhook response clause. Create echoed the
  request, and could store a masked value verbatim.
- The editor's Test button posts the form, so testing an edit after a
  reload sent the literal mask as the token or header.

Resolve each masked value to the saved value of the same key on update and
on a form test that carries a saved webhook's id, keep values typed in the
same request, and drop masks with no saved value. Build list, create and
update responses from one masked shape. Normalization now drops blank
headers, so clearing a header removes it instead of sending an empty
Content-Type that generic receivers cannot parse.
2026-10-06 09:21:41 +01:00
rcourtman aa1d65f6fc Record browser proof for index.css after merging main
Main's Alert History column widths (#2537) changed index.css after
this branch's browser pass, so the merged file had no receipt. The two
changes touch separate rules; the merged file was re-measured on the
Docker containers, Kubernetes configuration and Alert History tables.
2026-10-06 09:20:09 +01:00
rcourtman 417cd7f675 Merge remote-tracking branch 'origin/main' into fix/tablet-header-clips 2026-10-06 09:18:09 +01:00
rcourtman bbccd624b2 Size Alert History columns to content so messages read (#2537)
## Problem

The desktop Alerts → History table gave every visible column an equal
share: 16.7%, 14.3% or 11.1%, depending on how many columns the card can
show. So Message, the column that says what happened, got the same
100–160px as Timestamp:

- **Message:** at most 21 of 106 messages read in full at any width
("Kubernetes Clust…", "Docker container…"). Users had to open each row
to see what fired.
- **Status:** "acknowledged" (98px) was cut at a 1024px viewport, where
Status got 100px.

## Change

Each `<col>` now reads its width from a per-column custom property
(`--alert-history-<column>-width`), which the table sets at each density
in `index.css`. The existing visibility classes and density breakpoints
are unchanged, plus a new density at an 80rem card. Shares are sized for
the narrowest card at each density:

- **Content-sized:** Timestamp, Severity, Duration, Status (fits
"acknowledged"), Type and Node get what their content needs.
- **Protected:** Resource and Node keep at least their old share, so
they don't truncate more.
- **Actions:** the row buttons stay within two lines, and fit on one
line from an 80rem card.
- **Message:** takes the rest.

The table width still compensates for the collapsed tracks (it's the sum
of all nine track widths), so the visible columns fill the card exactly.
`App.architecture.test.ts` checks that arithmetic at every density, and
`Alerts.helpers.test.ts` pins the per-column tracks.

## Verification

Mock-mode private stack on main 1dcf9418aa, before against after, 106
rows, 15 widths from 768 to 1920px:

| Viewport | Messages fully readable (main → after) |
|---|---|
| 768 | 0 → 4 |
| 1280 | 8 → 39 |
| 1440 | 15 → 66 |
| 1760 | 17 → 83 |
| 1920 | 21 → 87 |

The only width with no gain is the 600px card at a 1024px viewport,
where the navigation rail leaves six columns very little room; it's 0 on
both main and this change.

- **Nothing worse:** every pill fits. Resource and Node truncate exactly
as often as on main at every width, and no header clips.
- **Layout:** the table width equals the card at every width, with no
scrollbar. Median row height is unchanged except from 1760px, where it
halves from 75 to 39px because the actions fit on one line.
- **Interactions:** the Timeline and Resource panels still open inline
and close at 1024, 1280 and 1760px. No console errors.
- **Tests:** 113 test files across `src/features/alerts`, `src/pages`,
`src/__tests__` and `src/components/Alerts` pass. The new pins fail
against main's source.

The browser receipt is
`frontend-modern/browser-verification/1f8abaa05e35815a.json`.
2026-10-06 09:14:44 +01:00
rcourtman 5b335fdfed Stop the Restarts and Lifecycle headers clipping at tablet widths
Docker containers clipped its Restarts header to "RES..." between 600
and 800px, and widening the column clipped Memory instead. Below a 720px
table the count now waits in the row drawer, as it already did on
phones, and a running container that restarted more than five times
says so in its State cell there ("7 restarts"), so the crash-loop
warning stays visible on narrow screens; State sorts by that same text.

Kubernetes Configuration clipped "Lifecycle / trust" to "LIFECYCLE /
..." through the tablet band. The shared responsive header label gains
compactInBand, which keeps the compact label it already shows on phones
("Status") through that band; other headers are unchanged.
2026-10-06 09:14:26 +01:00
pulse-triage[bot] ce3262f423 Bump golang in /.devcontainer (#2554)
Bumps golang in `/.devcontainer` from `6c2a553` to `0f063af`.

Updates `golang` from `6c2a553` to `0f063af`


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang&package-manager=docker&previous-version=1.26.8&new-version=1.26.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>
2026-10-06 06:44:00 +01:00
dependabot[bot] ed9ee88ca6 Bump golang in /.devcontainer
Bumps golang in `/.devcontainer` from `6c2a553` to `0f063af`.


Updates `golang` from `6c2a553` to `0f063af`

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docker
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-06 03:34:16 +00:00
rcourtman 9615e46d25 Register agent command channels under the identity Pulse resolves (#2550)
## Problem

An agent's command channel could be refused for good after a restart
whenever the ID it presented differed from the host ID Pulse resolved
for its reports. This applied to every install path.

- The first command-channel registration bound the install token to
whatever ID the agent presented, and a binding never moves.
- The agent registered before its first report, under a configured
`--agent-id`, or under an ID Pulse had forked for a cloned machine ID or
kept from an earlier enrollment.
- Pulse resolved its reports to another host ID and acknowledged that as
`agentId`, and the agent persisted it.
- The config gate judges the resolved ID, so it switched the operator's
commands off.
- The next restart registered under the persisted ID: "Agent token
mismatch: token is not bound to the registering agent", indefinitely.

This was the remaining gap called out in #2542.

## Why not move the binding at report ingest

The obvious fix is to rebind the token to the resolved ID when a report
arrives. It isn't safe. Identity resolution matches a report onto a
host's continuity record by machine ID without checking the token, and a
token-machine binding then keeps that resolution stable. A holder of one
host's token could send one report that resolves onto another host's
retained record, then a second claiming the bound ID, and move the
token's command identity across hosts. A forged report and a
legitimately stale one look identical, so this change moves no binding
from report contents.

## Fix

**Server (`internal/api/agent_ingest.go`)**
- Report acknowledgements name `commandAgentId`, the resolved host ID,
only when the token's binding already holds it, or when a registration
presenting it would take the first bind, backfill, legacy migration,
hostname rebind or one-time deploy repair it would get anyway.
- The credential must be one command admission takes (`agent:exec`, at
most one organization, legacy or no runtime role), and the host must be
held under the reporting token, so mock acks name nothing.
- The ID is judged for the hostname the report presents.
- Naming grants nothing that registration wouldn't. The config gate
takes the same decision.

**Agent (`internal/hostagent`)**
- The command channel stays staged until Pulse acknowledges a report,
then registers under the named identity.
- When Pulse names none (an older Pulse, or a token already bound to the
presented ID such as an existing `--agent-id` install), it registers
under the agent's own ID as before.
- Only a real ack negotiates the identity: `success` set, a host named,
and a report that presents this process's agent ID and hostname. An
unreadable or non-ack body, a replayed buffered report from an earlier
run, or an observer ack neither names nor releases the channel.
- A 403 (the token lacks report scope, so Pulse can never answer)
releases the channel under the agent's own ID, keeping today's behaviour
for exec-only tokens.
- A client Pulse has admitted is never rebuilt, because that would
reopen its receipt store and mark in-flight operations interrupted. A
client Pulse never admitted is retired and rebuilt under the named
identity, behind a compare-and-swap so it can't be admitted afterwards.
- A client a concurrent disable has already cleared can no longer take
the run slot.

## Tests

- **Agent (`internal/hostagent/command_client_test.go`):**
- registers under the acknowledged identity, and waits for an accepted
report;
- keeps its own ID when none is named, and keeps the acknowledged ID
through a later ack that names none and through a disable/enable;
  - ignores unreadable and non-ack bodies;
  - releases on 403;
- leaves an admitted running client in place, and moves a never-admitted
one;
  - ignores observer acks and replayed buffered reports;
  - lets no superseded client take the run slot;
  - CAS exclusivity, plus a real websocket handshake test.
- **Server:**
- `TestHostAgentCommandChannelBindsTheIdentityPulseAcknowledges`: end to
end through the router. A configured-ID install goes through report,
registration, restart, and the gate keeping commands on.
-
`TestContract_UnifiedAgentReportAckNamesCommandIdentityOnlyWhenTheBindingAdmitsIt`:
the field is present only when admitted, and absent for bound-elsewhere,
no-exec, collector and two-org tokens.
  - Security cases in `security_regression_test.go`:
    - an established binding never moves;
    - a hostname-bound deploy token backfills the resolved ID;
    - the deploy placeholder repair lands on the resolved ID;
- an agent reconfigured after #2547 recorded its ID is named that ID;
    - mock acks name nothing.
- **Fail without the fix:** the behavioural agent and server tests were
run against the previous `agent.go` and `agent_ingest.go` and fail
there, apart from the compatibility and invariant checks (own ID kept,
established binding never moves), which pass either way by design. The
tests for the newest guards use new seams, so the mutation checks below
cover them instead.
- **Mutation checks:** each guard was dropped in turn (real-ack check,
current-identity check, never-admitted retirement, handshake admission,
admitted-client protection, binding decision, role check, org check,
token-held check), and each mutation fails at least one test.
- **Locally:** the full `internal/hostagent` and `cmd/pulse-agent`
suites pass; the new agent tests pass under `-race`; the governance
guards and audits pass.

## Not in this change

- Tokens already bound to a diverged ID are not recovered automatically,
for the reason above. Re-running the install command issues a fresh
token, which binds the resolved identity.
- If Pulse later changes a host's resolved ID (a rename heal), it is
named only where the existing binding rules already allow that ID.
2026-10-06 01:12:54 +01:00
rcourtman 8b4df6e1fa Use the shared link and button primitives in two remaining spots (#2549)
This clears the last shared-template audit findings. Combined with
#2546, the audit then passes.

- **Backup export and import warnings:** the "migration guide" links
were raw `target="_blank"` anchors. They now use `ExternalTextLink`. Its
new `inlineInherit` variant keeps the warning's own amber or yellow
colour and underline, so a link inside a tinted callout sentence doesn't
switch to link blue.
- **External watchdog:** the Refresh and Show/Hide buttons now use the
shared outline `Button` instead of a copied border shell. On desktop
they look and measure the same as on main (26px and 38px). On phones
they become 44px touch targets.
- `BackupTransferDialogs.tsx` is now a registered required consumer of
`ExternalTextLink`, and the guardrail pins the new variant.

Verification:
- Checked live on a mock stack against main, in light and dark mode at
1440px and at 390px.
- The links keep their callout colour, stay inline and open in a new tab
with noopener.
- The receipt is included.
2026-10-06 00:53:39 +01:00
rcourtman b12af35847 Paint the backgrounds the dead bg-base class dropped (#2548)
`bg-base` has rendered nothing since the `base` colour alias was removed
(it also coloured the `text-base` font-size utility). Tailwind 4 ignores
unknown colour classes without warning, so about 85 uses were
transparent. On the page background that looked the same. Inside cards
it did not:

- command, JSON and XML blocks lost their wells (External agents, Agent
Doctor, installer, update guide, Ollama, SAML preview, setup wizard);
- the Patrol mode segmented control showed its selected option as a
white chip on a white track;
- the branding header preview vanished into its card;
- the Dead Man's Switch "Not configured" pill read as plain text;
- the alert bulk-selection bar showed white count text on a see-through
pill.

What changed:
- New `@utility bg-page` (the page backdrop, deliberately not a theme
colour) for wells inside surface cards.
- `bg-surface` for blocks on a surface-alt body (resource drawer JSON,
correlation and policy cards) and for the floating bulk bar;
`bg-surface-alt` for the muted Card tone; `hover:bg-surface-hover` for
drawer buttons whose hover did nothing.
- The class is dropped from page shells that sit directly on `<body>`,
which already paints the page colour.
- Command text that assumed a dark well (`green-400`, `blue-400`,
`emerald-400`, `slate-300`) gets light-mode shades so it stays readable.
- `App.architecture.test.ts` fails if a runtime class uses `bg-base`
again or a theme colour named `base` comes back.

Verified on a private mock stack before and after, in light and dark, at
1440px and 390px; details are in the browser receipt. A few surfaces
can't be reached with mock data (the drawer change history and its
coloured buttons, Quick Security Setup, the profile-suggestion JSON, the
Patrol tool-call trace). Their new tokens follow the same container
rules, and the drawer button hover was measured with the exact classes
in the live drawer.
2026-10-06 00:44:10 +01:00
rcourtman f77c8b2f3d Register agent command channels under the identity Pulse resolves
A token binds the first agent ID its command channel presents and never
moves. The agent registered that channel before its first report, under
its configured --agent-id, or an ID Pulse had forked for a cloned
machine ID or kept from an earlier enrollment. Pulse meanwhile resolved
its reports to another host ID and acknowledged it, and the agent
persisted that ID. The config gate, which judges the resolved ID, then
turned the operator's commands off, and the next restart under the
persisted ID was refused for good.

Report acknowledgements now name commandAgentId: the resolved host ID,
only when the token's binding already holds it or a registration
presenting it would take the first bind, backfill, legacy migration,
hostname rebind or deploy repair it would get anyway. The credential
must also be one command admission takes, and the host must be held
under the reporting token. Naming grants nothing that registration
would not. An established binding to another ID is never moved from
report contents, because resolution can map a report onto another
host's continuity record by machine ID, so a forged report and a stale
one look the same. The config gate takes the same decision.

The agent keeps its channel staged until a report is acknowledged and
registers under the named identity, or under its own ID when none is
named, which is what an older Pulse or an already-bound token sees.
An unreadable ack keeps it waiting, and a 403 releases it under its own
ID because Pulse can never answer that token. A running client is never
rebuilt, since that would reopen its receipt store under in-flight
operations. A superseded client can no longer take the run slot.

Tokens already bound to a diverged ID stay as they are; re-running the
install command issues a fresh token that binds the resolved identity.
2026-10-06 00:36:41 +01:00
rcourtman d961e0b18f Let agents without a command channel reduce their authority (#2547)
## Problem

The safe-profile migration's authority reduction (`POST
/api/agents/collector/reduce-authority`) needs the agent's ID on its
token: `bound_agent_id` must be set and equal the ID the installer
sends. Until now only a command-channel registration wrote that ID.

Deploy enrollment (since rcourtman/Pulse#2542), Proxmox
auto-registration and pre-v6.1.1 installs all leave a token bound to the
node's hostname with no agent ID. The ID is filled in only at the
agent's first command registration. An agent whose token lacks
`agent:exec`, or that never connects the command channel, never gets an
ID on its token. The reduction then refuses it with "Collector
credential binding mismatch" for as long as the agent runs. Before
#2542, deploy tokens instead carried an invented `agent-<hostname>`,
which failed the same check.

## Fix

The report path now records the agent's identity on such a token, under
the same rules as command binding:

- **When it binds.** After `/api/agents/agent/report` resolves a report,
a token with a `bound_hostname` and no `bound_agent_id` records the
server-resolved host ID. This happens only when the report presented
that same ID. In the same run that is the ID the agent's command
registration presents. It is also the `agentId` the response
acknowledges, which the agent persists and the installer sends to the
reduction.
- **Same decision as the command channel.**
`agentbinding.EvaluateReportedIdentity` records only where `Evaluate`
would first-bind, legacy-migrate or backfill that ID for the reported
hostname. It also requires:
- a token bound to at most one organization, whose command-session
organization matches the report's;
  - a collector, legacy full-trust or empty runtime role;
  - a token that has not expired.
- **Only a host Pulse actually holds.** The resolved host must be one
the monitor holds under that token (`host.TokenID`). Mock mode's
discarded, unresolved acknowledgements therefore never qualify, however
mock mode is toggled.
- **Judged on the report's own hostname.** That is the hostname the
agent's command registration presents, and it matters because a report
rejected by the ordering watermark is answered with the stored host.
- **Nothing else changes.** The path writes `bound_agent_id`, `bound_at`
and the binding version. The binding version makes the ID immutable to
every later registration. Scopes, runtime role, command policy and
`bound_hostname` are never touched. A bound ID never moves and a deploy
placeholder is never repaired. A recorded identity grants no execution
authority: command admission still needs `agent:exec` and the same ID.
- **Same store and lock as command admission.** The write goes to the
router's canonical token store and persistence, under the token lock
command admission holds, against the live record. A failed save restores
the previous metadata and the next report retries.
- **Cheap screen first.** The handler checks the request's token
snapshot before taking the exclusive token lock, so already-bound or
ineligible tokens cost nothing per report.

Contracts updated: agent-lifecycle, security-privacy, api-contracts and
storage-recovery.

## Behaviour change: the first recorded identity is final

As with a command registration, the first identity recorded is final.
Take an agent that reported under its machine ID while it had no command
channel, for example with commands off. If it is later restarted under a
different `--agent-id`, its command registration under the new ID is
refused on that token. A report replayed from an earlier run's buffer
has the same effect. This is exactly what happens today when that first
run's command channel did connect.

`TestReportedIdentityIsFinalForALaterReconfiguredAgent` pins the
behaviour. Command-channel identity convergence lifts it: that follow-up
to #2542 has the agent register under the identity Pulse names in the
report ack, and it builds on this decision.

## Tests

-
**`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport`**
(`internal/api/contract_test.go`) replays enrollment without commands,
the first report and the reduction, through the real router. It checks
that the token's scopes and runtime role are unchanged and that command
admission is still refused. It fails without the fix: the reduction
returns 403.
- **Boundary tests in `internal/api/security_regression_test.go`:**
- `TestReportedIdentityMatchesCommandRegistrationAndNeverMoves`: the
recorded ID matches the agent's own command registration; neither a
second identity's registration nor another machine's reports moves it;
an ID the command channel bound first survives a report that resolves
elsewhere.
-
`TestReportedIdentityRecordsNothingWhenTheResolvedIdentityIsNotThePresentedOne`:
an agent with a configured `--agent-id` records nothing, and its command
registration is still admitted.
- `TestReportedIdentityIsFinalForALaterReconfiguredAgent`: pins the
trade-off above.
- `TestReportedIdentityJudgesAReplayedReportByItsOwnHostname`: a report
the ordering watermark rejects is answered with the stored host, and is
judged on its own hostname rather than the stored one.
- `TestReportedIdentityRestoresTheTokenWhenTheSaveFails`,
`TestReportedIdentityRequiresTheTokensOwnOrganization` and
`TestReportedIdentityIgnoresAnExpiredToken`.
- `TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder`: the
handler's snapshot screen and mock mode.
- **`TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds`**
(`internal/monitoring/monitor_host_agents_test.go`) pins the monitor
properties the handler relies on:
  - an applied report carries its token's ID;
  - a watermark-rejected report returns the stored host and hostname;
  - mock mode's discard acknowledgement carries no token.
-
**`TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind`**
(`internal/api/agentbinding/policy_test.go`) is the policy table: the
deploy, auto-registered, legacy and equivalent-hostname cases, plus
every refusal, including a versionless token already bound to an ID.
- **Mutation checks.** Each of these fails at least one named test:
  - dropping the wiring;
  - allowing presented ≠ resolved;
  - letting a bound ID move;
  - allowing multiple organizations;
  - allowing any runtime role;
  - skipping `Evaluate`;
  - not stamping the binding version;
  - not restoring on a failed save;
  - dropping the organization or expiry check;
  - dropping the held-host check;
  - judging by the stored host's hostname;
  - dropping the snapshot screen.

Reviewed adversarially in three passes:

1. The first pass found two issues: the overstated "same ID as command
registration" claim, now scoped to the same run and paired with the
final-identity trade-off; and a mock-mode toggle race, replaced by the
held-host check.
2. The second pass found that replayed reports were judged on the stored
hostname, now judged on the report's own.
3. The third pass found nothing further.

Governance:

- `canonical_completion_guard.py` passes in CI mode on this commit.
- `registry_audit.py --check`, `contract_audit.py --check` and
`status_audit.py --check` pass.
- All governance `*_test.py` suites from `canonical-governance.yml` and
`go test ./internal/repoctl` pass.

## Not in this change

- **Presented ≠ resolved.** When the agent presents an ID other than the
one Pulse resolves (a configured agent ID, a continuity fork), nothing
is recorded. Reconciling that disagreement belongs to the
command-channel identity convergence follow-up.
- **Tokens bound to no hostname.** Generic host install tokens without
`agent:exec` still get their binding only from command-channel first
use, so their reduction still has no ID to match.
- **Historical deploy placeholders.** Tokens still bound to
`agent-<hostname>` are repaired only by command registration, so a
placeholder token without `agent:exec` still cannot reduce.
- **Builds on #2542**, which has merged; this branch adds one commit on
top of it.
2026-10-06 00:35:23 +01:00
rcourtman 77bdc77b35 Move three forms onto shared controls and draw the select arrow (#2546)
This clears the shared template audit (`node
scripts/shared-template-audit.mjs`) for the forms that still built their
own controls. Three findings remain, in BackupTransferDialogs.tsx and
AlertDeadManDestinationSection.tsx. Those two files are part of an open
`bg-base` change and will follow after it lands.

**Moved onto shared controls:**
- **Alerts › Thresholds › CPU averaging:** the ten per-platform selects
now use `FormSelect`, with each profile name as a visible label.
- **Discovery availability proposal:** the Interval and Observation
location fields use `FormSelect`, and the check-name field next to them
uses the shared field classes, so the three match.
- **Availability check › Request body:** the field uses `FormTextarea`.
Its size note is now the field's accessible description. With
`sm:min-h-24` it also gets the 96px height its class asked for on
desktop; on main it rendered at 54px.

**Select arrow.** `FormSelect`'s default chrome set `appearance-none`
and drew no arrow of its own, so every default select in Settings read
as a text field. That includes the existing Preset, Target type and
Probe selects in the availability dialog. A new `form-select-arrow`
utility draws a caret in the padding the class already reserved, using
the muted text colour. In forced-colors mode it hands the native arrow
back. Filter-bar selects use their own class and are unchanged.

**Registry fixes:**
- `AlertEscalationSection` is removed from the `form-select-shell`
required consumers. It has used checkboxes since e0baecc418.
- The inline detail row rule now excludes test files. Its only finding
was `ProxmoxBackupsTable.test.tsx`.

Verification:
- Checked live on a mock stack, before and after, in light and dark mode
at 1440px and 390px.
- The Discovery card was rendered through route interception.
- Forced-colors mode was emulated.
- 11,393 tests pass, and the receipt is included.
2026-10-06 00:20:46 +01:00
rcourtman df41170313 Send built-in webhook template headers on real alerts (#2545)
Fixes #2540.

A Telegram webhook passed **Test** while every real alert was rejected
with `Bad Request: message text is empty`.

**Cause.** The two paths built their headers differently:
- The Test path always overlaid the service template's headers
(`Content-Type: application/json`).
- Real deliveries sent the stored header copy that the UI seeded when
the webhook was created. An edit can leave that copy blank, masked or
replaced. For example, the edit form shows the stored value as
`***REDACTED***`, and clearing it saves an empty `Content-Type`.

Telegram's HTTP reader parses body arguments only for JSON,
form-urlencoded or multipart content types, and `sendMessage` checks
`text` before `chat_id`. So a JSON body without a JSON Content-Type
produces exactly that error.

**Fix.** When a built-in service renders its own body (no custom
template), the template's static headers describe that body:
- `prepareWebhookDeliveryContext` applies them on every real firing and
recovery, replacing stored values under any spelling of the header name.
Unrelated stored headers stay.
- The Test path uses the same `withWebhookTemplateHeaders` helper, so a
passing Test predicts real delivery.
- Custom templates and generic webhooks keep their stored headers. ntfy
recoveries still build their own plain-text request.

**Tests** (`internal/notifications/webhook_template_headers_test.go`):
- A real queued Telegram alert, with blank, masked, replaced, lowercase
and duplicate stored Content-Type values, now sends JSON with `text`.
Before the fix it sent `""`, `***REDACTED***` or `text/plain`.
- For every built-in service, the Test, grouped and resolved requests
carry identical template headers and keep unrelated stored headers.
- A custom template keeps its stored Content-Type.

`go test ./internal/notifications` and `./internal/api/alerting` pass.
2026-10-06 00:19:35 +01:00
rcourtman d043c09341 Show a Docker container's health check in its drawer (#2534)
Follow-up to #2526. The containers table now says Unhealthy or Exited
(139), but the row's drawer still disagreed with it. A running container
whose health check was failing read **Observed state: Online**, and a
crashed one read **Offline**. The drawer never mentioned the health
check, and it could show uptime that a stopped container had left
behind.

For a Docker container, the drawer's Runtime context now uses the same
presentation as the table (`dockerContainerStatePresentation.ts`):

- **Observed state** is Docker's own state, such as Running, or Exited
(137) in red.
- **Health check** is a new row for a running container: Failing (red),
Starting (amber) or Passing.
- **Uptime** covers only the current run. A stopped container shows
neither a stale health check nor leftover uptime.

Other resources are unchanged.

Verification:
- I opened the drawers at 1440px and on a 390px phone, using edited API
data: one container with a failing health check, healthy containers, and
a crashed container carrying a stale unhealthy status and leftover
uptime. I compared each against main.
- Tests cover the helpers and both drawer cases, and the receipt is
included.
2026-10-06 00:17:11 +01:00
rcourtman eaaea2214e Use the shared link and button primitives in two remaining spots
The shared template audit still flagged hand-built controls in two
places: the backup export and import warnings opened the migration
guide through raw new-tab anchors, and the external watchdog section
copied the outline button shell for Refresh and Show/Hide.

The guide links now compose ExternalTextLink. Its new inlineInherit
variant keeps the warning's own colour and underline, so a link inside
a tinted callout sentence does not switch to link blue. The watchdog
buttons compose Button (outline, xs and settingsAction), which keeps
their desktop look and gives them a 44px touch target on phones.
2026-10-06 00:08:07 +01:00
rcourtman b4ea3761e8 Keep paired PR benchmarks inside their time budget (#2544)
The non-required **Benchmarks** check has failed on every non-superseded
PR since about 15:00 UTC on 5 October. Examples are #2521, #2520, #2524,
#2525, #2527, #2530 and #2532, each ending with `Paired base and
candidate benchmarks (pull requests)` timed out after 25 minutes. The
paired step runs 10 base and 10 candidate samples, so each second added
per sample costs 20 seconds of step time.

### What made it slower

Paired step duration across PR runs, with per-package seconds per sample
taken from the `ok` lines in the job logs:

| Period | Paired step | `internal/unifiedresources` |
`internal/monitoring` |
|---|---|---|---|
| 24 Sep – 5 Oct 08:00 | 16–18 min | 7.9 s | 4.5 s |
| after #2486 (08:11) | 20.5–20.9 min | 16.2 s | 5.0 s |
| after #2518 (15:01) | killed at 25 min | 38.2 s | 9.9 s |

- **#2518** added `BenchmarkRegistryProjectionSnapshot`. Its `cold` mode
rebuilt the registry between `b.StopTimer` and `b.StartTimer` on every
iteration. Go picks `b.N` from timed work only, so the empty-registry
cases ran hundreds of thousands of untimed ingests. Per sample,
`/0/cold/point-targets` took 10.3 s and `/0/cold/bulk-targets` 5.9 s at
a 100 ms benchtime. That is the cause of the timeouts.
- **#2486** added 46 owner-index and metadata sub-benchmarks, about +8 s
per sample. That is legitimate coverage. #2518's
`BenchmarkBroadcastMetricsSnapshot` and #2527 added about +6 s more.
- `-count`, `-benchtime` and the package list did not change.

### Fix

1. **Root cause.** `cold` now invalidates the registry's derived state
(canonical metadata, typed views, source-target index) instead of
rebuilding the registry. The refresh never reads its own output, so this
repeats a fresh ingest's first read: at 1000 VMs, 40011 vs 40014
allocs/op and about 5.09 MB/op, the same as before. Locally the 24-case
family now runs in 4.9 s; before, its 6 cold cases alone took 33.8 s.
2. **Scope.** Paired PR runs now sample only packages whose in-module
dependency closure (`go list -deps -test`) holds a changed file. A
`_test.go` file selects only its own package. Changes to
`go.mod`/`go.sum`, dirty or non-Git trees, or an unreadable graph select
every package. Applied to today's failures: #2521, #2524 and #2525
sample nothing; #2530 samples `internal/api`; #2532 samples api and
monitoring; #2520 samples 5 packages; #2527 samples 7. The metadata
records `selection=` and `packages=`.
3. **Budget.** A module or shared-model change still samples nearly
everything, about 24 minutes including the ~2.7 min warm-up. The step
timeout is now a measured 30 minutes (job stays at 35).
`PULSE_BENCH_BUDGET_SECONDS=1680` makes the script project its total
after each round. Once the projection reaches 28 minutes it stops and
prints each package's mean seconds per sample, which replaces an
unexplained timeout. `TestPairedBenchmarksStopInsideTheirBudget` keeps
budget < step timeout < job timeout.

4. **Store-backed chart benchmarks** (second commit). Go calls a
benchmark function again for each `b.N` it tries.
`HandleWorkloadCharts_StoreBacked` and
`HandleWorkloadsSummaryCharts_StoreBacked` each seeded 84,000 points
into a fresh SQLite store (about 0.6 s) to time a request of 0.2 ms or
less, two or three times per sample. In CI that was 3.5 s and 3.6 s of
internal/api's 11.5 s per sample. The timed loop now runs in a
`range=4h` sub-benchmark, so the parent seeds once per run. Data,
request and bytes/allocs per op are unchanged, and locally the pair
falls from about 6 s to 1–1.5 s. The names gain `/range=4h`, so this
PR's comparison reports them unmatched.

Unpaired branch runs still sample every package. With the cold fix,
their 15-minute step (13–14.2 min today) regains about 3 minutes of
headroom.

### Verification

- `scripts/tests/test-ci-benchmarks.sh` covers no-op, own-test-only,
shared-dependency, unreadable-graph, dirty-tree, module-file,
budget-exceeded, in-budget and invalid-budget cases. Five mutations of
the selection and budget logic are each caught.
- On the real package graph, selection maps `internal/models` to 7
packages, `internal/monitoring` to api+monitoring, an `internal/api`
embed to api, and frontend or docs paths to none.
- End to end with real Go on two clean clones (`origin/main` vs this
change), only `./internal/unifiedresources/` was sampled. Its per-sample
time went from 53.9 s to 26.7 s on a loaded Mac.
- `go test ./scripts/installtests -run
'TestPairedBenchmarksStopInsideTheirBudget|TestBenchmarkQualificationRetainsProvenance'`,
plus the script-reference and doc-drift suites.

This PR's own Benchmarks run should sample only
`internal/unifiedresources` and `internal/api`, because both changes are
those packages' own test files.
2026-10-06 00:00:56 +01:00
rcourtman bf7f53d8b0 Paint the backgrounds the dead bg-base class dropped
Tailwind 4 emits nothing for a colour it does not know, and the `base`
colour alias was removed on purpose because it also coloured the
`text-base` font-size utility. Every remaining `bg-base` (about 85 uses)
therefore rendered transparent. On the page background that looked the
same, but command blocks, previews and status pills inside surface cards
lost their wells, the segmented filter track hid its selected option, and
the alert bulk-selection bar showed white text on a see-through pill.

Each use now gets what it was meant to have, checked live in light and
dark:
- a new `bg-page` utility (the page backdrop, not a theme colour) for
  wells inside a surface card: command and JSON blocks, the branding
  header preview, setup wizard wells, the segmented track;
- `bg-surface` where the block sits on a surface-alt body (resource drawer
  JSON, correlation and policy cards) or floats (the bulk-selection bar),
  `bg-surface-alt` for the muted Card tone, and `hover:bg-surface-hover`
  for drawer buttons whose hover did nothing;
- no class at all on page shells that sit directly on body.

Terminal-coloured command text that assumed a dark well gets light-mode
shades so it stays readable on the restored light well. An architecture
guard fails if a runtime class uses `bg-base` again or a theme colour
named `base` returns.

Contract-Neutral: Restores intended backgrounds for a Tailwind class that generated no CSS; no API, data or behaviour contract changes beyond the frontend-primitives note on the new bg-page utility.
2026-10-05 23:58:46 +01:00
rcourtman 8d77c0e277 Explain why the Container update risk switch is locked (#2541)
Patrol settings showed the Container update risk switch dimmed on
installs without ai_alerts and gave no reason, so a free install could
not tell a plan limit from a broken setting.

The card now renders the canonical Patrol availability presentation.
patrolAutonomyAvailability.ts keeps one precedence and the only action
labels, while Patrol mode and container update risk each supply their
lock state and copy. Links appear only when the session allows upgrade
prompts. The explanation is tied to the switch with aria-describedby,
because the shared Toggle does not render its locked/lockedMessage
props.

The second commit makes hidden commercial surfaces win over the runtime
lock, and the Patrol header and banners now pass the session's real
presentation policy instead of a constant. Demo and white-label sessions
on the community runtime previously saw "Pulse Pro runtime required" on
the card and "Install the Pulse Pro runtime" inside the header's mode
disclosure; they now get the neutral lock. Sessions that show commercial
surfaces keep the runtime prompt and download link unchanged.
2026-10-05 23:57:17 +01:00
pulse-triage[bot] e0647e860f Release v6.5.0 (#2536)
Release preparation for `v6.5.0`. After this merges, the release
workflow runs at this exact head
`38c981a397c7a37f469b619200b5035bfc60b328`.
2026-10-05 22:45:32 +00:00
rcourtman 4742159bb3 Let agents without a command channel reduce their authority
Deploy enrollment, Proxmox auto-registration and pre-v6.1.1 installs
leave an agent's token bound to the node's hostname and to no agent ID
until the agent registers a command channel. An agent whose token lacks
agent:exec, or that never connects that channel, never gets an ID on its
token, and the safe-profile migration's authority reduction, which
matches that ID, refuses it for as long as the agent runs.

A report the monitor resolved under such a token now records the
server-resolved host ID on it when the report presented that same ID,
which in the same run is the ID the agent's command registration
presents. The shared binding policy must first-bind, migrate or backfill
that ID for the reported hostname, the token must run command sessions
in the report's organization, and the binding version makes the ID
immutable. Scopes, runtime role and command policy are untouched, so a
recorded identity grants nothing; command registration still needs
agent:exec and the same ID. When the presented and resolved IDs differ,
nothing is recorded and the command channel decides as before. As with a
command registration, the first identity recorded is final, so an agent
later restarted under a different --agent-id is refused under that ID on
the same token.
2026-10-05 23:38:18 +01:00
rcourtman 25d299346d Keep deployed agents' command channel across restarts (#2542)
## Problem

Agents installed through the one-step cluster deploy lose their command
channel for good after their first restart.

`POST /api/agents/agent/enroll` bound the runtime token to an invented
`agent-<hostname>` and returned that as the agent's ID. The agent used
it until Pulse acknowledged its first report with the host's real,
machine-derived ID, which the agent persists. On the next start the
agent registered under that real ID. The token was by then a version-2
binding to `agent-<hostname>`, so admission refused it with "Agent token
mismatch: token is not bound to the registering agent", every few
minutes, indefinitely.

Seen live on a v6.5.0-rc.1 server with a deployed Proxmox node: reports
flowed, but the node could run no commands or actions.

## Fix

- **Enrollment leaves identity to the agent.** The runtime token is
bound to the node's hostname and the current binding version, marked
`deploy_identity_binding=agent`, and the response no longer carries
`agentId`. The first command registration backfills the agent's own ID
through the existing hostname-bound backfill path, and the config gate
takes the same decision.
- **One-time repair for tokens already issued.** A token enrollment
bound to `agent-<hostname>` (it has `deploy_job_id` and no identity
marker) may move once to the registering agent's ID:
- The hostname must match exactly (case-insensitive), not by short-name
equivalence.
- The move persists `deploy_identity_binding=repaired` in the same save,
and a failed save restores the previous metadata.
  - It applies whatever the token's binding version.
- Any other identity or hostname is refused rather than handed to the
legacy hostname migration.
- A marked token never moves again, including after a hostname rebind
recreates the `agent-<hostname>` shape.

## Tests

-
**`TestHandleEnroll_RuntimeTokenBindsTheAgentsOwnIdentityOnFirstCommandRegistration`**
replays the field sequence: the identity enrollment handed out, then the
acknowledged identity after a restart. It fails on the old code at the
"registration after restart" step.
- **`TestDeployPlaceholderTokenMovesToTheRegisteringAgentOnce`** covers:
  - the repair on the exact hostname only;
  - the persisted marker after a reload;
  - the repair, rebind to host b, repair-again attack;
  - config-gate parity.
- **`TestVersionlessDeployPlaceholderCannotBeMovedTwice`** covers the
versionless variant of that attack.
- **`TestCurrentDeployTokenNeverMovesOnceBound`** checks that a token
issued by current enrollment never moves once bound.
- **Policy unit tests:**
`TestEvaluateRepairsDeployPlaceholderBindingOnce`,
`TestEvaluateVersionlessDeployPlaceholderOnlyMovesThroughTheRepair` and
`TestEvaluateCurrentDeployTokenBackfillsTheAgentIdentity`.
- **`TestContractDeployEnrollResponseCarriesNoAgentIdentity`** pins the
enroll response keys.
- **Mutation checks:** removing the marker check, loosening the hostname
match, not persisting the marker, or letting placeholders fall through
to the legacy branch each fails at least one of these tests.

## Not in this change

Both of these exist for every install path and are not made worse here:

- The first command registration binds whatever ID the agent presents.
If that differs from the identity Pulse later resolves for its reports
(a configured agent ID, or a host continuity fork), the next restart
still mismatches.
- Tokens that never connect the command channel keep an empty
`bound_agent_id`, so collector-authority reduction cannot match them.
Before this change they had a non-empty but wrong `agent-<hostname>`,
which failed the same way.
2026-10-05 23:33:44 +01:00
rcourtman ca381121df Keep agent-reported disks out of cluster member state (#2543)
An agent on a Proxmox cluster node reports SMART data for the node's
disks, and the registry stamps each disk with its node's Proxmox
identity so it stays discoverable in the Proxmox workspace. The
`/api/connections` member projection accepted any resource carrying that
identity, so each disk merged into its node's member row. Its health
became the node's state and its model name became a host alias.

A disk in warning (60C or more, wear, errors) made an actively reporting
node show Stale; a disk with unknown health made it show Pending. Since
6.4.5 merges members by freshness, the row flips whenever the agent
report and the Proxmox poll swap places, several times a minute on a
customer's three-node 6.4.5 cluster. Standalone nodes are unaffected
because only cluster nodes carry the cluster-member flag.

Only node resources now compose cluster members. The disk keeps its
ownership facet, risk and storage evidence.

`TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks` runs a
cluster node, its linked agent and a 63C NVMe disk through the real
registry. On current main it reproduces the customer's payload exactly
(aliases include `wd_black sn7100 4tb`, state `stale`); with the fix the
member stays active.

Refs #2062, which reports the same Pending/Stale symptom on a 3-node
cluster and was expected to be fixed by 54c3cdc061 in 6.4.5.
2026-10-05 23:14:31 +01:00
rcourtman 9c0b0262df Keep Actions target names whole on narrow cards (#2539)
## Problem

Each Actions card's second line shows the target name, its type and the
time. The name and type were sibling flex items that shrank together. So
on a phone the line read "analytics-ba… · Virtual ma…" and "backup-coor…
· App cont…", which hid which resource the action touches. That's what
the operator needs to see before opening the review. From 360 to 430px
every target name was cut; from 480px up everything fit.

## Change

In `src/pages/Actions.tsx`, the name and the type now sit in their own
inline flex row:

- **Name:** never shrinks and is capped at the row's width (`shrink-0
max-w-full truncate`).
- **Type:** wrapped together with its separator, takes whatever room is
left (`min-w-0 flex-1 truncate`).

The time keeps its place. A first attempt with a large shrink factor
still gave the name about 0.08px of the shortfall, which was enough to
draw an ellipsis at every pixel density. The two-level row hands the
name no shortfall at all.

## Verification

Mock-mode private stack on main 1dcf9418aa, three open demo actions,
before against after at 360, 390, 430, 480, 640, 768, 1024 and 1440px:

| Viewport | `analytics-batch-01` (main → after) | `backup-coordinator`
(main → after) |
|---|---|---|
| 360 | 61 → 107 of 107px | 67 → 112 of 112px |
| 390 | 77 → 107 | 84 → 112 |
| 430 | 98 → 107 | 107 → 112 |

- **Exact fit:** the name's box equals its text width at 1×, 2× and 3×
pixel ratio. The type with its separator gets the rest ("Virtual
machine" 21, 51 and 91px at 360, 390 and 430px; whole from 480px).
Nothing changes from 480px up.
- **Long names:** a name longer than the line, injected in the browser,
truncates inside it at 360 and 390px with the time fully visible.
- **Interactions:** the review opens from a card and closes with Escape
at 390 and 1024px, and the History tab works. No console errors.
- **Tests:** the new
`features/actions/__tests__/Actions.metaLine.test.tsx` pins the
structure and fails against main's source. 25 test files across actions,
pages, routing and the resource-actions API pass.

The browser receipt is
`frontend-modern/browser-verification/2294fd5a84fdc31a.json`.
2026-10-05 23:12:54 +01:00
rcourtman f7e61446bd Move three forms onto shared controls and draw the select arrow
The shared template audit listed Settings, Alerts and Discovery forms
still building their own controls. The Thresholds CPU averaging selects
and the Discovery availability proposal now compose FormSelect (the
proposal's name field takes the shared field classes beside them), and
an availability check's request body composes FormTextarea, which links
its help text and, with sm:min-h-24, finally gets the height its class
asked for on desktop.

FormSelect's default chrome set appearance-none and drew no arrow, so
every default select in Settings read as a text field. A
form-select-arrow utility draws the caret in the padding the class
already reserved, and hands the native arrow back in forced-colors mode.

Two registry entries were wrong: AlertEscalationSection has used
checkboxes instead of a select since its destinations became
per-escalation, and the inline detail row rule matched a test file;
test files are now excluded from it.
2026-10-05 23:01:46 +01:00