Make setup and administration safer and show sparse History honestly (#2362)

## What changed and why
This batch repairs existing setup, administration and History surfaces:
- PBS and agent guides keep tokens out of shell history and process
arguments, prefer read-only identities, and require verified HTTPS
downloads saved for inspection. Fleet/action, RBAC, audit and
organisation examples use private header files, supported schemas,
signed-in session-only mutations, custom-role operations and private
exports.
- Generated Proxmox setup separates credential reveal from copied
commands and uses private-file handoffs. Download/preflight, permission,
identity and cleanup boundaries remain intact.
- Shared tables regain first-touch activation without swallowing
embedded controls or keyboard disclosure. Sparse drawer History displays
real stored observations without inventing trends, labels current
fallbacks honestly, and explains empty windows.
- Demo chart series reuse graph-version-bound metric samplers rather
than rebuilding the whole estate for every series, preserving values and
isolation.
- Release-note validation accepts authored grouped notes while retaining
identity and safety checks; qualification binds the complete current
source rather than an incomplete filesystem list.

These are repairs to existing behaviour and unsafe or inaccurate
guidance, not new features or permissions. PBS source/browser
improvements do not establish complete installed resolution of the mixed
#1723 report.

## Included source
Complete unpublished `pulse` range
`e5082c5fb1be8f8882275338d8b9f088b6a41cc4..f047d7b5db78ef1e3ca0b84de4fe2f6c43a9311c`.
This extends the former PR #2362 head
`704f4eeb4ef4f29cb0341d847665516b315cc24a`, whose workflows have
completed, rather than restarting an actively checking proposal.

Reviewed candidates and their additive integrations:
- PBS guidance `a5c8b5d59799c7cf43af0f0fc7e796722c95fe7f`, integrated at
`6a47dad9ce60a3814ad08de0814dff84493b5453`.
- Touch-row candidate `88f52387af1fe6ebd6554acd49320a2e5927c0e8` and
lint correction `12c8a7aba39bd8457f541ca0b1cc045893aed5db`, integrated
at `36418af66beac94204276f7dc73de2c41e297fb1`.
- Agent guidance `d5c187613a68e6e891ae9a5b6dbfd6154cea1844`, integrated
at `9bbbc7a817274ab13b9c20b2119f46edd94d2109`; fleet/action guidance
`704f4eeb4ef4f29cb0341d847665516b315cc24a`.
- Sparse History candidate `b405f8d99b252bfc0e8773914fe07c96e2ab7f59`,
retaining runtime `ec372845541fd0a79800b9ccadd7f4972be5e836`, integrated
at `a5347278430342fd805e758897c00ce187579d66`.
- Release-note/qualification candidate
`90c80acb493eb4ff6a789b184a794b87b4a6d538`, integrated at
`22380a35460a255ac4d60e20549e959875ccbc21`.
- Private-bootstrap/demo sampler candidate
`cf12c37e6302883e66039078d6e504b1014b26cc`, integrated at
`0832be112f024f5a6dd695da42b134e9039c2840`.
- Administration guidance `0c3cd4fb3ad1a32cb7ddb00b0992a53991594b79`
(three own commits), integrated at
`7d5c6eb0045f94481ae3147476187e9ad2eeccda`.
- Additive corrections `0fd47f7ebf5da8588d62b9f81b336b5267e3430d` and
`5116c2a2d9e49e83e794d23cf99e40c240e51ad9` reconcile the PBS guard and
explicit TLS fixture floor with accepted safe guidance and current
release direction.
- Upstream merges `f5e305d4c9494830743a140bbb1f951694555c38` and
`f047d7b5db78ef1e3ca0b84de4fe2f6c43a9311c` retain published ancestry,
including upstream's independent shadow lifecycle rehearsal. No accepted
identity was rewritten.

## Validation and adverse evidence
Sufficient existing candidate evidence is reused, with fresh bounded
reconciliation checks:
- PBS `source-1bj5m4w1`: 28 checks; frontend documentation/build/budgets
`source-dz1jcq79`. Fleet/action `source-cb0mkbhb`: nine executable guide
tests; frontend `source-qynvs3_n`, 32 browser checks/four inspected
screenshots. Agent guidance has six executable fixture tests, including
private-file permissions and filename-only handoffs.
- Touch candidate: four WebKit/Chromium/Firefox phone/desktop cases,
embedded-control and keyboard assertions, 12 inspected screenshots.
Sparse History final `source-nha4kbvo`: formatting and 121 affected
tests; predecessor full type/lint/test/build/budget proof and four
browser passes/32 inspected screenshots. Parallel `source-_d931osk`
failed one existing window-test timeout; that result remains adverse,
not an overall pass.
- Bootstrap/demo `source-rlyqg2wg`: full affected
configapi/hostagent/mock/monitoring/installtests race suites and
affected API cases; `source-p2vo7bpw`: affected vet and actual
frontend/server/agent builds. `source-l681cvrh` records frontend
type-check and 21,612 tests; browser matrix covers PVE/PBS desktop and
emulated phones with eight inspected screenshots. Earlier full API
timeout and output-limit attempts remain unresolved, not passing
receipts.
- Release-note closure `source-ig3ted7j`: complete install suite and 942
Python cases/eight skips on its recorded source. Final fixture
correction `source-in4fqcsx` passes authored-note tests and seven
negative safety/identity subcases.
- Administration `source-_klnz0rf`: 55 documentation tests, 26 API
tests, authentication tests and documentation audits. Frontend
`source-lrr0gmox`: 50 documentation tests/build/budgets; 41 browser
checks/eight inspected images.

Fresh full-range provenance and whitespace pass. All 23 unpublished
non-merge commits pass the prescribed per-commit completion/browser
checks using their recorded Contract-Neutral reasons where applicable;
aggregate browser coverage binds all six changed frontend runtime paths.
All 62 shipped-doc mirrors and 211 public Markdown checks pass. A naive
aggregate shape check fails because it cannot honour those per-commit
reasons; the workflow-prescribed per-commit check passes. The repaired
PBS required/forbidden source assertions were separately checked against
the final text. An optional additional source-VM check was cancelled
while queued, before execution: no receipt or pass is claimed.

Former-head CI runs `36829661316` and `36829661309` failed the retired
credential-bearing PBS assertion; the accepted additive corrections
replace it. The former CodeQL finding concerned the local TLS fixture's
implicit protocol floor, now explicit. Former advisory benchmark
`ConcurrentReadWrite-4` remains failed: 906.4→1007.7 µs, +11.17%,
p=.011, n=10. Its former candidate and base have identical Go
sources/manifests, but this does not prove the cause or turn the
measurement green. The observation is retained for independent Delivery
assessment; fresh exact-head CI remains necessary for the expanded
batch.

Required exact-head checks and independent review govern landing. These
are synthetic/source proofs, not physical-device acceptance, native
installation/recovery, complete PBS reporter acceptance, deployed
activation, release qualification or availability in a release.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-01 09:43:59 +00:00
committed by GitHub
89 changed files with 5344 additions and 1940 deletions
+131 -71
View File
@@ -34,7 +34,7 @@ X-API-Token: <token>
Then make a read-only request (curl 7.76 or later):
```bash
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/state/summary
```
@@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled;
do not use `--insecure`. On an authentication-enabled instance, the protected
summary checks token access; `/api/health` is public and does not verify authentication.
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
403, while retaining the error response.
403, while retaining the error response. Keep `--disable` first: it ignores
local curl configuration that could otherwise enable credential-bearing trace output.
Do not paste tokens into command lines, URLs or issue reports. Keep the header
file private and outside shared repositories and diagnostics; do not use curl
@@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include:
- `settings:write`
- `agent:config:read`
- `agent:manage`
- `actions:plan`, `actions:approve`, `actions:execute`
- `audit:read`
Endpoints that require admin access are noted below.
@@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta
The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels.
CLI adapter:
Use the private header file prepared in [Authentication](#-authentication),
with `settings:read` on the token:
```bash
PULSE_API_TOKEN=your-token pulse fleet connections \
--api-url http://localhost:7655
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/connections
```
### Unified Action Planning
`POST /api/actions/plan`
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
The existing action API separates planning, approval and execution. Run these
steps separately, inspecting each response before moving on; do not paste the
whole sequence as an unattended recovery script. Use the private header file
from [Authentication](#-authentication), not a token in a command or environment
assignment. For remote access, replace the loopback origin with your Pulse HTTPS
URL and keep certificate verification enabled.
`POST /api/actions/{id}/decision`
Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action.
| Endpoint | Token scope and access |
| --- | --- |
| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. |
| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. |
| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. |
| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. |
| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. |
| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. |
`POST /api/actions/{id}/execute`
Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint.
The legacy `ai:execute` scope also permits planning, approval and execution,
but use the narrower action scopes when possible. Approval and execution tokens
must be bound to an authorised user; a scope alone does not bypass approval
policy, separation of duties or step-up requirements. Audit reads through a
browser session additionally require admin access. A `monitoring:read` token is
not an action-control or audit token.
1. **Inspect capabilities.** Choose the canonical resource `id` from
`GET /api/resources`, not a display name or a VM number. Replace `vm:42`
throughout these examples and URL-encode it in request paths (`vm%3A42` here).
An empty capabilities list means there is nothing to plan for that resource.
CLI adapter:
```bash
PULSE_API_TOKEN=your-token pulse actions capabilities \
--api-url http://localhost:7655 \
--resource-id vm:42
PULSE_API_TOKEN=your-token pulse actions plan \
--api-url http://localhost:7655 \
--request-id agent-run-123 \
--resource-id vm:42 \
--capability restart \
--param mode=graceful \
--reason "Recover after confirmed outage" \
--requested-by agent:oncall-helper
PULSE_API_TOKEN=your-token pulse actions decide \
--api-url http://localhost:7655 \
--action-id act_... \
--outcome approved \
--reason "Inside maintenance window"
PULSE_API_TOKEN=your-token pulse actions execute \
--api-url http://localhost:7655 \
--action-id act_... \
--reason "Execute approved recovery"
PULSE_API_TOKEN=your-token pulse actions audit \
--api-url http://localhost:7655 \
--resource-id vm:42 \
--limit 10
PULSE_API_TOKEN=your-token pulse actions events \
--api-url http://localhost:7655 \
--action-id act_...
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
```
Request:
```json
2. **Plan only.** Use an advertised capability and its actual parameter schema;
`restart` and `mode=graceful` are examples, not universal capabilities. Choose
a request ID for this specific intent. Pulse derives the actor from the
authenticated credential, not a caller-supplied `requestedBy` value.
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/plan <<'JSON'
{
"requestId": "agent-run-123",
"requestId": "manual-recovery-123",
"resourceId": "vm:42",
"capabilityName": "restart",
"params": { "mode": "graceful" },
"reason": "Recover after confirmed outage",
"requestedBy": "agent:oncall-helper"
"reason": "Recover after confirmed outage"
}
JSON
```
Inspect the returned approval policy, blast radius, expiry and preflight checks.
Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...`
below with those returned values. Planning is not proof that execution is safe
or currently available.
3. **Decide only if approval is required.** Use an authorised approver's private
header file. Approve only the reviewed plan; stop on a refusal or stale-plan
response rather than weakening the policy or creating another recovery action.
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON'
{
"outcome": "approved",
"reason": "Inside maintenance window",
"planHash": "sha256:..."
}
JSON
```
4. **Execute separately.** Only after reviewing a successful decision, or an
approval-free executable plan, use the executor's authorised header file:
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON'
{
"reason": "Execute approved recovery",
"planHash": "sha256:..."
}
JSON
```
If the response is lost or times out, inspect the existing action and target
before retrying: losing the connection does not prove that execution stopped.
An HTTP success is not a substitute for checking the terminal result and the
resource's observed state.
5. **Read the audit and events**, using a private header file with audit access:
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10'
```
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/actions/act_.../events
```
When audit logging is unavailable, use Pulse's action detail in the signed-in
UI to inspect the existing action; do not infer success or repeat execution
from an unavailable audit response.
Response:
```json
{
"actionId": "act_...",
"requestId": "agent-run-123",
"requestId": "manual-recovery-123",
"allowed": true,
"requiresApproval": true,
"approvalPolicy": "admin",
@@ -525,9 +580,11 @@ Request body:
### Setup Script (Public)
`GET /api/setup-script`
Returns the Proxmox/PBS setup script as a shell-script download. Accepts an
optional temporary setup token in the `setup_token` query for embedded
non-interactive bootstrap; otherwise the script prompts for the one-time setup
token at runtime. Canonical callers must send a supported `type` of `pve` or
optional legacy `setup_token` query for compatibility. Current downloads
contain no token: paste the separately revealed token only at the silent
terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600
regular file in a mode-0700 directory owned by the script's user. Never put a
token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or
`pbs` plus non-empty `host` and `pulse_url`; the route no longer generates
placeholder-host scripts for later repair or reconstructs Pulse identity from
the request origin. The route now shares the same canonical type boundary as
@@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the
setup/bootstrap path derive from the canonical Pulse endpoint, not request-local
host fallbacks, so setup-script and turnkey node-add flows stay on one
deterministic `pulse-<canonical-scope-slug>` identity per Pulse instance.
`setupToken` remains bootstrap transport data for `/api/setup-script` and
`/api/auto-register`, while `tokenHint` is the operator-facing display field
for quick-setup surfaces and must stay masked instead of exposing the full
one-time token in UI copy. Shared frontend consumers may validate
`setupToken`, but they should not retain or display it once the returned
bootstrap artifact and `tokenHint` are available; visible quick-setup previews
should use the non-secret `commandWithoutEnv` form while copy actions keep
using the token-bearing `commandWithEnv` artifact, and manual download flows
should use the token-bearing `downloadURL` artifact instead of rebuilding a
plain setup-script URL from non-secret preview state. Non-frontend bootstrap
consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that
same full artifact contract too, rejecting missing or mismatched
`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a
reduced setup-token-only response shape.
The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain
identical credential-free commands. They download the complete script before
running it and prompt silently in the root-or-sudo process. The token crosses
the installer boundary through a private file, not process arguments or an
exported secret. `downloadURL` equals the tokenless `url`, so a manual download
also needs the separately revealed token at runtime. `setupToken` is used for
`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings
reveals the token in a separate dialog: run the command first, then copy and
paste the token only at its prompt. The artifact is reused only for the same
host and options while its five-minute expiry is live, and discarded when the
setup modal closes.
Non-frontend consumers must validate the complete artifact, including the
canonical host, type, URLs, filename, masked hint and live expiry. Current
Unified Agents and the shell installer also accept the coherent older-server
artifact during upgrades, but never execute its command text. For new
Proxmox agent enrolment against a newer server, use its current installer;
already enrolled agents keep reporting normally.
### Auto-Register (Public)
`POST /api/auto-register`
@@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user.
```json
{ "id": "acme-corp", "displayName": "Acme Corporation" }
```
The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters.
The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example.
### Get Organization
`GET /api/orgs/{id}` (requires `settings:read`)
@@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org.
```json
{ "userId": "jane", "role": "editor" }
```
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
### Remove Member
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
@@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations.
"accessRole": "viewer"
}
```
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
### Delete Share
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)
+30 -12
View File
@@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
### API
Use a token with `audit:read`, bound to a user permitted to read audit logs,
and the licensed `audit_logging` capability. Prepare the private header file
in [API authentication](API.md#-authentication); never paste the token or a
session cookie into a command. For a one-off read, you can instead open the
API path in your signed-in Pulse browser.
The loopback URLs below apply on the Pulse host. For remote access, use your
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
keeping `--disable` first to ignore local trace/verbose defaults.
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
402 and 403. Share only the relevant redacted error, not the header file or
whole audit response: events can contain usernames, client addresses and paths.
```bash
# List recent events
curl http://localhost:7655/api/audit?limit=50 \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?limit=50'
# Filter by event type and date range
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
# Get audit summary
curl http://localhost:7655/api/audit/summary \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/summary
```
### Query Parameters
@@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
Export the audit log for external analysis or compliance archival:
```bash
curl http://localhost:7655/api/audit/export \
-H "Authorization: Bearer $TOKEN" \
-o audit-export.json
umask 077
export_dir="$(mktemp -d)" &&
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--output "$export_dir/audit-export.json" \
http://127.0.0.1:7655/api/audit/export &&
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
```
The export includes all events matching the current filter criteria.
This creates a new private directory and prints the file's location only on
success. It does not reuse filters selected in the UI. Treat the export as
sensitive data; keep it outside shared repositories and issue attachments.
---
@@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
```bash
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
```
Response:
+84 -44
View File
@@ -51,9 +51,30 @@ Each member has a role within an organization:
Organizations can share specific resources with other organizations:
- Share a VM, container, host, or storage resource with another org.
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
- The receiving org sees shared resources alongside their own, with a share badge.
- The receiving org must accept the share before it sees the resource alongside its own.
## Before Using the API
Create organizations and manage members and shares in the signed-in Pulse UI.
These changes require **session-based user authentication** and the relevant
organization role; API tokens are rejected with `403 session_required`, even
when they have `settings:write`. The UI handles the session and CSRF protection.
Do not extract a session cookie or CSRF token into a shell command.
The read-only curl examples use an org-bound token with `settings:read` and
the private header file from [API authentication](API.md#-authentication).
Keep the token out of command lines, URLs and reports. A token's organization
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
not grant it access to another organization.
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
keep certificate verification enabled. Replace the example organization ID
`production-datacenter` with your own. Run requests separately, and share only
the relevant redacted error, not whole member or infrastructure responses.
## Managing Organizations
@@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations:
**UI:** Settings → Organization → Create Organization
**API:**
```bash
curl -X POST http://localhost:7655/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
**API contract:** `POST /api/orgs`, session authentication only:
```json
{"id": "production-datacenter", "displayName": "Production Datacenter"}
```
The creator becomes the owner. `id` is a stable ID of 1–64 characters using
letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a
simple lowercase/hyphen ID such as the example. `displayName` is the name
shown in Pulse. `name` and `description` are not the creation fields.
### Switching Organizations
Use the **Org Switcher** dropdown in the header. When you switch:
@@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch:
**UI:** Settings → Organization → Access
**API:**
**Read-only API:**
```bash
# List members
curl http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN"
# Add a member
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id", "role": "editor"}'
# Update role
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/members
```
**Invite or update a member:** use the Access panel as an owner or admin.
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
```json
{"userId": "user-id", "role": "editor"}
```
For a new member, the response is `202` with a pending invitation; the user
must accept it in Pulse before gaining access. An existing member's role is
updated by posting their `userId` and new role to the same endpoint, not by
PATCHing a member URL:
```json
{"userId": "user-id", "role": "admin"}
```
Only the current owner can transfer ownership, and only to an existing member
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
member update. Default-organization members cannot be managed here.
### Sharing Resources
**UI:** Settings → Organization → Sharing
**API:**
```bash
# Create a share
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetOrgId": "other-org-id",
"resourceType": "host",
"resourceId": "resource-id",
"role": "viewer"
}'
Create the share as an owner or admin of the source organization. An owner or
admin of the target organization then accepts it in **Sharing → Incoming**.
Until acceptance, the share is pending and does not grant access.
# View incoming shares
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
```json
{
"targetOrgId": "other-org-id",
"resourceType": "vm",
"resourceId": "vm:101",
"accessRole": "viewer"
}
```
Use `accessRole`, not `role`, and the resource type and ID returned by Pulse,
not a display name. Supported types include `vm`, `system-container`, `agent`,
`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and
`container` are not supported. Changing a share's access role makes it pending
again, so the target must accept the new grant.
**Read-only incoming shares:**
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
```
## Monitoring Multiple Internal Estates
@@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
| `GET` | `/api/orgs` | List organizations the current user can access |
| `POST` | `/api/orgs` | Create a new organization |
| `GET` | `/api/orgs/{id}` | Get organization details |
| `PATCH` | `/api/orgs/{id}` | Update organization |
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
| `DELETE` | `/api/orgs/{id}` | Delete organization |
| `GET` | `/api/orgs/{id}/members` | List members |
| `POST` | `/api/orgs/{id}/members` | Add a member |
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
| `POST` | `/api/orgs/{id}/shares` | Create a share |
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
### Tenant Context
@@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active
2. Session cookie (browser)
3. Falls back to the `default` organization
Neither the active context nor a token scope overrides organization membership
or a token's organization binding.
## Storage
- The **default** org uses the root data directory (backward compatible).
@@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
### Shared resources not appearing
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
2. Confirm the share role grants sufficient access.
3. Check that the source org's resources are online.
+149 -70
View File
@@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways:
### 1. Direct PBS Connection (Recommended)
Connect directly to your PBS server for full monitoring capabilities:
Connect to the PBS API with a dedicated read-only token. An agent is not
required for these API-backed readings:
**Benefits:**
- ✅ Deduplication factor and storage efficiency stats
@@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
- ❌ Can be slow for encrypted PBS storage
- ❌ Limited metadata per backup
**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
**Recommendation:** Start with a direct API connection when you need PBS
datastore, job or server status beyond the PVE passthrough. Add a host agent
only for extra local telemetry such as SMART and temperatures; see
[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices).
---
## Setting Up Direct PBS Connection
### Method 1: Unified Agent Install (Recommended for Bare Metal)
### Method 1: API-Only Connection (Recommended)
Install the unified agent directly on your PBS server for automatic setup:
Use a dedicated PBS monitoring user and token, not a root password. On a new
setup, run these commands in an administrator shell **on the PBS server**:
```bash
# Run on your PBS server
curl -fsSL http://<pulse-ip>:7655/install.sh | \
sudo bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox --proxmox-type pbs
```
The agent will:
1. Detect it's running on a PBS server
2. Create a `pulse-monitor@pbs` user with read-only access
3. Generate an API token
4. Register the PBS node with Pulse automatically
### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐
Use this when you can run a command on the PBS host but do not want to install the agent.
From Pulse's Settings page:
1. Go to **Settings → Infrastructure**.
2. Click **Add infrastructure**.
3. Choose **Proxmox Backup Server**.
4. Use the API-only setup path and enter your PBS server's URL.
5. Click copy to get the setup command.
6. Run the command on your PBS server.
Example (what the UI generates):
```bash
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
```
Pulse generates that full command for you from **Settings → Infrastructure**, including
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
generates a scoped API token, and registers the server with Pulse.
> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality.
> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding.
### Method 3: Manual Token Creation
If you prefer manual setup:
```bash
# SSH into your PBS server
# 1. Create a dedicated monitoring user
proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring"
# 2. Grant read-only access (Audit role)
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
# 3. Generate an API token (save the output!)
proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'
```
Copy the token value and enter it in Pulse:
- **Token ID:** `pulse-monitor@pbs!pulse-token`
- **Token Value:** The UUID shown after running the command
PBS tokens have their own permissions, limited by their owning user's
permissions. Grant `Audit` to **both the user and the token**. If either already
exists, inspect and reuse it instead of deleting it or rerunning token creation.
Do not disable privilege separation or grant `Admin` to work around a missing
permission.
The token secret is shown once. Save it privately and enter it only in Pulse's
**Token Value** field; do not paste it into a shell command, URL, screenshot or
issue report. Keep any terminal recording containing that output private.
In Pulse:
1. Open **Settings → Infrastructure → Add infrastructure** and choose
**Proxmox Backup Server**.
2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`.
3. Select **API Token** and **Manual Token Setup**.
4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in
**Token Value**.
5. Keep certificate verification enabled. For a self-signed certificate, use
an independently verified **SSL Fingerprint**; see the TLS guidance below.
6. Test and save the connection. Check that the expected datastores and backups
are visible, not just that the connection test succeeds.
### Method 2: Optional Host Agent
Use this only when you also need host-local telemetry. The Linux agent normally
runs as root; it is not required to fix API authentication or missing backup
permissions. Review [Agent Security](AGENT_SECURITY.md) first.
Create a separate Pulse token in **API Access**, using the **Agent host** preset
for reporting, configuration reads and agent management. This is a **Pulse**
token, not the **PBS** token used above. Do not enable command execution just
for monitoring.
On the PBS host, enter an administrator root shell before preparing the private
file below. In the editor, save only the Pulse agent token, with no header or
quotes; never put the secret in a command argument:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/pbs-agent-token"
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
vi "$HOME/.config/pulse/pbs-agent-token"
```
Download the agent installer from **your Pulse server's HTTPS address**, then
inspect the saved script before running it. Replace the example Pulse URL in
both commands. Stop if the download fails; run the next command only after a
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
the Pulse server, not the agent.
```bash
curl --fail --silent --show-error \
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
https://pulse.example.com/install.sh
```
```bash
bash "$HOME/.config/pulse/pbs-agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/pbs-agent-token" \
--enable-proxmox --proxmox-type pbs --enable-docker=false
```
Do not bypass certificate checks to fetch or run the installer. Use a trusted
Pulse certificate or a CA file you verified separately. See
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
After installation, check a fresh agent report and the host's
`pulse-agent --version`; installation or hardware capacity alone does not prove
that every CPU, memory or History reading is available. Keep the token file
private and remove the bootstrap copy when no longer needed; do not remove the
installed agent's runtime credential.
---
@@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil
### "Connection Failed" Error
1. **Check URL:** Ensure the PBS URL is correct (default port is 8007)
- Format: `https://pbs.example.com:8007`
Check from the Pulse host or container's network, if possible. A request from
another machine does not prove that Pulse can reach PBS.
2. **Verify token:** Test authentication:
```bash
curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \
https://your-pbs:8007/api2/json/version
```
- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing
and the firewall before changing credentials.
- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse
runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value
through the PBS console or another already-trusted administrative channel.
Do not accept a fingerprint solely from the failed connection. For example,
on the PBS server you can inspect its public certificate (not its private key):
3. **Network access:** Ensure Pulse can reach PBS on port 8007
```bash
openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256
```
4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings
- **Authentication/permissions:** test a datastore request, not just `/version`.
Prepare a private header file on the machine running curl:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/pbs-header"
chmod 600 "$HOME/.config/pulse/pbs-header"
vi "$HOME/.config/pulse/pbs-header"
```
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
for the PBS token being tested:
```text
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
```
Then use curl 7.76 or later, with your PBS hostname:
```bash
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
--header "@$HOME/.config/pulse/pbs-header" \
https://pbs.example.com:8007/api2/json/admin/datastore
```
For curl with a private CA or self-signed certificate, add
`--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained
and verified through a trusted channel. Its hostname must still match. Do not
use `--insecure` or `-k`.
`401` means authentication was rejected; `403` means the requested access was
refused. Both return a non-zero curl exit while retaining the error body.
Inspect the existing token and both `Audit` grants before replacing anything.
A `200` response listing the expected datastore establishes access to that
list, not successful collection of every backup, job or History graph. An
empty list is not proof that all permissions are correct.
Keep the header file outside shared repositories and diagnostics. Do not share
verbose/trace curl output, full infrastructure responses, token values or
private keys. If help is needed, provide only the HTTP status, relevant redacted
error and which expected reading is missing. Do not clear History or recreate a
working connection to make missing data look resolved.
### Slow Backup Loading
@@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE:
### Duplicate Backups
If you see the same backup twice:
- This shouldn't happen—Pulse deduplicates by VMID and timestamp
- If it does occur, the direct PBS version takes priority
- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')`
Check the source, datastore, namespace, guest type/ID and backup time on each
entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone
are not proof of a duplicate. Keep both records while checking their origin.
If the same backup remains listed twice, report those redacted distinctions and
whether each entry came from direct PBS or PVE passthrough. Do not delete backups
or change retention to hide a display problem.
---
@@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator:
- **"PBS"** badge alone = Direct PBS connection (full data)
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)
Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities.
When the same backup is reconciled across both sources, Pulse prefers the
direct PBS observation. Check the actual source and expected readings after
adding the connection; the presence of a badge alone is not collection proof.
---
+61 -30
View File
@@ -46,42 +46,65 @@ user merely because no local administrator is configured.
## Managing Roles
### Before Using the API
Prefer **Settings → Security → Access Control** for one-off administration.
The API examples require the licensed `rbac` capability and permission to
administer users. With RBAC enabled, role and user administration requires a
full-access (`*`) API token bound to an authorised administrator; a monitoring
token is not enough. Do not widen an agent's token for this job.
If you already use an administration token, prepare its private header file
as described in [API authentication](API.md#-authentication). The examples read
`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into
a command. Keep this file outside shared repositories and diagnostics.
The loopback URLs work on the Pulse host. For remote use, substitute your
Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or
later; `--disable` must remain first to ignore local trace/verbose defaults,
and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each
change separately and check its response before continuing.
### Creating a Role
**UI:** Settings → Security → Access Control → Create Role
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
**API:**
```bash
curl -X POST http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "operator",
"name": "Operator",
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST \
--data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON'
{
"id": "alert-manager",
"name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}
]
}'
}
JSON
```
### Listing Roles
```bash
curl http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/roles
```
### Updating a Role
```bash
curl -X PUT http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Operator",
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON'
{
"name": "Alert Manager",
"description": "Updated description",
"permissions": [
{"action": "read", "resource": "alerts"},
@@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}
]
}'
}
JSON
```
### Deleting a Role
```bash
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
```
---
@@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
### Listing Users and Their Roles
```bash
curl http://localhost:7655/api/admin/users \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/users
```
SSO users are displayed using the latest configured username claim and email
@@ -118,20 +142,27 @@ principal used for authorization.
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
Use the stable `username` returned by the user list, not the display name or
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
SSO principal). `jane` below is an example local username. Create the custom
role before assigning it; do not run the deletion example first.
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": ["operator", "viewer"]}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
{"roleIds": ["alert-manager", "viewer"]}
JSON
```
To remove all custom roles from a user, send an empty list:
To clear a user's entire role assignment, including built-in roles, send an empty list:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": []}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
{"roleIds": []}
JSON
```
Note: Users cannot modify their own role assignments (self-escalation prevention).
@@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
### Removing User Access
```bash
curl -X DELETE http://localhost:7655/api/admin/users/jane \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
```
This removes the Pulse identity and all role assignments and revokes its active
+31 -8
View File
@@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the
## Recommended: Pulse Agent (Proxmox)
The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed.
The unified agent runs on each Proxmox host and reports temperatures locally
with no SSH keys needed. First complete the
[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas)
on that host: protect the Pulse agent token, download the installer from your
Pulse server over verified HTTPS, and inspect it. Then use the same Pulse
address and add the Proxmox collector flag:
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox
```
The Pulse agent token is not a Proxmox API token. Keep the secret out of
command arguments and leave command execution disabled for monitoring.
Notes:
- Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`).
- Temperatures appear automatically once the agent reports.
@@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
## Legacy Cleanup (If Upgrading)
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
If you still have the old sensor proxy installed from prior releases, remove it
from each **Proxmox host** (not the Pulse container) with the supported cleanup
helper. In that host's administrative shell, download it to a private directory:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --local-only
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh
```
Stop if the download fails and inspect the saved script before running it.
Do not pipe a web response into a privileged shell:
```bash
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
--uninstall --purge --local-only
```
`--local-only` avoids cluster SSH entirely; run the command once on every
@@ -122,8 +145,8 @@ remote portion fail after local cleanup completes.
If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
--uninstall --purge --remove-proxmox-access --local-only
```
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.
+147 -62
View File
@@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers.
## Quick Start
Generate an installation command in the UI:
Choose the host profile and create a monitoring token in the UI:
**Settings → Infrastructure → Install on a host**
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
The generated command is not tied to a single machine. For a Proxmox VE
cluster, one API connection already provides cluster-wide inventory; the agent
is per host, so run the same generated command on each cluster node where you
want agent-provided telemetry (temperatures, SMART, host identity). Each agent
registers itself and attaches to its own cluster member.
Keep the token out of shell commands, history, URLs and screenshots. Use the
private-file installation steps below; the command arguments contain only the
file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring
does not require command execution: leave that option off unless you intend
to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root
host agent instead of an API connection or the opt-in Linux safe profile.
The same generated command is also the supported v5-to-v6 agent upgrade path.
For a Proxmox VE cluster, one API connection already provides cluster-wide
inventory; the agent is per host. Repeat the private-file installation on each
cluster node where you want agent-provided telemetry (temperatures, SMART, host
identity), using the intended profile. Each agent registers itself and attaches
to its own cluster member.
The same installer is also the supported v5-to-v6 agent upgrade path.
Run it on the host that already has the v5 `pulse-agent` service to replace the
binary and service configuration in place; do not uninstall the old service
first unless you are intentionally removing that host from Pulse.
@@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL
so replacing the Pulse host does not require an agent migration.
After importing the configuration on a Pulse server with a different address,
retarget each existing standard Linux agent from that agent machine:
retarget each existing standard Linux agent from that agent machine. Download
and inspect the installer from the **new** Pulse address using the HTTPS
preparation below; retargeting reuses the saved credential, so do not create
another token just for this operation:
```bash
curl -fsSL https://pulse.example.com:7655/install.sh | \
sudo bash -s -- --retarget --url https://pulse.example.com:7655
bash "$HOME/.config/pulse/agent-install.sh" \
--retarget --url https://pulse.example.com
```
The retarget operation recovers the existing token, agent ID, enabled
collectors, and other service options. It does not carry the old endpoint's
TLS bypass, custom CA, or certificate fingerprint to the new address. Supply
`--cacert`, `--server-fingerprint`, or (only on a trusted network)
`--insecure` explicitly when the new endpoint requires it. The script must
come from the new server so it supports the retarget operation. A newly
generated full installation command from **Settings → Infrastructure → Install
on a host** remains the fallback.
`--cacert` or an independently verified `--server-fingerprint` explicitly
when the new agent endpoint requires it. The installer download itself must
use a trusted certificate or a separately verified CA file; an agent
fingerprint option does not verify that earlier download. The script must
come from the new server so it supports retargeting. If retargeting is not
supported on that host, use the private-file installation below with the
intended profile, rather than uninstalling the existing service first.
On Windows, run the full generated PowerShell installation command from the
new Pulse server as Administrator, including the desired collector options.
On Windows, use the PowerShell private-file installation below from the new
Pulse server as Administrator, including the desired collector options.
Do not expect the configuration import itself to make agent-only machines
appear at the new address.
@@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with
This is the agent installer served by your Pulse server. It is separate from the
top-level GitHub `install.sh`, which installs or updates the Pulse server itself.
### Linux (systemd)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
### Private-file installation: Linux, macOS and NAS
### macOS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
Use an administrative shell **on the host being monitored**, not the Pulse
server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS
use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API
connection is usually enough for TrueNAS inventory and usage; an agent is
optional for host-local data.
1. In that shell, protect the file before opening the editor. Save only the
Pulse agent token, with no quotes or header. Use your own private directory
and regular files, not shared paths or symlinks:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/agent-token"
chmod 600 "$HOME/.config/pulse/agent-token"
vi "$HOME/.config/pulse/agent-token"
```
2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address
in both the download and installation commands. Download to a file:
```bash
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
--output "$HOME/.config/pulse/agent-install.sh" \
https://pulse.example.com/install.sh
```
**Stop if the download fails.** Inspect the saved script before executing
it. Do not bypass certificate verification or pipe an unchecked response
into a privileged shell. For a private CA, add curl's `--cacert` with the
separately verified CA file, and supply the installer's `--cacert` option
for the agent connection as well. Do not substitute GitHub's top-level
`install.sh`: that installs the Pulse server, not the agent.
3. Install with the private token file, adding a profile from
[Installation Options](#installation-options) when needed:
```bash
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
Keep the bootstrap token file private and remove that copy when no longer
needed; do not delete the installed agent's runtime credential. Check a fresh
report in Pulse and `pulse-agent --version` on the host. A started service or
hardware capacity alone does not prove that every requested metric is present.
### Windows (PowerShell, run as Administrator)
Use a **new** setup directory under your own Windows profile; if it already
exists, choose another name. The following preparation grants access only to
your account and SYSTEM. Stop if directory creation or the ACL command fails:
```powershell
irm http://<pulse-ip>:7655/install.ps1 | iex
$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup'
New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F'
if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' }
$tokenFile = Join-Path $setupDir 'agent-token.txt'
New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null
Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait
```
With environment variables:
In Notepad, save only the Pulse agent token, with no quotes or header. Never
paste it into a PowerShell command or assign a literal secret to an environment
variable. Replace the example HTTPS address in both commands below. Download
and inspect the script first; do not use `Invoke-Expression` on a web response:
```powershell
$env:PULSE_URL="http://<pulse-ip>:7655"
$env:PULSE_TOKEN="<api-token>"
irm http://<pulse-ip>:7655/install.ps1 | iex
$installerFile = Join-Path $setupDir 'install.ps1'
Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop
```
### Synology NAS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
Only after a successful download and inspection, run the saved script. Clear
an old `PULSE_TOKEN` environment value so it cannot override the file:
```powershell
Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue
& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile
```
### TrueNAS SCALE/CORE
TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE).
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
Keep the setup directory private and remove the bootstrap copy when no longer
needed, not the installed service's token. Verify a fresh report and the
installed agent version; the remaining platform trust trade-offs are in
[Agent Security](AGENT_SECURITY.md).
## Features
@@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing.
## Installation Options
These commands use the protected token file and successfully downloaded,
inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas).
Use the same HTTPS address in preparation and installation. The flags below
choose collectors; they do not grant command execution.
### Simple Install (host + Docker auto-detect)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token>
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
### Proxmox VE Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pve
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve
```
### Proxmox Backup Server Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pbs
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs
```
### Force Enable Docker (if auto-detection fails)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-docker
```
### Disable Docker (even if detected)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-docker=false
```
### Host + Kubernetes Monitoring
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes
```
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker
```
### Exclude Specific Disks from Monitoring
@@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop.
To disable auto-updates:
```bash
# During installation
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
# After private-file preparation and successful installer inspection
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --disable-auto-update
# Or set environment variable
PULSE_DISABLE_AUTO_UPDATE=true
@@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys
## Uninstall
Download and inspect the agent installer using the HTTPS preparation above
before running this on the agent host. Uninstallation needs no new token:
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | bash -s -- --uninstall
bash "$HOME/.config/pulse/agent-install.sh" --uninstall
```
This removes:
@@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged
and installed copy must fit at once.
If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a
directory on a data volume and re-run the installer:
directory on a data volume and re-run the already downloaded and inspected
agent installer from the private-file preparation above:
```bash
TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token <token>
TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
(`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root.
+11 -9
View File
@@ -242,16 +242,16 @@ user language should update the control plane.
## Current State
1. v6 is the current active release profile.
2. `v6-product-lane-expansion` is the current active engineering target.
Pulse v6 GA and the initial 6.0.x patch line have shipped; active
development and stable release preparation now run on `main`.
2. `v6-release-reliability` is the current active engineering target.
Pulse v6 has shipped. Active development runs on `main`; release preparation
uses the exact source line resolved by `control_plane.json`.
3. `v6-ga-promotion` is complete. Its release records remain historical
evidence and must not keep pre-GA branch, checkout, or readiness posture
active in current lane state.
4. Candidate lanes and coverage gaps now route post-GA product expansion.
Release-blocking correctness work may still override that default queue
when a shipped customer contract can drift across billing, entitlements,
runtime behavior, or public copy.
4. Reported defects, dependable delivery and release qualification take
priority over new product expansion. Candidate lanes and coverage gaps
support that work; billing, entitlements, runtime behavior and public copy
remain customer contracts that must not drift.
5. `v6-rc-stabilization` is completed after the shipped RCs established the
current monitored-first floor and the active objective moved to stable
promotion.
@@ -259,8 +259,10 @@ user language should update the control plane.
7. The existing v6 control surfaces are still live, but they now sit underneath
an evergreen Pulse control plane rather than pretending to be the whole
long-term system.
8. Both prerelease and stable v6 promotions resolve to `main` via
`control_plane.json`.
8. The v6 profile defaults to `main`; explicit version overrides in
`control_plane.json` bind existing releases to `release/v6.4` and
`release/v6.5`. A new checkpoint's mapping is verified during governed
preparation, not inferred from the profile default or this page.
9. Legacy maintenance releases that still feed governed automation outside the
active v6 line must also resolve through `control_plane.json`.
Right now the remaining `5.1.x` stable maintenance line resolves to `main`
@@ -1021,9 +1021,20 @@ Companion drill:
`.github/scripts/check-demo-reachability.sh`
`scripts/trigger-stable-patch.sh`
- Automated proof:
`go test ./scripts/installtests -run 'TestStablePatchReleaseNotes' -count=1`
`cd scripts/release_control && python3 -m unittest render_release_body_test`
`cd scripts/release_control && python3 -m unittest secure_runtime_rootful_attestation_v1_test release_note_visuals_test documentation_currentness_test`
- The rootful source-closure test must cover the current filesystem probe and
shared filesystem evidence packages as well as the compiled harness. Its
complete missing-package list is a failed boundary check, not qualification.
`cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test`
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
- Manual scenario:
- Compare the exact selected release notes with the install-metadata verdict.
Authored grouped notes must retain version identity, unsigned-Windows and
publisher warning, mobile compatibility and the exact stable rollback. A
wording mismatch is not a product-runtime diagnosis. Do not mutate a shipped
release or skip functional checks to obtain a passing watchdog.
1. Push the exact candidate commit to the governed stable branch.
2. For a no-public-release rehearsal, dispatch
`./scripts/trigger-stable-patch.sh --dry-run <version>`.
@@ -3452,6 +3452,11 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Credential-safe Proxmox bootstrap (1 October 2026)
Proxmox initial bootstrap keeps credentials out of copied shell source. The current PVE/PBS agent command fetches a complete installer, runs its credential-free preflight, then privately prompts inside the root/sudo Bash child. Its 0700 directory and 0600 token file are owned by that child, passed only by path and removed on exit or handled signals. The UI reveals credentials separately, clears cached setup material on close and discards late issuance after close/reset. No exec scope is added. Existing fleet reporting is unchanged; newly enrolling an older agent with a newer server requires the current installer. New agents and the root installer still validate coherent old-server artifacts during rolling upgrades, but never execute their command strings.
### Update progress stream delivery
Server update progress delivery is owned by internal/updates and its API
@@ -4579,6 +4579,11 @@ auto-register mutation boundary.
## Current State
### Credential-safe Proxmox bootstrap (1 October 2026)
The current Proxmox setup artifact supersedes the former token-bearing command/download contract: `command`, `commandWithEnv` and `commandWithoutEnv` are identical credential-free, single-line transports. `downloadURL` equals the tokenless `url`; downloads embed no setup token. `setupToken` (or agent-install `token`) is returned separately through the authenticated issuance response for an explicit reveal, never interpolated by the consumer. Settings uses the existing token-reveal dialog and tells users to run the command before pasting at its silent terminal prompt. Setup cache remains bound to endpoint/mode/live five-minute expiry and is discarded on modal close. The rendered scripts accept a bounded private `PULSE_SETUP_TOKEN_FILE` before mutation, unexport the secret, and put registration credentials in stdin, not process arguments. Legacy explicit query-token downloads remain accepted for compatibility, but no current artifact generates those URLs. Failed attempted registration returns nonzero and never echoes the response body. Runtime and root-installer consumers validate either the complete new artifact or the coherent old-server artifact; mixed transports fail closed.
### Update progress stream delivery
`GET /api/updates/stream` answers `text/event-stream` with
@@ -1925,9 +1925,23 @@ artifact-selection behaviour.
duplicate appended `Installation` / `Promotion Metadata` sections verbatim.
From the release after `v6.4.0-rc.1` onward, that renderer also owns a
customer-facing communication contract. Public notes lead with one short
outcome paragraph, use a scannable `What's improved` section with no more
than six concrete items, keep fixes symptom-led, and reserve `Before you
outcome paragraph, use scannable symptom-led changes, and reserve `Before you
upgrade` or `Known issues` for information users must act on or understand.
From v6.4.6 onward, the authoring tools and renderer accept a short plain-text
`Highlights` list and grouped customer sections (alerts, storage, PBS, NAS,
updates, Pro, service health, security or other improvements). A narrow patch
may retain `What's improved`. Groups must be non-empty and not duplicate the
same change, bullets remain bounded, and internal status headings stay out.
The install-metadata checks bind the current version in the notes title and
retain Windows signing/warning, companion compatibility and exact stable
rollback disclosures. Stable maturity and promotion lineage remain bound by
the changelog and release metadata, not a mandatory public boilerplate
sentence. Historical published notes are not edited or re-rendered to fit a
later authoring template.
The rootful qualification source manifest retains the complete compiled
install-test package, including the release-notes helper and its exact
authored-copy fixture. Accepting grouped notes must not silently narrow
that attested source boundary.
Qualification counts, readiness assertions, release gates, workflow
narration, artifact identity, and promotion metadata stay in governed
workflow summaries and evidence records rather than the public changelog.
@@ -2556,6 +2570,11 @@ artifact-selection behaviour.
## Current State
### Credential-safe Proxmox bootstrap (1 October 2026)
Current PVE auto-registration metadata accepts the credential-free setup artifact: all command aliases use a private-file handoff and `downloadURL` equals the tokenless script URL. This replaces earlier requirements to embed setup tokens in commands/URLs. The older coherent server artifact is accepted read-only during upgrades, never executed. Host, type, canonical filename/URL, masked hint and live expiry remain required. Root-installer JSON parsing and registration pass secrets through descriptor/stdin input rather than Python/curl argv, and the setup response is no longer persisted as a plaintext /tmp diagnostic. No install source, API scope, trust exception, release selector or success condition is widened.
### Update progress stream delivery
`GET /api/updates/stream` is the in-app updater's progress feed and must never
@@ -6201,3 +6220,13 @@ rewriting the author's instructions or selecting another rollback version.
distant-scope rejection, and valid generated and inline ownership conditions.
This changes publication validation, not installer behaviour, immutable release
contents or proof of an installed upgrade/rollback.
## Rootful qualification source closure (1 October 2026)
The rootful source manifest includes `internal/filesystemprobe` and
`pkg/agents/filesystem`: both are compiled dependencies of the current collector
and qualification harness. Omitting them would leave the attested packet
unbound to filesystem-observation code that it actually executes. The closure
test enumerates all repository-local dependencies of the install-test binary,
collector and helper and reports every missing package together. No qualification
gate, source exclusion or production permission is relaxed by this correction.
@@ -336,10 +336,23 @@ These proofs establish presentation/recovery, not installed collection or delive
Shared workload, node, Docker-host, and resource-drawer history presentation
keeps current readings separate from stored samples. A current metric may
populate the legend while history is still being collected, but it must never
be expanded into synthetic timestamps or chart geometry. An empty stored
series renders the shared collecting-history state; zero remains a valid
populate the legend only with the explicit `current` label, never as a stored
reading, timestamp or chart geometry. A successful empty read says `No stored
history in this range`; it does not imply that collection is running or that
changing provider configuration will produce a sample. Zero remains a valid
reported reading, while an absent metric remains unavailable.
A lone stored series renders one point at its actual timestamp and value,
including measured zero, never an invented line. A group with one observation
time exposes that date visibly with `Single observation. No trend yet.` and
retains the existing accessible observation description. Multiple series with
separate single observations remain separate points, inspectable at their
actual times through the existing native control. Failed same-source refreshes
retain these points; source/range replacement clears them along with paths and
captions. `GuestDrawerHistory.sparse.test.tsx` checks geometry, units, provenance,
invalid data, refresh and replacement. `browser-tests/history-window.cjs` also
checks sparse/empty/current states through the production PBS drawer and direct
shared renderer. This is presentation proof, not provider collection or #1723
installed acceptance.
Shared history-chart gridlines must carry numeric labels derived from the
plotted scale rather than semantic `Avg` / `Max` placeholders. Byte and
byte-rate axes include their human-readable unit at each gridline, and the
@@ -401,9 +414,20 @@ priority.
Expandable platform summary rows use
`getPlatformResourceDetailRowInteractionProps` (or
`createPlatformResourceDetailState`, which owns the same state contract) for
whole-row pointer activation, Enter/Space keyboard activation, focus treatment,
`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls.
`createPlatformResourceDetailState` for their expansion state) for whole-row
pointer activation and exclusion of embedded links and controls. The nested
native disclosure button owns keyboard activation, focus and
`aria-expanded` / `aria-controls`; static table rows are not duplicate controls.
The shared `TableRow` installs a non-activating native click target only while
an `onClick` action exists. WebKit touch must be able to synthesize that row's
click, but actions remain document-delegated so embedded controls can stop
propagation before the row action runs. Do not move the action into the native
compatibility listener or add touch-end activation that opens rows during
scrolling. Explicit caller-owned native listeners remain caller-owned.
`Table.test.tsx` covers native binding, removal, single activation, child
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
The production PBS table/drawer browser fixture exercises first-tap disclosure
and History in WebKit phone emulation, not installed collection or devices.
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
is visually removed on phone layouts where the complete row is the touch target;
provider tables must not add a second mobile chevron. When row activation
@@ -697,7 +721,7 @@ the synthetic estate continues updating, plus the alert timeline and note form.
The Backups surface passes its complete deduplicated route inventory to the PBS table. Only PBS servers render as rows; other resources supply correlation inputs. Preserve PBS drawer identity and use the correlated canonical history target. Missing disk utilisation does not gate CPU/memory history.
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no stored host series and renders the no-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines.
@@ -876,11 +876,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
the migration boundary.
11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx`
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
A current metric may remain visible in a chart legend while history is
accumulating, but it must never be expanded into synthetic timestamps or a
flat line. Fewer than two stored points remains `Collecting history` and
only metrics-store samples may contribute chart geometry, bounds, or hover
inspection. Current-value legends must preserve the same availability
A current metric may remain visible in a chart legend only when labelled
`current`; it must never be expanded into synthetic timestamps or a flat
line. One stored sample is a visible point at its actual time, with a dated
single-observation caption rather than a collecting claim or invented trend.
Successful empty reads say `No stored history in this range`. Only
metrics-store samples may contribute chart geometry, bounds, or hover
inspection, including valid measured zero. This presentation adds no fetch,
point expansion, polling or retention budget. Verify sparse and mixed-series
geometry and failed-refresh/source-change behaviour in
`GuestDrawerHistory.sparse.test.tsx`, with rendered phone/desktop coverage in
`browser-tests/history-window.cjs`. Current-value legends preserve the same availability
semantics as workload rows: the poller's negative unknown-disk sentinel is
unavailable and renders `-`, never a fabricated negative percentage.
History cards must let the plot area stretch to the card height instead of
@@ -5241,6 +5241,7 @@
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"frontend-modern/src/components/Toast/Toast.tsx",
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
@@ -5947,6 +5948,24 @@
"frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts",
"frontend-modern/src/pages/__tests__/Docs.test.ts"
]
},
{
"id": "drawer-metric-history",
"label": "shared drawer stored/current history proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx"
]
}
],
"match_files": null
@@ -2640,6 +2640,11 @@ vdev layout is reported` in
## Current State
### Credential-safe Proxmox bootstrap (1 October 2026)
The shared Proxmox setup boundary now receives its bootstrap credential from private terminal input via a bounded 0700/0600 file, before native setup mutation, rather than from copied shell literals or newly generated token-bearing download URLs. Existing registration grant binding, PVE/PBS monitoring scopes, and explicit non-rotating Audit/Repair remain unchanged. A private-file validation failure stops setup, and failed attempted registration exits nonzero with fixed guidance rather than echoed credential-bearing responses. This supersedes historical shared-boundary requirements for token-bearing commands and manual download URLs; it does not change storage permissions, recovery or retention.
### Update progress stream delivery
Update progress delivery changes observation only. Streaming or polling a
+23 -1
View File
@@ -3,6 +3,22 @@
One short paragraph explaining the customer outcome of the release. Lead with
what feels better or works now, not how it was implemented.
## Highlights
- One to three short plain-text outcomes. Keep each under 140 characters with
no Markdown, links or issue references so the in-app highlights stay readable.
## Proxmox, PBS and backups
- Explain a change in plain language, where users notice it and why it matters.
From v6.4.6 onward, group related changes under the relevant reader-facing
headings: Alerts and notifications, Disks and storage, Proxmox, PBS and backups,
TrueNAS, vSphere and Docker, Install, updates and agents, Updates and agents,
Pulse Pro, AI and hosted, Monitoring and service health, Security, and Other
improvements. Omit unused groups. A narrow patch can keep the simpler section
below instead. Do not repeat a change between groups or add empty sections.
## What's improved
- **Short outcome** - Explain where users notice it and why it matters.
@@ -12,7 +28,7 @@ what feels better or works now, not how it was implemented.
Use a concise set of meaningful improvements. A narrow patch may use fewer
rather than padding the notes with internal work. Each user-visible change
belongs in this list exactly once. Prefer observable behavior over component
belongs in the grouped changes exactly once. Prefer observable behavior over component
names, group related implementation work into one user-recognizable theme, and
use plain language. Each complete bullet, including Markdown links, must be no
more than 260 characters. New release notes must not contain semicolons or em
@@ -33,6 +49,12 @@ constraints, and optional per-pass traces.
Do not add a separate `Fixes` section. That shape encourages the same change to
be described twice as both an improvement and a fix.
## Known issues
Describe remaining user symptoms and an actionable workaround or next step.
Omit this section when there are none. Do not substitute internal verification
status for information users can act on.
## Before you upgrade
Include only compatibility, migration, signing, companion-app, known-risk, or
@@ -138,7 +138,15 @@ const { chromium, firefox, webkit } = require('playwright');
}
if (state === 'failure')
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
return route.fulfill({ json: response(type, id, range, state === 'empty') });
const result = response(type, id, range, state === 'empty');
if (state === 'sparse')
result.metrics = {
cpu: [point(5, 0)],
netin: [point(5, 0)],
diskwrite: [point(5, 0)],
temperature: [point(5, 50)],
};
return route.fulfill({ json: result });
}
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
@@ -336,14 +344,54 @@ const { chromium, firefox, webkit } = require('playwright');
);
await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) });
progress('replacement-window-verified', { theme });
state = 'empty';
state = 'sparse';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await page.waitForFunction(
() => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0,
);
await detail.screenshot({ path: path.join(artifacts, `sparse-${theme}.png`) });
const sparseObservation = {
theme,
pointCount: await detail.locator('[data-history-observation]').count(),
collectingClaims: await detail.getByText('Collecting history', { exact: true }).count(),
descriptions: await detail
.getByTestId('guest-history-plot')
.evaluateAll((plots) =>
plots.map(
(plot) => document.getElementById(plot.getAttribute('aria-describedby'))?.textContent,
),
),
};
progress('sparse-observation', sparseObservation);
assert.equal(sparseObservation.pointCount, 4, 'each lone series must be visible');
assert.equal(sparseObservation.collectingClaims, 0);
assert.equal(await detail.getByRole('slider').count(), 0);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
for (const dot of await detail.locator('[data-history-observation]').all()) {
assert.ok(Math.abs(Number(await dot.getAttribute('cx')) - expectedX(5, '1h')) < 0.01);
assert.ok(Number.isFinite(Number(await dot.getAttribute('cy'))));
}
await verifyWindow(detail, '1h');
state = 'failure';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await detail
.getByText('History refresh failed. Showing previously loaded history.')
.waitFor();
assert.equal(await detail.locator('[data-history-observation]').count(), 4);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
state = 'empty';
await detail.getByRole('button', { name: 'Retry history' }).click();
await page.waitForFunction(
() => document.querySelectorAll('[data-history-observation]').length === 0,
);
await verifyWindow(detail, '1h');
assert.equal(await detail.getByRole('slider').count(), 0);
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4);
assert.equal(
await detail.getByText('No stored history in this range', { exact: true }).count(),
4,
);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 0);
await detail.screenshot({ path: path.join(artifacts, `empty-${theme}.png`) });
state = 'success';
await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d');
await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor();
@@ -354,6 +402,23 @@ const { chromium, firefox, webkit } = require('playwright');
assert.equal(requests.length, readsBeforeLock);
assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0);
assert.equal(errors.length, 0, errors.join('\n'));
// The direct production renderer deliberately supplies current readings.
// A live fallback must be labelled, never plotted or attributed to a stored time.
state = 'empty';
await page.goto('http://127.0.0.1:5225/browser-tests/pbs-history-refresh.html');
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
theme === 'dark',
);
detail = page.getByTestId('history-refresh-fixture');
await detail.getByText('No stored history in this range', { exact: true }).first().waitFor();
const liveCPU = detail.locator(
'[data-history-group="utilization"] [data-history-current="cpu"]',
);
assert.match((await liveCPU.innerText()).replace(/\s+/g, ' '), /CPU\s*42\.0%\s*current/);
assert.equal(await detail.locator('[data-history-observation]').count(), 0);
assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0);
await detail.screenshot({ path: path.join(artifacts, `current-empty-${theme}.png`) });
observations.push({ theme, requests, errors, utilizationX, networkX });
progress('completed-theme', { theme });
await page.close();
@@ -209,7 +209,10 @@ const { chromium } = require('playwright');
await page.getByRole('button', { name: 'Switch to service target', exact: true }).click();
await status.getByText('Failed to load history data', { exact: true }).waitFor();
assert.equal(await plots.count(), 0);
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 0);
assert.equal(
await detail.getByText('No stored history in this range', { exact: true }).count(),
0,
);
assert.equal(await detail.getByText(/previously loaded/).count(), 0);
await checkLayout();
await screenshot('replacement-failed');
@@ -0,0 +1,238 @@
// Browser checks only; server responses are synthetic, not installed bootstrap.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
server: { host: '127.0.0.1', port: 5237, strictPort: true },
});
const engine = process.argv.includes('--phone') ? 'webkit' : 'chromium';
const width = engine === 'webkit' ? 390 : 1280;
const artifacts = path.join(root, 'node_modules', `proxmox-private-${engine}`);
fs.mkdirSync(artifacts, { recursive: true });
let browser;
const observations = [];
try {
await server.listen();
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const type of ['pve', 'pbs']) {
const page = await browser.newPage({
viewport: { width, height: 900 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.addInitScript(() => {
window.__copies = [];
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: async (value) => {
window.__copies.push(value);
},
},
});
});
let count = 0;
let holdEndpoint, releaseHeld, sawHeld;
await page.route('**/api/**', async (route) => {
const url = new URL(route.request().url());
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === holdEndpoint) {
holdEndpoint = undefined;
await new Promise((resolve) => {
releaseHeld = resolve;
sawHeld();
});
}
if (url.pathname === '/api/setup-script-url') {
count++;
const body = route.request().postDataJSON();
assert.equal(body.type, type);
const scriptURL = `http://127.0.0.1:5237/api/setup-script?type=${type}`;
const command = `( curl -fsSL '${scriptURL}' -o "$install_script"; sudo bash -c 'read -r -s pulse_token; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"'; )`;
return route.fulfill({
json: {
type,
host: body.host,
url: scriptURL,
downloadURL: scriptURL,
scriptFileName: `pulse-setup-${type}.sh`,
command,
commandWithEnv: command,
commandWithoutEnv: command,
setupToken: 'a'.repeat(32),
tokenHint: 'aaa…aaa',
expires: Math.floor(Date.now() / 1000) + 300,
},
});
}
if (url.pathname === '/api/agent-install-command') {
const body = route.request().postDataJSON();
assert.equal(body.type, type);
assert.equal(body.enableCommands, false);
assert.equal(body.insecure, false);
return route.fulfill({
json: {
command:
'( bash "$install_script" --preflight-only; sudo bash -c \'read -r -s pulse_token; bash "$1" --token-file "$token_file" --enable-proxmox\'; )',
token: 'b'.repeat(32),
},
});
}
if (url.pathname === '/api/setup-script') {
assert.ok(!url.searchParams.has('setup_token'));
return route.fulfill({
status: 200,
contentType: 'text/x-shellscript; charset=utf-8',
headers: { 'Content-Disposition': `attachment; filename="pulse-setup-${type}.sh"` },
body: '#!/bin/bash\n# no credential in the download\n',
});
}
return route.fulfill({ json: { data: [] } });
});
await page.goto(
`http://127.0.0.1:5237/browser-tests/proxmox-private-input.html?type=${type}`,
);
await page.locator('h1').waitFor();
if (engine === 'webkit')
await page.evaluate(() => document.documentElement.classList.add('dark'));
console.log(
JSON.stringify({
type,
errors,
buttons: await page.getByRole('button').allTextContents(),
body: (await page.locator('body').innerText()).slice(0, 300),
}),
);
const copy = page.getByRole('button', { name: 'Copy command', exact: true });
await copy.first().click();
let dialog = page.getByRole('dialog', { name: 'API token ready' });
await dialog.waitFor();
await page.waitForFunction(() => {
const panel = document.querySelector('[role="dialog"]');
return panel && getComputedStyle(panel).opacity === '1';
});
assert.ok((await dialog.innerText()).includes('silent “Pulse setup token” prompt'));
assert.ok(!(await page.evaluate(() => window.__copies[0])).includes('a'.repeat(32)));
await page.screenshot({ path: path.join(artifacts, `${type}-setup.png`), fullPage: true });
const tokenButton = dialog.getByRole('button', { name: 'Copy token', exact: true });
await tokenButton.focus();
await page.keyboard.press('Enter');
assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'a'.repeat(32));
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
await copy.first().click();
await dialog.waitFor();
assert.equal(count, 1, 'live setup artifact must be reused');
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
await page.getByRole('button', { name: 'Close setup', exact: true }).click();
assert.equal(await page.getByTestId('cache-state').innerText(), 'empty');
await page.getByRole('button', { name: 'Reopen setup', exact: true }).click();
await copy.first().click();
await dialog.waitFor();
assert.equal(count, 2, 'closed modal must not retain its setup secret');
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
await page.getByText('Alternative: Download script manually', { exact: true }).click();
await page.getByRole('button', { name: 'Download setup script', exact: true }).click();
await dialog.waitFor();
assert.equal(count, 2, 'download must reuse the live credential-free artifact');
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click();
await page
.getByRole('button', {
name: type === 'pbs' ? 'Copy to clipboard' : 'Copy command',
exact: true,
})
.first()
.click();
await dialog.waitFor();
await page.waitForFunction(() => {
const panel = document.querySelector('[role="dialog"]');
return panel && getComputedStyle(panel).opacity === '1';
});
assert.ok((await dialog.innerText()).includes('silent “Pulse agent token” prompt'));
assert.ok(!(await page.evaluate(() => window.__copies.at(-1))).includes('b'.repeat(32)));
await page.screenshot({ path: path.join(artifacts, `${type}-agent.png`), fullPage: true });
await dialog.getByRole('button', { name: 'Copy token', exact: true }).click();
assert.equal(await page.evaluate(() => window.__copies.at(-1)), 'b'.repeat(32));
await dialog.getByRole('button', { name: 'Dismiss', exact: true }).click();
// A late issuance must not repopulate a closed modal's secret cache
// or reopen the global reveal dialog after the user has left the flow.
for (const endpoint of ['/api/setup-script-url', '/api/agent-install-command']) {
const closeSetup = page.getByRole('button', { name: 'Close setup', exact: true });
if (await closeSetup.count()) await closeSetup.click();
await page.getByRole('button', { name: 'Reopen setup', exact: true }).click();
if (endpoint.includes('agent-install'))
await page.getByRole('button', { name: 'Host Telemetry Agent', exact: true }).click();
else await page.getByRole('button', { name: /Connect via API/ }).click();
holdEndpoint = endpoint;
let deadline;
const held = new Promise((resolve, reject) => {
sawHeld = resolve;
deadline = setTimeout(() => reject(new Error('held request not observed')), 15000);
});
const nextCopy = page
.getByRole('button', {
name:
endpoint.includes('agent-install') && type === 'pbs'
? 'Copy to clipboard'
: 'Copy command',
exact: true,
})
.first();
await nextCopy.click();
await held;
clearTimeout(deadline);
await page.getByRole('button', { name: 'Close setup', exact: true }).click();
const completed = page.waitForResponse(
(response) => new URL(response.url()).pathname === endpoint,
);
releaseHeld();
await completed;
await page.waitForTimeout(200); // allow the actual promise continuation, not an API retry
assert.equal(await page.getByTestId('cache-state').innerText(), 'empty');
assert.equal(await page.getByRole('dialog').count(), 0);
}
assert.deepEqual(errors, []);
observations.push({
type,
engine,
width,
setupRequests: count,
credentialFreeCopies: true,
separateTokenCopy: true,
cacheClose: true,
lateIssuanceDiscarded: true,
commandsEnabled: false,
insecure: false,
});
await page.close();
}
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify(
{
playwright: require('playwright/package.json').version,
browser: browser.version(),
observations,
},
null,
2,
),
);
console.log(JSON.stringify({ engine, browser: browser.version(), observations, artifacts }));
} finally {
if (browser) await browser.close();
await server.close();
}
})();
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proxmox private input fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./proxmox-private-input.tsx"></script>
</body>
</html>
@@ -0,0 +1,54 @@
// Real settings state, setup section and existing token dialog; synthetic APIs.
import { createSignal, Show } from 'solid-js';
import type { NodeConfig } from '../src/types/nodes';
import { render } from 'solid-js/web';
import { useNodeModalState } from '../src/components/Settings/useNodeModalState';
import { NodeModalSetupGuideSection } from '../src/components/Settings/NodeModalSetupGuideSection';
import { TokenRevealDialog } from '../src/components/TokenRevealDialog';
import '../src/index.css';
const Fixture = () => {
const type = new URLSearchParams(location.search).get('type') === 'pbs' ? 'pbs' : 'pve';
const [open, setOpen] = createSignal(true);
const props = {
get isOpen() {
return open();
},
nodeType: type,
prefillNode: {
type,
name: `${type} fixture`,
host: `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`,
verifySSL: true,
} as NodeConfig,
onClose() {
setOpen(false);
},
onSave: async () => {},
};
const state = useNodeModalState(props);
return (
<main class="mx-auto max-w-2xl p-4 bg-base text-base-content">
<h1 class="text-lg mb-3">Connect a Proxmox server</h1>
<button
type="button"
class="border border-border p-3 mb-3"
onClick={() => {
setOpen(!open());
if (open())
state.updateField('host', `https://${type}.example:${type === 'pbs' ? 8007 : 8006}`);
}}
>
{open() ? 'Close setup' : 'Reopen setup'}
</button>
<Show when={open()}>
<NodeModalSetupGuideSection modalProps={props} state={state} />
</Show>
<output data-testid="cache-state">
{state.quickSetupCommandReady() ? 'ready' : 'empty'}
</output>
<TokenRevealDialog />
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,245 @@
// Real production table rows and PBS drawers; synthetic bounded HTTP data.
// Do not instrument rows with native listeners: doing so masks the WebKit bug.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, firefox, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const engine = process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'webkit';
const theme = process.argv.includes('--dark') ? 'dark' : 'light';
const width = process.argv.includes('--desktop') ? 1365 : 390;
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
const out = path.join(root, 'node_modules', `table-row-touch-${engine}-${width}-${theme}`);
fs.mkdirSync(out, { recursive: true });
fs.chmodSync(out, 0o755);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: fs.mkdtempSync(path.join(out, 'vite-cache-')),
server: { host: '127.0.0.1', port: 5227, strictPort: true },
});
const sourceHashes = Object.fromEntries(
['src/components/shared/Table.tsx', 'package-lock.json'].map((file) => [
file,
createHash('sha256')
.update(fs.readFileSync(path.join(root, file)))
.digest('hex'),
]),
);
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
let browser, page;
const errors = [],
requests = [],
observations = [];
const snapshot = async (name) => {
const file = path.join(out, name + '.png');
await page.screenshot({ path: file });
fs.chmodSync(file, 0o644);
};
const activate = (locator) => (width === 390 ? locator.tap() : locator.click());
const noOverflow = async () =>
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
'document overflow',
);
try {
await server.listen();
browser = await { chromium, firefox, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
locale: 'en-GB',
timezoneId: 'UTC',
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
});
page.setDefaultTimeout(20_000);
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5227') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, search: url.search, method: route.request().method() });
assert.equal(route.request().method(), 'GET');
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community', label: 'Pulse Community runtime' },
blocked_capabilities: [],
},
});
if (url.pathname === '/api/metrics-store/history') {
const id = url.searchParams.get('resourceId');
assert.ok(['vm-one', 'vm-two', 'agent-three'].includes(id), `unexpected target ${id}`);
const end = Date.UTC(2026, 9, 1, 12);
return route.fulfill({
json: {
resourceType: url.searchParams.get('resourceType'),
resourceId: id,
range: url.searchParams.get('range'),
start: end - 86400000,
end,
source: 'store',
metrics: {
cpu: [
{ timestamp: end - 600000, value: 20 },
{ timestamp: end - 300000, value: 30 },
],
memory: [{ timestamp: end - 300000, value: 40 }],
netin: [
{ timestamp: end - 600000, value: 1024 },
{ timestamp: end - 300000, value: 2048 },
],
},
},
});
}
return route.fulfill({ json: { data: [], enabled: false } });
});
console.log(JSON.stringify({ stage: 'production-pbs', engine, theme, width, sourceHashes }));
await page.goto('http://127.0.0.1:5227/browser-tests/pbs-identity-boundary.html', {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
theme === 'dark',
);
await activate(page.getByRole('button', { name: 'Corroborate links', exact: true }));
for (const suffix of ['one', 'two', 'three']) {
const cell = page.locator(`td[title="backup-connection-${suffix} · tank"]`);
const disclosure = cell.getByRole('button');
assert.equal(await disclosure.getAttribute('aria-expanded'), 'false');
// No focus, force, dispatchEvent, synthetic click or added event listeners.
await activate(cell);
await page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`).waitFor();
assert.equal(await disclosure.getAttribute('aria-expanded'), 'true');
const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`);
observations.push({ suffix, firstActivationOpened: true });
await noOverflow();
if (suffix === 'one') await snapshot('pbs-first-activation');
await activate(detail.getByRole('tab', { name: 'History', exact: true }));
await detail.getByTestId('guest-history-plot').first().locator('path').first().waitFor();
assert.equal(
await disclosure.getAttribute('aria-expanded'),
'true',
'tab must not retoggle row',
);
if (suffix === 'three') await snapshot('pbs-third-history');
// Close by the same ordinary cell; nested History input cannot consume it.
await activate(cell);
assert.equal(await disclosure.getAttribute('aria-expanded'), 'false');
assert.equal(await detail.count(), 0);
}
const keyboardCell = page.locator('td[title="backup-connection-one · tank"]');
const keyboardDisclosure = keyboardCell.getByRole('button');
await keyboardDisclosure.focus();
await page.keyboard.press('Enter');
assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'true');
await page.keyboard.press('Space');
assert.equal(await keyboardDisclosure.getAttribute('aria-expanded'), 'false');
assert.ok(requests.some((request) => request.search.includes('resourceId=vm-one')));
assert.ok(requests.some((request) => request.search.includes('resourceId=vm-two')));
assert.ok(requests.some((request) => request.search.includes('resourceId=agent-three')));
console.log(JSON.stringify({ stage: 'shared-controls', engine, theme, width }));
await page.goto('http://127.0.0.1:5227/browser-tests/table-row-touch.html', {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
theme === 'dark',
);
const state = async () =>
JSON.parse(await page.getByRole('status', { name: 'Action state' }).innerText());
const name = page.getByText('Shared resource name', { exact: true });
await name.waitFor();
await activate(name);
assert.equal((await state()).rowActions, 1);
assert.equal((await state()).expanded, true);
await activate(page.getByRole('button', { name: 'Child action', exact: true }));
await activate(page.getByRole('link', { name: 'Native link' }));
assert.equal(new URL(page.url()).hash, '#native-link');
await activate(page.getByRole('checkbox', { name: 'Child checkbox' }));
assert.equal(await page.getByRole('checkbox').isChecked(), true);
assert.equal((await state()).rowActions, 1, 'nested controls must not run row action');
assert.equal((await state()).childActions, 2);
const toggle = page.getByTestId('control-row').locator('[data-row-action="true"]');
await activate(toggle);
assert.equal((await state()).rowActions, 2, 'nested disclosure must toggle once');
await toggle.focus();
await page.keyboard.press('Enter');
assert.equal((await state()).rowActions, 3);
await page.keyboard.press('Space');
assert.equal((await state()).rowActions, 4);
await activate(page.getByRole('button', { name: 'Toggle row action' }));
await activate(name);
assert.equal((await state()).rowActions, 4);
await activate(page.getByRole('button', { name: 'Toggle row action' }));
await activate(name);
assert.equal((await state()).rowActions, 5);
await activate(page.getByText('Static row', { exact: true }));
assert.equal((await state()).rowActions, 5);
await activate(page.getByText('Grid resource', { exact: true }));
assert.equal((await state()).gridActions, 1);
await activate(page.getByRole('button', { name: 'Grid child action', exact: true }));
assert.equal((await state()).gridActions, 1);
assert.equal((await state()).childActions, 3);
await noOverflow();
await snapshot('shared-controls');
assert.deepEqual(errors, []);
const result = {
engine,
browserVersion: browser.version(),
playwrightVersion,
theme,
width,
sourceHashes,
observations,
requests,
controlState: await state(),
errors,
result: 'passed',
};
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2) + '\n', {
mode: 0o644,
});
console.log(JSON.stringify(result));
} catch (error) {
if (page) await snapshot('failure').catch(() => {});
fs.writeFileSync(
path.join(out, 'failure.json'),
JSON.stringify(
{ error: String(error), errors, requests, observations, sourceHashes },
null,
2,
) + '\n',
{ mode: 0o644 },
);
throw error;
} finally {
await browser?.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shared table touch verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/table-row-touch.tsx"></script>
</body>
</html>
@@ -0,0 +1,91 @@
// Real shared table/grid actions with synthetic data, not an installed journey.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Table, TableBody, TableCell, TableRow } from '../src/components/shared/Table';
import { SummaryRowActionButton } from '../src/components/shared/SummaryRowActionButton';
import { PulseDataGrid } from '../src/components/shared/PulseDataGrid';
import '../src/index.css';
function Fixture() {
const [expanded, setExpanded] = createSignal(false);
const [rowActions, setRowActions] = createSignal(0);
const [childActions, setChildActions] = createSignal(0);
const [enabled, setEnabled] = createSignal(true);
const [gridActions, setGridActions] = createSignal(0);
const toggle = () => {
setRowActions((count) => count + 1);
setExpanded((value) => !value);
};
const child = (event: MouseEvent) => {
event.stopPropagation();
setChildActions((count) => count + 1);
};
return (
<main class="space-y-4 p-4">
<h1>Shared table touch verification</h1>
<button type="button" onClick={() => setEnabled((value) => !value)}>
Toggle row action
</button>
<output aria-label="Action state" class="block break-all text-xs">
{JSON.stringify({
expanded: expanded(),
rowActions: rowActions(),
childActions: childActions(),
enabled: enabled(),
gridActions: gridActions(),
})}
</output>
<Table>
<TableBody>
<TableRow data-testid="control-row" onClick={enabled() ? toggle : undefined}>
<TableCell>
<SummaryRowActionButton
kind="disclosure"
subjectLabel="shared resource"
expanded={expanded()}
hideWhenRowTappableOnMobile={false}
onAction={toggle}
/>
<span>Shared resource name</span>
</TableCell>
<TableCell>
<button type="button" onClick={child}>
Child action
</button>
<a href="#native-link" onClick={child}>
Native link
</a>
<input
type="checkbox"
aria-label="Child checkbox"
onClick={(event) => event.stopPropagation()}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Static row</TableCell>
<TableCell>No action</TableCell>
</TableRow>
</TableBody>
</Table>
<PulseDataGrid<{ id: string; name: string }>
data={[{ id: 'grid-one', name: 'Grid resource' }]}
keyExtractor={(row) => row.id}
onRowClick={() => setGridActions((count) => count + 1)}
columns={[
{ key: 'name', label: 'Name' },
{
key: 'action',
label: 'Action',
render: () => (
<button type="button" onClick={child}>
Grid child action
</button>
),
},
]}
/>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
+35 -767
View File
@@ -1,795 +1,63 @@
{
"version": 1,
"base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9",
"verified_at": "2026-10-01T01:42:43Z",
"base_sha": "6b23876ec8104965c7842e194e055f3206134d4f",
"verified_at": "2026-10-01T04:21:56.727078Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
"frontend-modern/src/api/nodes.ts",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
"frontend-modern/src/components/Settings/useNodeModalState.ts",
"frontend-modern/src/stores/tokenReveal.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99"
"frontend-modern/src/api/nodes.ts": "398f3b87ce192c7650a9e34f184ecdca0e8baf35b57d90ef5f8f697348675fec",
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "e4168945744f8ec20b5439f5730b1ecd8b4811b29ec00da4e7e3798f868a1b41",
"frontend-modern/src/components/Settings/useNodeModalState.ts": "9372c3a4b346a0e31733a7042c0a65b5ebe3518b12595c6c6a68b1cadd667dcd",
"frontend-modern/src/stores/tokenReveal.ts": "c21ac970bde6229506f7d6e627430b6e91c9ea983f705a28e3d149e4415c39b7"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)"
"/browser-tests/proxmox-private-input.html?type=pve",
"/browser-tests/proxmox-private-input.html?type=pbs"
],
"viewports": [
{
"width": 1365,
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 844
"height": 900
}
],
"states": [
"light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot",
"all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels",
"three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities",
"same-time observations occupy the same horizontal coordinate across groups with different sample coverage",
"failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window",
"held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window",
"empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read"
"Actual settings state/setup section and existing token-reveal dialog with production CSS; synthetic server artifacts and no installed bootstrap execution",
"Separate command and credential copy for API Inventory and Host Telemetry Agent; raw secrets absent from copied shell text",
"PVE/PBS tokenless script download reuses live bootstrap metadata; credential cache clears on close/reopen",
"Held setup and agent issuance completed after modal close is discarded: no secret cache and no reveal dialog",
"Default TLS verification and monitoring-only agent request (enableCommands=false) unchanged",
"Desktop light and phone-emulated WebKit dark token reveal is readable; screenshots visually inspected"
],
"interactions": [
"desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)",
"focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network",
"Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads",
"fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range"
"Copy command; copy token via Enter and pointer; Escape and Dismiss close token dialog",
"Close/reopen setup; download script; switch API/Agent paths",
"Hold setup/agent response, close modal, then release response and verify no late reveal/cache"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.",
"command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs; pulse-worker-browser frontend-modern/browser-tests/proxmox-private-input.cjs --phone (separate commands from assigned workspace root)",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log"
],
"notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.",
"prior_verifications": [
{
"version": 1,
"base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881",
"verified_at": "2026-10-01T00:37:51Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp",
"a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data",
"zero-valued lone observations can be inspected without a synthetic trend",
"genuinely aligned series share cursor geometry and their own observed values",
"network directions preserve independently missing and zero rates",
"native keyboard/touch selection takes precedence over pointer movement",
"failed same-source refresh retains correctly attributed observations and a visible warning",
"a successful refresh updates the hovered timestamp values without choosing other latest metrics",
"held service-target replacement has no former-host observations, cursor or dated pointer description"
],
"interactions": [
"move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry",
"leave the plot to restore latest/current legends; verify pointer inspection makes no API reads",
"focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection",
"Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area",
"fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
"browser_versions": {
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log"
],
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability."
},
{
"version": 1,
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
"verified_at": "2026-09-30T23:28:26Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"light/dark ordered and sparse stored series",
"native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface",
"missing series at a selected timestamp stay missing, not nearby or live values",
"same-source refresh adds samples without changing the selected timestamp or keyboard focus",
"failed refresh retains inspectable matching observations and honest warning",
"single stored observation has a dated description but no fabricated trend or pointless slider",
"empty store/current readings expose no inspection control",
"held replacement target has no former-host points or selection; new target starts at its own latest observation",
"licence-locked range has no reads or inspection action"
],
"interactions": [
"Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads",
"refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp",
"move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works",
"Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values",
"fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
"browser_versions": {
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log"
],
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten."
},
{
"version": 1,
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
"verified_at": "2026-09-30T22:39:35Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/UpdateProgressModal.tsx",
"frontend-modern/src/components/updateReadinessModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
},
"routes": [
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
],
"interactions": [
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
],
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
"artifacts": [
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
],
"notes": [
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
]
},
{
"version": 1,
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
"verified_at": "2026-09-30T20:12:09Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"corroborated host with seven observed metric paths",
"uncached range replacement pending with no old paths",
"new range loaded before the old range response",
"withdrawn host link with replacement target pending",
"replacement target returns 503 with no former-host history",
"service-only response retains CPU/memory, not host disk/network/I/O",
"locked 14-day range issues no history read"
],
"interactions": [
"desktop keyboard Enter / phone row click to open PBS details",
"select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
"withdraw third host link while service GET is held, then return 503",
"select 6h after failure and inspect the service-only response",
"select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
],
"notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately."
},
{
"version": 1,
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
"verified_at": "2026-09-30T01:40:13Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"three unlinked PBS services alongside six label/IP-colliding Agents",
"explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication",
"unchanged links with host rows omitted and restored",
"withdrawn third link with real and colliding host rows still present",
"fresh exact PBS nodeName to Agent hostname match with mismatched display labels"
],
"interactions": [
"desktop keyboard disclosure and phone row activation for all three PBS drawers",
"inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges",
"join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal",
"24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png"
],
"notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure."
},
{
"version": 1,
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
"verified_at": "2026-09-29T23:04:22Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
},
"routes": [
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
"host rows omitted in a transient refresh while the backend link is unchanged",
"third backend link withdrawn while the Agent row remains omitted"
],
"interactions": [
"open all three PBS drawers and inspect their Metrics Target identity",
"omit host rows and confirm targets stay on the correlated hosts",
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
],
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
],
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series."
},
{
"version": 1,
"base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3",
"verified_at": "2026-09-29T22:13:56Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/ContainerUpdateBadge.tsx",
"frontend-modern/src/components/shared/containerUpdateBadgeModel.ts",
"frontend-modern/src/components/shared/useContainerUpdateButtonState.ts",
"frontend-modern/src/stores/containerUpdates.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/ContainerUpdateBadge.tsx": "fd88f3e3e275926572126f43cce7296e7361b5e527868dd3c59c7eb951e9f5c9",
"frontend-modern/src/components/shared/containerUpdateBadgeModel.ts": "10bc488cb82ae84c6ae46d77ec672d34df2a1b3c28e138ae0f3b4214cccbe37e",
"frontend-modern/src/components/shared/useContainerUpdateButtonState.ts": "4b9dd4a42549b0433a1afa56f26183fe721c80626f823fb3c8489934721267c5",
"frontend-modern/src/stores/containerUpdates.ts": "b5d47132f7daf7f14bcc38462043288fc211344410af622ada7728694bcd514d"
},
"routes": [
"/browser-tests/update-action-receipt.html (production Docker update button with scripted action reads)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"governed update receipt pending after registry updateAvailable becomes false",
"existing action re-opened by GET with no second plan or execute",
"recorded completed action after a second GET changes the displayed outcome"
],
"interactions": [
"inspect pending button and absence of a Current or Completed claim",
"select Review action and inspect receipt-pending dialog",
"select Check for receipt and inspect recorded completion",
"assert both action requests are GET; no page errors or horizontal overflow"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/update-action-receipt.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/pending-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/review-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-ahrmjhme/update-action-receipt-proof-final/completed-390.png"
],
"notes": "Mock-backed UI proof; six screenshots inspected, including pending and completed phone states. This does not establish installed behaviour or resolve #1891."
},
{
"version": 1,
"base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
"verified_at": "2026-09-29T19:50:45Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/pages/Actions.tsx"],
"content_sha256": {
"frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
},
"routes": [
"/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"detail for edge-a held while edge-b is selected and rendered",
"late edge-a response cannot replace edge-b review or URL",
"pending receipt re-read finishes after edge-b review closes"
],
"interactions": [
"select edge-a then edge-b before the first detail response arrives",
"release older response and inspect current review at desktop and phone widths",
"start same-action receipt GET, close review, release response; no dialog reopens",
"assert all scripted API requests are GET and no horizontal overflow or page errors"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png"
],
"notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established."
},
{
"version": 1,
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
"verified_at": "2026-09-29T19:09:52Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"],
"content_sha256": {
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
},
"routes": [
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"fresh executing/receipt_pending, including after a same-action re-read",
"failed status re-read with unknown outcome preserved",
"completed action after a recorded receipt"
],
"interactions": [
"desktop pointer and phone keyboard Check for receipt",
"GET same action under pending, read error and completion; no POST",
"desktop and phone layout and horizontal-overflow checks"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
],
"notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest."
},
{
"version": 1,
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
"verified_at": "2026-09-29T16:42:43Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/types/resource.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12",
"frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56"
},
"routes": [
"/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"PBS API + corroborated Agent with token-auth nodeName absent",
"same-host PVE API joined alongside a PVE-only name collision",
"correlated host row temporarily omitted",
"correlated host row restored"
],
"interactions": [
"desktop disclosure-button keyboard focus and Enter; phone row click",
"open History and select 24h, 6h, 1h and 12h across topology changes",
"join PVE API, omit host row, restore host row"
],
"command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png"
],
"notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.",
"preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d"
},
{
"version": 1,
"base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2",
"verified_at": "2026-09-29T12:15:52Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx",
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts",
"frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/utils/alertDestinationsPresentation.ts",
"frontend-modern/src/utils/licensePresentation.ts",
"frontend-modern/src/utils/relayPresentation.ts",
"frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts",
"frontend-modern/src/utils/selfHostedPlans.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx": "1def33d23464ed3e0a6127e75f16e7da814c9ca7d8fc6a680532fbef05e67755",
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "4847df256a489833c5836e0babcd651ea85d640a204288979f965bda4ff7432b",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "d4832713b84d88033fa15211342b6ff8a243e20c56732c5431aed8e10b22e9c2",
"frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts": "56525ac9bd0f3634adea1cccbc53c557caed8faad223f48d788c074ab2fa8a86",
"frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx": "3a160748f1bd0f42ba412757ca919803a440ad8afb0a946d0d5807188c4bc5a0",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "1e07af69a4f4268601f8227500e79eb6a827c49f7b0d62b283129ff5fa2935f9",
"frontend-modern/src/i18n/messages.de.ts": "2daa6b5a6bb2a59bf7846fa2f93310a48065fe7bc3082777cbb51c2fd5f107e2",
"frontend-modern/src/i18n/messages.es.ts": "7052ac4596ef1d36b46935f1bf451f552aee87abe45e23840b3e9d570b215205",
"frontend-modern/src/i18n/messages.ts": "42c6798b32c5647c89a9f658b40e605773d85e5478a4b889bc0469ef3b2efe8e",
"frontend-modern/src/utils/alertDestinationsPresentation.ts": "82980cfc35fab1e91c5484b75e1bc3b84f6c2234e26ed5306b4583e89da3f880",
"frontend-modern/src/utils/licensePresentation.ts": "3573f465207e64e2984c5f18dc89f644b6a88de6e124779840714b01f572e3d6",
"frontend-modern/src/utils/relayPresentation.ts": "9a0d1000d91e307d31b7176be7d4529a150f7364fd913a4b379e2c96a3aa9e1c",
"frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts": "64baf62ae41b9832381ac2a91f7c9724131e765f2493cb894efabeed2ae16d0e",
"frontend-modern/src/utils/selfHostedPlans.ts": "5efab41402fa3409f307398d5feb3b8ccdbf16ac01d6ce0cdacc3024c640b970"
},
"routes": [
"/settings/system-relay",
"/alerts/notifications",
"/settings/pulse-intelligence/billing/plan",
"/settings/system-general"
],
"viewports": [
{
"width": 1024,
"height": 768
},
{
"width": 390,
"height": 844
}
],
"states": [
"Licensed instance (MSP test license from the offline issuer, relay capability present): Settings > Pulse Mobile shows 'Pair Pulse Mobile' with the 31 March 2027 retirement copy, the 'Allow Pulse Mobile connections' toggle, no Remote Access wording and no upgrade prompt",
"Licensed instance: Alerts > Notifications shows the Mobile push notifications panel with retirement copy, minimum-severity control and the 'Open Pulse Mobile settings' link",
"Licensed instance: Plans & Billing lists the relay feature as 'Pulse Relay (Mobile Connection)' with no Remote Access or remote web access wording",
"Community instance (no license): Alerts > Notifications has no Mobile push panel and no Relay upsell while webhook, email and Apprise destinations remain",
"Community instance: settings navigation has no Pulse Mobile or Remote Access item; the direct /settings/system-relay route shows the retirement message with no upgrade link",
"Community instance: Plans & Billing shows the Community plan with no Relay plan card or remote web access copy"
],
"interactions": [
"Activated the offline issuer test license through /api/license/activate on the licensed stack and confirmed relay in /api/license/entitlements",
"Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow",
"Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links"
]
},
{
"version": 1,
"base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3",
"verified_at": "2026-09-30T22:01:47Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903",
"frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"light/dark current host history",
"real 30-second poll fails while matching points remain explicitly labelled",
"keyboard retry held with busy/activation guard and retained points",
"successful retry keeps keyboard focus and updates the same control",
"real background polling supersedes a slow foreground read and settles loading",
"late aborted response cannot replace current paths",
"uncached service-target failure has neither former-host points nor a collecting claim",
"service-target retry renders CPU/memory only",
"locked range exposes no read or refresh action"
],
"interactions": [
"Enter retry twice while its GET is held; exactly one retry read starts",
"fulfil retry; inspect warning removal, changed paths and retained focus",
"hold manual refresh across the real polling interval; inspect loading settlement and aborted read",
"switch to service target, fail its GET, retry the same key, select locked 14d"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png"
],
"notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed."
}
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-agent.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pbs-setup.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-agent.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/pve-setup.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-desktop/result.json",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-agent.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pbs-setup.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-agent.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/pve-setup.png",
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-yp94gsia/browser-phone/result.json"
]
}
+131 -71
View File
@@ -34,7 +34,7 @@ X-API-Token: <token>
Then make a read-only request (curl 7.76 or later):
```bash
curl --fail-with-body --header "@$HOME/.config/pulse/api-header" \
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/state/summary
```
@@ -43,7 +43,8 @@ access, use your Pulse HTTPS URL and keep certificate verification enabled;
do not use `--insecure`. On an authentication-enabled instance, the protected
summary checks token access; `/api/health` is public and does not verify authentication.
`--fail-with-body` returns a non-zero exit on HTTP errors, including 401 and
403, while retaining the error response.
403, while retaining the error response. Keep `--disable` first: it ignores
local curl configuration that could otherwise enable credential-bearing trace output.
Do not paste tokens into command lines, URLs or issue reports. Keep the header
file private and outside shared repositories and diagnostics; do not use curl
@@ -93,6 +94,8 @@ Some endpoints require admin privileges and/or scopes. Common scopes include:
- `settings:write`
- `agent:config:read`
- `agent:manage`
- `actions:plan`, `actions:approve`, `actions:execute`
- `audit:read`
Endpoints that require admin access are noted below.
@@ -311,77 +314,129 @@ Returns the canonical fleet connections ledger with per-row fleet-governance sta
The payload is the source of truth for enrollment, liveness, version drift, adapter health, config rollout, credential posture, update posture, and remote-control posture. Consumers must not rebuild those states from provider-specific config stores or display labels.
CLI adapter:
Use the private header file prepared in [Authentication](#-authentication),
with `settings:read` on the token:
```bash
PULSE_API_TOKEN=your-token pulse fleet connections \
--api-url http://localhost:7655
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/connections
```
### Unified Action Planning
`POST /api/actions/plan`
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
The existing action API separates planning, approval and execution. Run these
steps separately, inspecting each response before moving on; do not paste the
whole sequence as an unattended recovery script. Use the private header file
from [Authentication](#-authentication), not a token in a command or environment
assignment. For remote access, replace the loopback origin with your Pulse HTTPS
URL and keep certificate verification enabled.
`POST /api/actions/{id}/decision`
Records an explicit `approved` or `rejected` decision for a persisted `pending_approval` action. It does not execute the action.
| Endpoint | Token scope and access |
| --- | --- |
| `GET /api/agent/resource-capabilities/{id}` | `monitoring:read`; lists the resource's advertised capabilities and parameter schemas. |
| `POST /api/actions/plan` | `actions:plan` and permission to plan actions; creates a plan but does not approve or execute it. |
| `POST /api/actions/{id}/decision` | `actions:approve` and permission to approve actions; records an explicit `approved` or `rejected` decision for a pending action, without executing it. |
| `POST /api/actions/{id}/execute` | `actions:execute` and permission to execute actions; dispatches only an approved action or an approval-free executable plan. Dry-run-only plans cannot execute. |
| `GET /api/audit/actions` | `audit:read`, audit-log read permission and licensed audit logging. |
| `GET /api/audit/actions/{id}/events` | The same audit access; returns the action's lifecycle evidence. |
`POST /api/actions/{id}/execute`
Starts execution only for an approved action or an approval-free executable plan, records `executing` before dispatch, and records the terminal result afterward. Dry-run-only plans are rejected and cannot be executed through this endpoint.
The legacy `ai:execute` scope also permits planning, approval and execution,
but use the narrower action scopes when possible. Approval and execution tokens
must be bound to an authorised user; a scope alone does not bypass approval
policy, separation of duties or step-up requirements. Audit reads through a
browser session additionally require admin access. A `monitoring:read` token is
not an action-control or audit token.
1. **Inspect capabilities.** Choose the canonical resource `id` from
`GET /api/resources`, not a display name or a VM number. Replace `vm:42`
throughout these examples and URL-encode it in request paths (`vm%3A42` here).
An empty capabilities list means there is nothing to plan for that resource.
CLI adapter:
```bash
PULSE_API_TOKEN=your-token pulse actions capabilities \
--api-url http://localhost:7655 \
--resource-id vm:42
PULSE_API_TOKEN=your-token pulse actions plan \
--api-url http://localhost:7655 \
--request-id agent-run-123 \
--resource-id vm:42 \
--capability restart \
--param mode=graceful \
--reason "Recover after confirmed outage" \
--requested-by agent:oncall-helper
PULSE_API_TOKEN=your-token pulse actions decide \
--api-url http://localhost:7655 \
--action-id act_... \
--outcome approved \
--reason "Inside maintenance window"
PULSE_API_TOKEN=your-token pulse actions execute \
--api-url http://localhost:7655 \
--action-id act_... \
--reason "Execute approved recovery"
PULSE_API_TOKEN=your-token pulse actions audit \
--api-url http://localhost:7655 \
--resource-id vm:42 \
--limit 10
PULSE_API_TOKEN=your-token pulse actions events \
--api-url http://localhost:7655 \
--action-id act_...
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/agent/resource-capabilities/vm%3A42
```
Request:
```json
2. **Plan only.** Use an advertised capability and its actual parameter schema;
`restart` and `mode=graceful` are examples, not universal capabilities. Choose
a request ID for this specific intent. Pulse derives the actor from the
authenticated credential, not a caller-supplied `requestedBy` value.
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/plan <<'JSON'
{
"requestId": "agent-run-123",
"requestId": "manual-recovery-123",
"resourceId": "vm:42",
"capabilityName": "restart",
"params": { "mode": "graceful" },
"reason": "Recover after confirmed outage",
"requestedBy": "agent:oncall-helper"
"reason": "Recover after confirmed outage"
}
JSON
```
Inspect the returned approval policy, blast radius, expiry and preflight checks.
Keep its `actionId` and reviewed `planHash`; replace `act_...` and `sha256:...`
below with those returned values. Planning is not proof that execution is safe
or currently available.
3. **Decide only if approval is required.** Use an authorised approver's private
header file. Approve only the reviewed plan; stop on a refusal or stale-plan
response rather than weakening the policy or creating another recovery action.
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/act_.../decision <<'JSON'
{
"outcome": "approved",
"reason": "Inside maintenance window",
"planHash": "sha256:..."
}
JSON
```
4. **Execute separately.** Only after reviewing a successful decision, or an
approval-free executable plan, use the executor's authorised header file:
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST --data-binary @- \
http://127.0.0.1:7655/api/actions/act_.../execute <<'JSON'
{
"reason": "Execute approved recovery",
"planHash": "sha256:..."
}
JSON
```
If the response is lost or times out, inspect the existing action and target
before retrying: losing the connection does not prove that execution stopped.
An HTTP success is not a substitute for checking the terminal result and the
resource's observed state.
5. **Read the audit and events**, using a private header file with audit access:
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit/actions?resourceId=vm%3A42&limit=10'
```
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/actions/act_.../events
```
When audit logging is unavailable, use Pulse's action detail in the signed-in
UI to inspect the existing action; do not infer success or repeat execution
from an unavailable audit response.
Response:
```json
{
"actionId": "act_...",
"requestId": "agent-run-123",
"requestId": "manual-recovery-123",
"allowed": true,
"requiresApproval": true,
"approvalPolicy": "admin",
@@ -525,9 +580,11 @@ Request body:
### Setup Script (Public)
`GET /api/setup-script`
Returns the Proxmox/PBS setup script as a shell-script download. Accepts an
optional temporary setup token in the `setup_token` query for embedded
non-interactive bootstrap; otherwise the script prompts for the one-time setup
token at runtime. Canonical callers must send a supported `type` of `pve` or
optional legacy `setup_token` query for compatibility. Current downloads
contain no token: paste the separately revealed token only at the silent
terminal prompt, or supply `PULSE_SETUP_TOKEN_FILE` pointing to a mode-0600
regular file in a mode-0700 directory owned by the script's user. Never put a
token in a copied command or URL. Canonical callers must send a supported `type` of `pve` or
`pbs` plus non-empty `host` and `pulse_url`; the route no longer generates
placeholder-host scripts for later repair or reconstructs Pulse identity from
the request origin. The route now shares the same canonical type boundary as
@@ -557,20 +614,23 @@ authorize the request itself. Pulse-managed Proxmox monitor-token names on the
setup/bootstrap path derive from the canonical Pulse endpoint, not request-local
host fallbacks, so setup-script and turnkey node-add flows stay on one
deterministic `pulse-<canonical-scope-slug>` identity per Pulse instance.
`setupToken` remains bootstrap transport data for `/api/setup-script` and
`/api/auto-register`, while `tokenHint` is the operator-facing display field
for quick-setup surfaces and must stay masked instead of exposing the full
one-time token in UI copy. Shared frontend consumers may validate
`setupToken`, but they should not retain or display it once the returned
bootstrap artifact and `tokenHint` are available; visible quick-setup previews
should use the non-secret `commandWithoutEnv` form while copy actions keep
using the token-bearing `commandWithEnv` artifact, and manual download flows
should use the token-bearing `downloadURL` artifact instead of rebuilding a
plain setup-script URL from non-secret preview state. Non-frontend bootstrap
consumers such as the runtime-side Unified Agent bootstrap flow and shell installer must fail closed on that
same full artifact contract too, rejecting missing or mismatched
`downloadURL`, `tokenHint`, or expired `expires` values instead of accepting a
reduced setup-token-only response shape.
The `command`, `commandWithEnv`, and `commandWithoutEnv` fields now contain
identical credential-free commands. They download the complete script before
running it and prompt silently in the root-or-sudo process. The token crosses
the installer boundary through a private file, not process arguments or an
exported secret. `downloadURL` equals the tokenless `url`, so a manual download
also needs the separately revealed token at runtime. `setupToken` is used for
`/api/auto-register`; `tokenHint` remains masked on the setup page. Settings
reveals the token in a separate dialog: run the command first, then copy and
paste the token only at its prompt. The artifact is reused only for the same
host and options while its five-minute expiry is live, and discarded when the
setup modal closes.
Non-frontend consumers must validate the complete artifact, including the
canonical host, type, URLs, filename, masked hint and live expiry. Current
Unified Agents and the shell installer also accept the coherent older-server
artifact during upgrades, but never execute its command text. For new
Proxmox agent enrolment against a newer server, use its current installer;
already enrolled agents keep reporting normally.
### Auto-Register (Public)
`POST /api/auto-register`
@@ -1123,7 +1183,7 @@ Returns organizations accessible to the authenticated user.
```json
{ "id": "acme-corp", "displayName": "Acme Corporation" }
```
The creator becomes the owner and first member. Organization IDs must be lowercase alphanumeric with hyphens, 3-64 characters.
The creator becomes the owner and first member. Organization IDs use letters, digits, periods, underscores or hyphens, 1–64 characters, but cannot be `.` or `..`. Prefer a simple lowercase/hyphen ID such as the example.
### Get Organization
`GET /api/orgs/{id}` (requires `settings:read`)
@@ -1149,7 +1209,7 @@ Returns all members with their roles. User must be a member of the org.
```json
{ "userId": "jane", "role": "editor" }
```
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. Setting role to `owner` transfers ownership (only current owner can do this). Default org members cannot be managed.
Roles: `owner`, `admin`, `editor`, `viewer`. Admin or owner role required. A new user receives a pending invitation (`202`) and must accept it in Pulse before gaining membership. Posting an existing member's `userId` updates their role; there is no member PATCH endpoint. Setting role to `owner` transfers ownership only to an existing member, by the current owner after fresh sign-in. Default org members cannot be managed.
### Remove Member
`DELETE /api/orgs/{id}/members/{userId}` (requires `settings:write`, session auth only)
@@ -1174,7 +1234,7 @@ Returns resources shared inbound to this organization from other organizations.
"accessRole": "viewer"
}
```
Share a resource with another organization. Valid resource types: `vm`, `container`, `agent`, `storage`, `pbs`, `pmg`. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org.
Share a resource with another organization, using the resource type and ID returned by Pulse. Supported types include `vm`, `system-container`, `agent`, `node`, `docker-host`, `storage`, `pbs` and `pmg`; generic `host` and `container` types are not supported. Access roles: `viewer`, `editor`, `admin`. Admin or owner role required on the source org. The share is pending until a target-org admin or owner accepts it in Pulse; changing its access role requires acceptance again.
### Delete Share
`DELETE /api/orgs/{id}/shares/{shareId}` (requires `settings:write`, session auth only)
+30 -12
View File
@@ -55,18 +55,31 @@ The audit log panel shows events in reverse chronological order with filtering b
### API
Use a token with `audit:read`, bound to a user permitted to read audit logs,
and the licensed `audit_logging` capability. Prepare the private header file
in [API authentication](API.md#-authentication); never paste the token or a
session cookie into a command. For a one-off read, you can instead open the
API path in your signed-in Pulse browser.
The loopback URLs below apply on the Pulse host. For remote access, use your
Pulse HTTPS URL with certificate verification enabled. Use curl 7.76 or later,
keeping `--disable` first to ignore local trace/verbose defaults.
`--fail-with-body` returns a non-zero exit on HTTP failures, including 401,
402 and 403. Share only the relevant redacted error, not the header file or
whole audit response: events can contain usernames, client addresses and paths.
```bash
# List recent events
curl http://localhost:7655/api/audit?limit=50 \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?limit=50'
# Filter by event type and date range
curl "http://localhost:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false" \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
'http://127.0.0.1:7655/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false'
# Get audit summary
curl http://localhost:7655/api/audit/summary \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/summary
```
### Query Parameters
@@ -87,12 +100,17 @@ curl http://localhost:7655/api/audit/summary \
Export the audit log for external analysis or compliance archival:
```bash
curl http://localhost:7655/api/audit/export \
-H "Authorization: Bearer $TOKEN" \
-o audit-export.json
umask 077
export_dir="$(mktemp -d)" &&
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--output "$export_dir/audit-export.json" \
http://127.0.0.1:7655/api/audit/export &&
printf 'Saved private export to %s\n' "$export_dir/audit-export.json"
```
The export includes all events matching the current filter criteria.
This creates a new private directory and prints the file's location only on
success. It does not reuse filters selected in the UI. Treat the export as
sensitive data; keep it outside shared repositories and issue attachments.
---
@@ -101,8 +119,8 @@ The export includes all events matching the current filter criteria.
Every audit event is cryptographically signed at creation time. You can verify that an event has not been modified:
```bash
curl http://localhost:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify
```
Response:
+84 -44
View File
@@ -51,9 +51,30 @@ Each member has a role within an organization:
Organizations can share specific resources with other organizations:
- Share a VM, container, host, or storage resource with another org.
- Share a VM, container, machine agent, storage, PBS, or PMG resource with another org.
- Assign an access role (`viewer`, `editor`, or `admin`) to the share.
- The receiving org sees shared resources alongside their own, with a share badge.
- The receiving org must accept the share before it sees the resource alongside its own.
## Before Using the API
Create organizations and manage members and shares in the signed-in Pulse UI.
These changes require **session-based user authentication** and the relevant
organization role; API tokens are rejected with `403 session_required`, even
when they have `settings:write`. The UI handles the session and CSRF protection.
Do not extract a session cookie or CSRF token into a shell command.
The read-only curl examples use an org-bound token with `settings:read` and
the private header file from [API authentication](API.md#-authentication).
Keep the token out of command lines, URLs and reports. A token's organization
binding is an access boundary: changing a URL or `X-Pulse-Org-ID` header does
not grant it access to another organization.
Use curl 7.76 or later. Keep `--disable` first to ignore local trace/verbose
defaults; `--fail-with-body` makes HTTP failures return a non-zero exit. The
loopback URLs apply on the Pulse host; remotely, use your Pulse HTTPS URL and
keep certificate verification enabled. Replace the example organization ID
`production-datacenter` with your own. Run requests separately, and share only
the relevant redacted error, not whole member or infrastructure responses.
## Managing Organizations
@@ -61,14 +82,16 @@ Organizations can share specific resources with other organizations:
**UI:** Settings → Organization → Create Organization
**API:**
```bash
curl -X POST http://localhost:7655/api/orgs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production Datacenter", "description": "EU production infrastructure"}'
**API contract:** `POST /api/orgs`, session authentication only:
```json
{"id": "production-datacenter", "displayName": "Production Datacenter"}
```
The creator becomes the owner. `id` is a stable ID of 1–64 characters using
letters, digits, periods, underscores or hyphens, but not `.` or `..`; use a
simple lowercase/hyphen ID such as the example. `displayName` is the name
shown in Pulse. `name` and `description` are not the creation fields.
### Switching Organizations
Use the **Org Switcher** dropdown in the header. When you switch:
@@ -81,45 +104,58 @@ Use the **Org Switcher** dropdown in the header. When you switch:
**UI:** Settings → Organization → Access
**API:**
**Read-only API:**
```bash
# List members
curl http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN"
# Add a member
curl -X POST http://localhost:7655/api/orgs/{orgId}/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"userId": "user-id", "role": "editor"}'
# Update role
curl -X PATCH http://localhost:7655/api/orgs/{orgId}/members/{userId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/members
```
**Invite or update a member:** use the Access panel as an owner or admin.
Its API contract is `POST /api/orgs/{id}/members`, session authentication only:
```json
{"userId": "user-id", "role": "editor"}
```
For a new member, the response is `202` with a pending invitation; the user
must accept it in Pulse before gaining access. An existing member's role is
updated by posting their `userId` and new role to the same endpoint, not by
PATCHing a member URL:
```json
{"userId": "user-id", "role": "admin"}
```
Only the current owner can transfer ownership, and only to an existing member
after fresh sign-in. The owner cannot be demoted or removed as an ordinary
member update. Default-organization members cannot be managed here.
### Sharing Resources
**UI:** Settings → Organization → Sharing
**API:**
```bash
# Create a share
curl -X POST http://localhost:7655/api/orgs/{orgId}/shares \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"targetOrgId": "other-org-id",
"resourceType": "host",
"resourceId": "resource-id",
"role": "viewer"
}'
Create the share as an owner or admin of the source organization. An owner or
admin of the target organization then accepts it in **Sharing → Incoming**.
Until acceptance, the share is pending and does not grant access.
# View incoming shares
curl http://localhost:7655/api/orgs/{orgId}/shares/incoming \
-H "Authorization: Bearer $TOKEN"
**API contract:** `POST /api/orgs/{id}/shares`, session authentication only:
```json
{
"targetOrgId": "other-org-id",
"resourceType": "vm",
"resourceId": "vm:101",
"accessRole": "viewer"
}
```
Use `accessRole`, not `role`, and the resource type and ID returned by Pulse,
not a display name. Supported types include `vm`, `system-container`, `agent`,
`node`, `docker-host`, `storage`, `pbs` and `pmg`; the generic types `host` and
`container` are not supported. Changing a share's access role makes it pending
again, so the target must accept the new grant.
**Read-only incoming shares:**
```bash
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/orgs/production-datacenter/shares/incoming
```
## Monitoring Multiple Internal Estates
@@ -158,15 +194,16 @@ When multi-tenant is enabled, **Settings → Organization** shows:
| `GET` | `/api/orgs` | List organizations the current user can access |
| `POST` | `/api/orgs` | Create a new organization |
| `GET` | `/api/orgs/{id}` | Get organization details |
| `PATCH` | `/api/orgs/{id}` | Update organization |
| `PUT` | `/api/orgs/{id}` | Update organization (session only) |
| `DELETE` | `/api/orgs/{id}` | Delete organization |
| `GET` | `/api/orgs/{id}/members` | List members |
| `POST` | `/api/orgs/{id}/members` | Add a member |
| `PATCH` | `/api/orgs/{id}/members/{userId}` | Update member role |
| `POST` | `/api/orgs/{id}/members` | Invite a member or update an existing member's role (session only) |
| `DELETE` | `/api/orgs/{id}/members/{userId}` | Remove a member |
| `POST` | `/api/org-invitations/{id}/accept` | Accept your pending invitation (session only) |
| `GET` | `/api/orgs/{id}/shares` | List outgoing shares |
| `GET` | `/api/orgs/{id}/shares/incoming` | List incoming shares |
| `POST` | `/api/orgs/{id}/shares` | Create a share |
| `POST` | `/api/orgs/{id}/shares` | Create or update a share (session only) |
| `POST` | `/api/orgs/{id}/shares/incoming/{shareId}/accept` | Accept an incoming share (session only) |
| `DELETE` | `/api/orgs/{id}/shares/{shareId}` | Remove a share |
### Tenant Context
@@ -177,6 +214,9 @@ All data-fetching endpoints respect the active organization context. The active
2. Session cookie (browser)
3. Falls back to the `default` organization
Neither the active context nor a token scope overrides organization membership
or a token's organization binding.
## Storage
- The **default** org uses the root data directory (backward compatible).
@@ -202,7 +242,7 @@ Activate an Enterprise license with the `multi_tenant` capability in **Settings
### Shared resources not appearing
1. Verify the share exists: **Settings → Organization → Sharing → Incoming**.
1. Verify the share exists and has been accepted: **Settings → Organization → Sharing → Incoming**.
2. Confirm the share role grants sufficient access.
3. Check that the source org's resources are online.
+149 -70
View File
@@ -8,7 +8,8 @@ Pulse can monitor PBS backups in two ways:
### 1. Direct PBS Connection (Recommended)
Connect directly to your PBS server for full monitoring capabilities:
Connect to the PBS API with a dedicated read-only token. An agent is not
required for these API-backed readings:
**Benefits:**
- ✅ Deduplication factor and storage efficiency stats
@@ -29,73 +30,100 @@ If your PVE cluster has PBS storage configured, Pulse automatically fetches back
- ❌ Can be slow for encrypted PBS storage
- ❌ Limited metadata per backup
**Recommendation:** If you see a banner in the Recovery page (formerly Backups) suggesting you add PBS directly, following this guide will significantly improve your monitoring experience.
**Recommendation:** Start with a direct API connection when you need PBS
datastore, job or server status beyond the PVE passthrough. Add a host agent
only for extra local telemetry such as SMART and temperatures; see
[Agent Security](AGENT_SECURITY.md#proxmox-deployment-choices).
---
## Setting Up Direct PBS Connection
### Method 1: Unified Agent Install (Recommended for Bare Metal)
### Method 1: API-Only Connection (Recommended)
Install the unified agent directly on your PBS server for automatic setup:
Use a dedicated PBS monitoring user and token, not a root password. On a new
setup, run these commands in an administrator shell **on the PBS server**:
```bash
# Run on your PBS server
curl -fsSL http://<pulse-ip>:7655/install.sh | \
sudo bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox --proxmox-type pbs
```
The agent will:
1. Detect it's running on a PBS server
2. Create a `pulse-monitor@pbs` user with read-only access
3. Generate an API token
4. Register the PBS node with Pulse automatically
### Method 2: API-Only Setup Script (Best for PBS in Containers) ⭐
Use this when you can run a command on the PBS host but do not want to install the agent.
From Pulse's Settings page:
1. Go to **Settings → Infrastructure**.
2. Click **Add infrastructure**.
3. Choose **Proxmox Backup Server**.
4. Use the API-only setup path and enter your PBS server's URL.
5. Click copy to get the setup command.
6. Run the command on your PBS server.
Example (what the UI generates):
```bash
curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }
```
Pulse generates that full command for you from **Settings → Infrastructure**, including
the one-time setup token. The script creates a `pulse-monitor@pbs` user,
generates a scoped API token, and registers the server with Pulse.
> **Note**: API-only mode does not include temperature monitoring or AI command execution. Use **Agent Install** for full functionality.
> **Tip**: The installer now auto-detects Proxmox mode (`pve` or `pbs`) when possible, but keeping `--proxmox-type pbs` explicit is recommended for predictable PBS onboarding.
### Method 3: Manual Token Creation
If you prefer manual setup:
```bash
# SSH into your PBS server
# 1. Create a dedicated monitoring user
proxmox-backup-manager user create pulse-monitor@pbs --comment "Pulse monitoring"
# 2. Grant read-only access (Audit role)
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
# 3. Generate an API token (save the output!)
proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'
```
Copy the token value and enter it in Pulse:
- **Token ID:** `pulse-monitor@pbs!pulse-token`
- **Token Value:** The UUID shown after running the command
PBS tokens have their own permissions, limited by their owning user's
permissions. Grant `Audit` to **both the user and the token**. If either already
exists, inspect and reuse it instead of deleting it or rerunning token creation.
Do not disable privilege separation or grant `Admin` to work around a missing
permission.
The token secret is shown once. Save it privately and enter it only in Pulse's
**Token Value** field; do not paste it into a shell command, URL, screenshot or
issue report. Keep any terminal recording containing that output private.
In Pulse:
1. Open **Settings → Infrastructure → Add infrastructure** and choose
**Proxmox Backup Server**.
2. Enter the PBS HTTPS URL, normally `https://pbs.example.com:8007`.
3. Select **API Token** and **Manual Token Setup**.
4. Enter **Token ID** `pulse-monitor@pbs!pulse-token` and its secret in
**Token Value**.
5. Keep certificate verification enabled. For a self-signed certificate, use
an independently verified **SSL Fingerprint**; see the TLS guidance below.
6. Test and save the connection. Check that the expected datastores and backups
are visible, not just that the connection test succeeds.
### Method 2: Optional Host Agent
Use this only when you also need host-local telemetry. The Linux agent normally
runs as root; it is not required to fix API authentication or missing backup
permissions. Review [Agent Security](AGENT_SECURITY.md) first.
Create a separate Pulse token in **API Access**, using the **Agent host** preset
for reporting, configuration reads and agent management. This is a **Pulse**
token, not the **PBS** token used above. Do not enable command execution just
for monitoring.
On the PBS host, enter an administrator root shell before preparing the private
file below. In the editor, save only the Pulse agent token, with no header or
quotes; never put the secret in a command argument:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/pbs-agent-token"
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
vi "$HOME/.config/pulse/pbs-agent-token"
```
Download the agent installer from **your Pulse server's HTTPS address**, then
inspect the saved script before running it. Replace the example Pulse URL in
both commands. Stop if the download fails; run the next command only after a
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
the Pulse server, not the agent.
```bash
curl --fail --silent --show-error \
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
https://pulse.example.com/install.sh
```
```bash
bash "$HOME/.config/pulse/pbs-agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/pbs-agent-token" \
--enable-proxmox --proxmox-type pbs --enable-docker=false
```
Do not bypass certificate checks to fetch or run the installer. Use a trusted
Pulse certificate or a CA file you verified separately. See
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
After installation, check a fresh agent report and the host's
`pulse-agent --version`; installation or hardware capacity alone does not prove
that every CPU, memory or History reading is available. Keep the token file
private and remove the bootstrap copy when no longer needed; do not remove the
installed agent's runtime credential.
---
@@ -133,18 +161,65 @@ If you have multiple PBS servers, add each one separately in Settings. Pulse wil
### "Connection Failed" Error
1. **Check URL:** Ensure the PBS URL is correct (default port is 8007)
- Format: `https://pbs.example.com:8007`
Check from the Pulse host or container's network, if possible. A request from
another machine does not prove that Pulse can reach PBS.
2. **Verify token:** Test authentication:
```bash
curl -sk -H "Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:YOUR_TOKEN" \
https://your-pbs:8007/api2/json/version
```
- **Address/network:** use the PBS HTTPS URL and port `8007`; check DNS, routing
and the firewall before changing credentials.
- **TLS:** keep verification enabled. Use a certificate trusted by the Pulse
runtime, or set its **SSL Fingerprint** after verifying the SHA-256 value
through the PBS console or another already-trusted administrative channel.
Do not accept a fingerprint solely from the failed connection. For example,
on the PBS server you can inspect its public certificate (not its private key):
3. **Network access:** Ensure Pulse can reach PBS on port 8007
```bash
openssl x509 -in /etc/proxmox-backup/proxy.pem -noout -fingerprint -sha256
```
4. **SSL verification:** If using self-signed certificates, disable SSL verification in the node settings
- **Authentication/permissions:** test a datastore request, not just `/version`.
Prepare a private header file on the machine running curl:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/pbs-header"
chmod 600 "$HOME/.config/pulse/pbs-header"
vi "$HOME/.config/pulse/pbs-header"
```
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
for the PBS token being tested:
```text
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
```
Then use curl 7.76 or later, with your PBS hostname:
```bash
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
--header "@$HOME/.config/pulse/pbs-header" \
https://pbs.example.com:8007/api2/json/admin/datastore
```
For curl with a private CA or self-signed certificate, add
`--cacert "$HOME/.config/pulse/pbs-ca.pem"` using a public certificate obtained
and verified through a trusted channel. Its hostname must still match. Do not
use `--insecure` or `-k`.
`401` means authentication was rejected; `403` means the requested access was
refused. Both return a non-zero curl exit while retaining the error body.
Inspect the existing token and both `Audit` grants before replacing anything.
A `200` response listing the expected datastore establishes access to that
list, not successful collection of every backup, job or History graph. An
empty list is not proof that all permissions are correct.
Keep the header file outside shared repositories and diagnostics. Do not share
verbose/trace curl output, full infrastructure responses, token values or
private keys. If help is needed, provide only the HTTP status, relevant redacted
error and which expected reading is missing. Do not clear History or recreate a
working connection to make missing data look resolved.
### Slow Backup Loading
@@ -155,10 +230,12 @@ If you notice slow loading for PBS storage accessed via PVE:
### Duplicate Backups
If you see the same backup twice:
- This shouldn't happen—Pulse deduplicates by VMID and timestamp
- If it does occur, the direct PBS version takes priority
- Check console for debug logs: `localStorage.setItem('debug-pmg', 'true')`
Check the source, datastore, namespace, guest type/ID and backup time on each
entry. Independent PVE installations can reuse a guest ID; matching VMIDs alone
are not proof of a duplicate. Keep both records while checking their origin.
If the same backup remains listed twice, report those redacted distinctions and
whether each entry came from direct PBS or PVE passthrough. Do not delete backups
or change retention to hide a display problem.
---
@@ -169,7 +246,9 @@ In the Recovery view, PBS backups show a data source indicator:
- **"PBS"** badge alone = Direct PBS connection (full data)
- **"PBS via PVE"** = Passthrough via PVE storage (limited data)
Adding your PBS server directly will remove the "via PVE" indicator and unlock full monitoring capabilities.
When the same backup is reconciled across both sources, Pulse prefers the
direct PBS observation. Check the actual source and expected readings after
adding the connection; the presence of a badge alone is not collection proof.
---
+61 -30
View File
@@ -46,42 +46,65 @@ user merely because no local administrator is configured.
## Managing Roles
### Before Using the API
Prefer **Settings → Security → Access Control** for one-off administration.
The API examples require the licensed `rbac` capability and permission to
administer users. With RBAC enabled, role and user administration requires a
full-access (`*`) API token bound to an authorised administrator; a monitoring
token is not enough. Do not widen an agent's token for this job.
If you already use an administration token, prepare its private header file
as described in [API authentication](API.md#-authentication). The examples read
`$HOME/.config/pulse/api-header`; never paste the token or a session cookie into
a command. Keep this file outside shared repositories and diagnostics.
The loopback URLs work on the Pulse host. For remote use, substitute your
Pulse HTTPS URL and keep certificate verification enabled. Use curl 7.76 or
later; `--disable` must remain first to ignore local trace/verbose defaults,
and `--fail-with-body` makes HTTP failures return a non-zero exit. Run each
change separately and check its response before continuing.
### Creating a Role
**UI:** Settings → Security → Access Control → Create Role
Use a new custom role ID. Built-in roles (`admin`, `operator`, `viewer`,
`auditor`) cannot be modified or deleted. These examples use `alert-manager`.
**API:**
```bash
curl -X POST http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "operator",
"name": "Operator",
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request POST \
--data-binary @- http://127.0.0.1:7655/api/admin/roles <<'JSON'
{
"id": "alert-manager",
"name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [
{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}
]
}'
}
JSON
```
### Listing Roles
```bash
curl http://localhost:7655/api/admin/roles \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/roles
```
### Updating a Role
```bash
curl -X PUT http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Operator",
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/roles/alert-manager <<'JSON'
{
"name": "Alert Manager",
"description": "Updated description",
"permissions": [
{"action": "read", "resource": "alerts"},
@@ -89,14 +112,15 @@ curl -X PUT http://localhost:7655/api/admin/roles/operator \
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}
]
}'
}
JSON
```
### Deleting a Role
```bash
curl -X DELETE http://localhost:7655/api/admin/roles/operator \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/roles/alert-manager
```
---
@@ -106,8 +130,8 @@ curl -X DELETE http://localhost:7655/api/admin/roles/operator \
### Listing Users and Their Roles
```bash
curl http://localhost:7655/api/admin/users \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
http://127.0.0.1:7655/api/admin/users
```
SSO users are displayed using the latest configured username claim and email
@@ -118,20 +142,27 @@ principal used for authorization.
Role assignments are set as a complete list — the user's roles are replaced with the provided set:
Use the stable `username` returned by the user list, not the display name or
email. URL-encode it as one path segment (for example, `:` becomes `%3A` for an
SSO principal). `jane` below is an example local username. Create the custom
role before assigning it; do not run the deletion example first.
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": ["operator", "viewer"]}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
{"roleIds": ["alert-manager", "viewer"]}
JSON
```
To remove all custom roles from a user, send an empty list:
To clear a user's entire role assignment, including built-in roles, send an empty list:
```bash
curl -X PUT http://localhost:7655/api/admin/users/jane/roles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"roleIds": []}'
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--header 'Content-Type: application/json' --request PUT \
--data-binary @- http://127.0.0.1:7655/api/admin/users/jane/roles <<'JSON'
{"roleIds": []}
JSON
```
Note: Users cannot modify their own role assignments (self-escalation prevention).
@@ -139,8 +170,8 @@ Note: Users cannot modify their own role assignments (self-escalation prevention
### Removing User Access
```bash
curl -X DELETE http://localhost:7655/api/admin/users/jane \
-H "Authorization: Bearer $TOKEN"
curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \
--request DELETE http://127.0.0.1:7655/api/admin/users/jane
```
This removes the Pulse identity and all role assignments and revokes its active
@@ -9,13 +9,22 @@ If you are upgrading from older releases that used `pulse-sensor-proxy`, see the
## Recommended: Pulse Agent (Proxmox)
The unified agent runs on each Proxmox host and reports temperatures locally with no SSH keys needed.
The unified agent runs on each Proxmox host and reports temperatures locally
with no SSH keys needed. First complete the
[private-file preparation](UNIFIED_AGENT.md#private-file-installation-linux-macos-and-nas)
on that host: protect the Pulse agent token, download the installer from your
Pulse server over verified HTTPS, and inspect it. Then use the same Pulse
address and add the Proxmox collector flag:
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token> --enable-proxmox
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox
```
The Pulse agent token is not a Proxmox API token. Keep the secret out of
command arguments and leave command execution disabled for monitoring.
Notes:
- Install `lm-sensors` on each host (`apt install lm-sensors && sensors-detect --auto`).
- Temperatures appear automatically once the agent reports.
@@ -104,11 +113,25 @@ ssh -i /path/to/key root@node "cat /sys/class/thermal/thermal_zone0/temp"
## Legacy Cleanup (If Upgrading)
If you still have the old sensor proxy installed from prior releases, remove it from each **Proxmox host** (not the Pulse container) with the supported cleanup helper:
If you still have the old sensor proxy installed from prior releases, remove it
from each **Proxmox host** (not the Pulse container) with the supported cleanup
helper. In that host's administrative shell, download it to a private directory:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --local-only
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh
```
Stop if the download fails and inspect the saved script before running it.
Do not pipe a web response into a privileged shell:
```bash
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
--uninstall --purge --local-only
```
`--local-only` avoids cluster SSH entirely; run the command once on every
@@ -122,8 +145,8 @@ remote portion fail after local cleanup completes.
If you also want to remove the old `pulse-monitor@pam` API user and tokens before re-adding the node, include `--remove-proxmox-access`:
```bash
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh | \
sudo bash -s -- --uninstall --purge --remove-proxmox-access --local-only
bash "$HOME/.config/pulse/sensor-proxy-uninstall.sh" \
--uninstall --purge --remove-proxmox-access --local-only
```
Reinstalling or upgrading the Pulse container does **not** remove the sensor proxy from the host — they are separate installations. If you skip this cleanup, the selfheal timer will keep running and may generate recurring `TASK ERROR` entries in the Proxmox task log.
+147 -62
View File
@@ -27,18 +27,25 @@ preventing duplicate root-SSH sensor polling between cluster peers.
## Quick Start
Generate an installation command in the UI:
Choose the host profile and create a monitoring token in the UI:
**Settings → Infrastructure → Install on a host**
Choose a target profile in that screen when you want explicit install flags for Docker, Kubernetes, Proxmox VE, or Proxmox Backup Server.
The generated command is not tied to a single machine. For a Proxmox VE
cluster, one API connection already provides cluster-wide inventory; the agent
is per host, so run the same generated command on each cluster node where you
want agent-provided telemetry (temperatures, SMART, host identity). Each agent
registers itself and attaches to its own cluster member.
Keep the token out of shell commands, history, URLs and screenshots. Use the
private-file installation steps below; the command arguments contain only the
file's path. A Pulse agent token is not a Proxmox or PBS API token. Monitoring
does not require command execution: leave that option off unless you intend
to grant it. See [Agent Security](AGENT_SECURITY.md) before choosing a root
host agent instead of an API connection or the opt-in Linux safe profile.
The same generated command is also the supported v5-to-v6 agent upgrade path.
For a Proxmox VE cluster, one API connection already provides cluster-wide
inventory; the agent is per host. Repeat the private-file installation on each
cluster node where you want agent-provided telemetry (temperatures, SMART, host
identity), using the intended profile. Each agent registers itself and attaches
to its own cluster member.
The same installer is also the supported v5-to-v6 agent upgrade path.
Run it on the host that already has the v5 `pulse-agent` service to replace the
binary and service configuration in place; do not uninstall the old service
first unless you are intentionally removing that host from Pulse.
@@ -51,24 +58,29 @@ agents initiate the connection. Prefer a stable DNS name for the primary URL
so replacing the Pulse host does not require an agent migration.
After importing the configuration on a Pulse server with a different address,
retarget each existing standard Linux agent from that agent machine:
retarget each existing standard Linux agent from that agent machine. Download
and inspect the installer from the **new** Pulse address using the HTTPS
preparation below; retargeting reuses the saved credential, so do not create
another token just for this operation:
```bash
curl -fsSL https://pulse.example.com:7655/install.sh | \
sudo bash -s -- --retarget --url https://pulse.example.com:7655
bash "$HOME/.config/pulse/agent-install.sh" \
--retarget --url https://pulse.example.com
```
The retarget operation recovers the existing token, agent ID, enabled
collectors, and other service options. It does not carry the old endpoint's
TLS bypass, custom CA, or certificate fingerprint to the new address. Supply
`--cacert`, `--server-fingerprint`, or (only on a trusted network)
`--insecure` explicitly when the new endpoint requires it. The script must
come from the new server so it supports the retarget operation. A newly
generated full installation command from **Settings → Infrastructure → Install
on a host** remains the fallback.
`--cacert` or an independently verified `--server-fingerprint` explicitly
when the new agent endpoint requires it. The installer download itself must
use a trusted certificate or a separately verified CA file; an agent
fingerprint option does not verify that earlier download. The script must
come from the new server so it supports retargeting. If retargeting is not
supported on that host, use the private-file installation below with the
intended profile, rather than uninstalling the existing service first.
On Windows, run the full generated PowerShell installation command from the
new Pulse server as Administrator, including the desired collector options.
On Windows, use the PowerShell private-file installation below from the new
Pulse server as Administrator, including the desired collector options.
Do not expect the configuration import itself to make agent-only machines
appear at the new address.
@@ -83,42 +95,96 @@ the agent has reported, and confirm the host-local version with
This is the agent installer served by your Pulse server. It is separate from the
top-level GitHub `install.sh`, which installs or updates the Pulse server itself.
### Linux (systemd)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
### Private-file installation: Linux, macOS and NAS
### macOS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
Use an administrative shell **on the host being monitored**, not the Pulse
server or an unrelated container. Linux/systemd, macOS, Synology and TrueNAS
use the same preparation. TrueNAS SCALE uses systemd; CORE uses rc.d. An API
connection is usually enough for TrueNAS inventory and usage; an agent is
optional for host-local data.
1. In that shell, protect the file before opening the editor. Save only the
Pulse agent token, with no quotes or header. Use your own private directory
and regular files, not shared paths or symlinks:
```bash
umask 077
mkdir -p "$HOME/.config/pulse"
chmod 700 "$HOME/.config/pulse"
touch "$HOME/.config/pulse/agent-token"
chmod 600 "$HOME/.config/pulse/agent-token"
vi "$HOME/.config/pulse/agent-token"
```
2. Replace `https://pulse.example.com` with your Pulse server's HTTPS address
in both the download and installation commands. Download to a file:
```bash
curl --fail --silent --show-error --connect-timeout 10 --max-time 60 \
--output "$HOME/.config/pulse/agent-install.sh" \
https://pulse.example.com/install.sh
```
**Stop if the download fails.** Inspect the saved script before executing
it. Do not bypass certificate verification or pipe an unchecked response
into a privileged shell. For a private CA, add curl's `--cacert` with the
separately verified CA file, and supply the installer's `--cacert` option
for the agent connection as well. Do not substitute GitHub's top-level
`install.sh`: that installs the Pulse server, not the agent.
3. Install with the private token file, adding a profile from
[Installation Options](#installation-options) when needed:
```bash
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
Keep the bootstrap token file private and remove that copy when no longer
needed; do not delete the installed agent's runtime credential. Check a fresh
report in Pulse and `pulse-agent --version` on the host. A started service or
hardware capacity alone does not prove that every requested metric is present.
### Windows (PowerShell, run as Administrator)
Use a **new** setup directory under your own Windows profile; if it already
exists, choose another name. The following preparation grants access only to
your account and SYSTEM. Stop if directory creation or the ACL command fails:
```powershell
irm http://<pulse-ip>:7655/install.ps1 | iex
$setupDir = Join-Path $env:USERPROFILE 'PulseAgentSetup'
New-Item -ItemType Directory -Path $setupDir -ErrorAction Stop | Out-Null
$userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
icacls.exe $setupDir /inheritance:r /grant:r "*${userSid}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F'
if ($LASTEXITCODE -ne 0) { throw 'Could not protect the setup directory' }
$tokenFile = Join-Path $setupDir 'agent-token.txt'
New-Item -ItemType File -Path $tokenFile -ErrorAction Stop | Out-Null
Start-Process -FilePath notepad.exe -ArgumentList "`"$tokenFile`"" -Wait
```
With environment variables:
In Notepad, save only the Pulse agent token, with no quotes or header. Never
paste it into a PowerShell command or assign a literal secret to an environment
variable. Replace the example HTTPS address in both commands below. Download
and inspect the script first; do not use `Invoke-Expression` on a web response:
```powershell
$env:PULSE_URL="http://<pulse-ip>:7655"
$env:PULSE_TOKEN="<api-token>"
irm http://<pulse-ip>:7655/install.ps1 | iex
$installerFile = Join-Path $setupDir 'install.ps1'
Invoke-WebRequest -Uri 'https://pulse.example.com/install.ps1' -OutFile $installerFile -ErrorAction Stop
```
### Synology NAS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
Only after a successful download and inspection, run the saved script. Clear
an old `PULSE_TOKEN` environment value so it cannot override the file:
```powershell
Remove-Item Env:PULSE_TOKEN -ErrorAction SilentlyContinue
& $installerFile -Url 'https://pulse.example.com' -TokenFile $tokenFile
```
### TrueNAS SCALE/CORE
TrueNAS SCALE and TrueNAS CORE are both supported. The installer auto-detects the platform and configures the appropriate service manager (systemd for SCALE, rc.d for CORE).
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
Keep the setup directory private and remove the bootstrap copy when no longer
needed, not the installed service's token. Verify a fresh report and the
installed agent version; the remaining platform trust trade-offs are in
[Agent Security](AGENT_SECURITY.md).
## Features
@@ -550,46 +616,58 @@ operators are comfortable with Proxmox-side guest probing.
## Installation Options
These commands use the protected token file and successfully downloaded,
inspected installer from [Private-file installation](#private-file-installation-linux-macos-and-nas).
Use the same HTTPS address in preparation and installation. The flags below
choose collectors; they do not grant command execution.
### Simple Install (host + Docker auto-detect)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token>
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
### Proxmox VE Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pve
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pve
```
### Proxmox Backup Server Node (explicit profile)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-proxmox --proxmox-type pbs
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-proxmox --proxmox-type pbs
```
### Force Enable Docker (if auto-detection fails)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-docker
```
### Disable Docker (even if detected)
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker=false
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-docker=false
```
### Host + Kubernetes Monitoring
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-kubernetes
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-kubernetes
```
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-host=false --enable-docker
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --enable-host=false --enable-docker
```
### Exclude Specific Disks from Monitoring
@@ -693,9 +771,10 @@ installer path instead of relying on a plain-HTTP first hop.
To disable auto-updates:
```bash
# During installation
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
# After private-file preparation and successful installer inspection
bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token" --disable-auto-update
# Or set environment variable
PULSE_DISABLE_AUTO_UPDATE=true
@@ -718,8 +797,11 @@ See [Centralized Agent Management](CENTRALIZED_MANAGEMENT.md) for supported keys
## Uninstall
Download and inspect the agent installer using the HTTPS preparation above
before running this on the agent host. Uninstallation needs no new token:
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | bash -s -- --uninstall
bash "$HOME/.config/pulse/agent-install.sh" --uninstall
```
This removes:
@@ -805,10 +887,13 @@ Unraid), `/tmp` and `/usr/local/bin` share that filesystem, so both the staged
and installed copy must fit at once.
If the check fails because `/tmp` is on a constrained root, point `TMPDIR` at a
directory on a data volume and re-run the installer:
directory on a data volume and re-run the already downloaded and inspected
agent installer from the private-file preparation above:
```bash
TMPDIR=/share/CACHEDEV1_DATA/tmp bash install.sh --url http://pulse --token <token>
TMPDIR=/share/CACHEDEV1_DATA/tmp bash "$HOME/.config/pulse/agent-install.sh" \
--url https://pulse.example.com \
--token-file "$HOME/.config/pulse/agent-token"
```
(`mktemp` honours `TMPDIR`, so this moves the staging copy off the RAM root.
@@ -93,7 +93,7 @@ describe('NodesAPI — branch coverage (normalizeProxmoxSetupCommandResponse val
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
@@ -149,10 +149,11 @@ describe('NodesAPI — branch coverage (downloadProxmoxSetupScript failure arms)
});
const pveBootstrap = (): ProxmoxSetupCommandResponse => ({
setupToken: 'synthetic-setup-token',
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/base/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
+61 -38
View File
@@ -313,8 +313,7 @@ describe('NodesAPI', () => {
}),
}),
);
expect(result).toEqual({ command: 'curl ...' });
expect(result).not.toHaveProperty('token');
expect(result).toEqual({ command: 'curl ...', token: 'secret-token' });
});
it('supports the PBS proxmox install command contract', async () => {
@@ -332,6 +331,16 @@ describe('NodesAPI', () => {
expect(result.command).toBe('curl pbs ...');
});
it('rejects an agent credential embedded in the copied command', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
command: 'bash install.sh --token synthetic-secret',
token: 'synthetic-secret',
});
await expect(
NodesAPI.getAgentInstallCommand({ type: 'pve', enableProxmox: true }),
).rejects.toThrow('Install credentials must be entered separately from the command');
});
it('rejects blank install commands', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({ command: ' ', token: 'secret-token' });
@@ -342,12 +351,12 @@ describe('NodesAPI', () => {
});
describe('getProxmoxSetupCommand', () => {
it('uses the canonical setup-script-url contract for PVE while keeping raw setup tokens inside the shared client boundary', async () => {
it('uses the canonical PVE setup contract with a separate revealed setup token', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
setupToken: 'setup-token-123',
@@ -374,21 +383,41 @@ describe('NodesAPI', () => {
);
expect(result.type).toBe('pve');
expect(result.host).toBe('https://pve.example:8006');
expect(result.downloadURL).toBe(
'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
);
expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pve');
expect(result.scriptFileName).toBe('pulse-setup-pve.sh');
expect(result.tokenHint).toBe('set…123');
expect(result.expires).toBe(1_900_000_000);
expect(result).not.toHaveProperty('setupToken');
expect(result.setupToken).toMatch(/token-123$/);
});
it.each(['url', 'downloadURL', 'command', 'commandWithEnv', 'commandWithoutEnv'])(
'rejects a setup credential embedded in %s without returning it in the error',
async (field) => {
const secret = 'synthetic-setup-secret';
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'safe command',
setupToken: secret,
tokenHint: 'syn…ret',
expires: 1_900_000_000,
[field]: `unsafe-${secret}`,
});
await expect(
NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example', backupPerms: false }),
).rejects.toThrow('Setup credentials must be entered separately from commands and URLs');
},
);
it('sends the typed connection name so the setup token can carry it into auto-registration', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
setupToken: 'setup-token-123',
@@ -422,8 +451,7 @@ describe('NodesAPI', () => {
type: ' pve ',
host: ' https://pve.example:8006 ',
url: ' https://pulse.example/api/setup-script?type=pve ',
downloadURL:
' https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123 ',
downloadURL: ' https://pulse.example/api/setup-script?type=pve ',
scriptFileName: ' pulse-setup-pve.sh ',
command: ' curl pve ... ',
commandWithEnv: ' curl env pve ... ',
@@ -443,13 +471,14 @@ describe('NodesAPI', () => {
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
commandWithoutEnv: 'curl bare pve ...',
expires: 1_900_000_000,
tokenHint: 'set…123',
setupToken: 'setup-token-123',
});
});
@@ -458,7 +487,7 @@ describe('NodesAPI', () => {
type: 'pbs',
host: 'https://pbs.example:8007',
url: 'https://pulse.example/api/setup-script?type=pbs',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
scriptFileName: 'pulse-setup-pbs.sh',
command: 'curl pbs ...',
setupToken: 'pbs-token-123',
@@ -486,12 +515,10 @@ describe('NodesAPI', () => {
expect(result.command).toBe('curl pbs ...');
expect(result.type).toBe('pbs');
expect(result.host).toBe('https://pbs.example:8007');
expect(result.downloadURL).toBe(
'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
);
expect(result.downloadURL).toBe('https://pulse.example/api/setup-script?type=pbs');
expect(result.scriptFileName).toBe('pulse-setup-pbs.sh');
expect(result.tokenHint).toBe('pbs…123');
expect(result).not.toHaveProperty('setupToken');
expect(result.setupToken).toMatch(/token-123$/);
});
it('falls back to command for commandWithEnv when the backend omits it', async () => {
@@ -499,7 +526,7 @@ describe('NodesAPI', () => {
type: 'pbs',
host: 'https://pbs.example:8007',
url: 'https://pulse.example/api/setup-script?type=pbs',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
scriptFileName: 'pulse-setup-pbs.sh',
command: 'curl pbs ...',
setupToken: 'pbs-token-123',
@@ -521,7 +548,7 @@ describe('NodesAPI', () => {
type: 'pbs',
host: 'https://pbs.example:8007',
url: '',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs&setup_token=pbs-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pbs',
scriptFileName: 'pulse-setup-pbs.sh',
command: 'curl pbs ...',
setupToken: 'pbs-token-123',
@@ -543,7 +570,7 @@ describe('NodesAPI', () => {
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
setupToken: 'setup-token-123',
@@ -565,7 +592,7 @@ describe('NodesAPI', () => {
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
command: 'curl pve ...',
setupToken: 'setup-token-123',
tokenHint: 'set…123',
@@ -586,7 +613,7 @@ describe('NodesAPI', () => {
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/api/setup-script?type=pve',
downloadURL: 'https://pulse.example/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
setupToken: 'setup-token-123',
@@ -616,11 +643,11 @@ describe('NodesAPI', () => {
);
const result = await NodesAPI.downloadProxmoxSetupScript({
setupToken: 'synthetic-setup-token',
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/base/api/setup-script?type=pve',
downloadURL:
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
@@ -629,9 +656,7 @@ describe('NodesAPI', () => {
tokenHint: 'set…123',
});
expect(apiFetch).toHaveBeenCalledWith(
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
);
expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pve');
expect(result).toEqual({
content: '#!/bin/bash\necho pve',
contentType: 'text/x-shellscript; charset=utf-8',
@@ -651,11 +676,11 @@ describe('NodesAPI', () => {
);
const result = await NodesAPI.downloadProxmoxSetupScript({
setupToken: 'synthetic-setup-token',
type: 'pbs',
host: 'pbs.example',
url: 'https://pulse.example/base/api/setup-script?type=pbs',
downloadURL:
'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pbs',
scriptFileName: 'pulse-setup-pbs.sh',
command: 'curl pbs ...',
commandWithEnv: 'curl env pbs ...',
@@ -664,9 +689,7 @@ describe('NodesAPI', () => {
tokenHint: 'pbs…123',
});
expect(apiFetch).toHaveBeenCalledWith(
'https://pulse.example/base/api/setup-script?type=pbs&setup_token=pbs-token-123',
);
expect(apiFetch).toHaveBeenCalledWith('https://pulse.example/base/api/setup-script?type=pbs');
expect(result).toEqual({
content: '#!/bin/bash\necho pbs',
contentType: 'text/x-shellscript; charset=utf-8',
@@ -687,11 +710,11 @@ describe('NodesAPI', () => {
await expect(
NodesAPI.downloadProxmoxSetupScript({
setupToken: 'synthetic-setup-token',
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/base/api/setup-script?type=pve',
downloadURL:
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
@@ -712,11 +735,11 @@ describe('NodesAPI', () => {
await expect(
NodesAPI.downloadProxmoxSetupScript({
setupToken: 'synthetic-setup-token',
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/base/api/setup-script?type=pve',
downloadURL:
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
@@ -740,11 +763,11 @@ describe('NodesAPI', () => {
await expect(
NodesAPI.downloadProxmoxSetupScript({
setupToken: 'synthetic-setup-token',
type: 'pve',
host: 'https://pve.example:8006',
url: 'https://pulse.example/base/api/setup-script?type=pve',
downloadURL:
'https://pulse.example/base/api/setup-script?type=pve&setup_token=setup-token-123',
downloadURL: 'https://pulse.example/base/api/setup-script?type=pve',
scriptFileName: 'pulse-setup-pve.sh',
command: 'curl pve ...',
commandWithEnv: 'curl env pve ...',
+16 -1
View File
@@ -59,6 +59,7 @@ type RawAgentInstallCommandResponse = {
export type AgentInstallCommandResponse = {
command: string;
token?: string;
};
type RawHostAgentInstallTokenResponse = {
@@ -96,6 +97,7 @@ export type ProxmoxSetupCommandResponse = {
commandWithoutEnv?: string;
expires: number;
tokenHint: string;
setupToken: string;
};
export type DownloadedProxmoxSetupScript = {
@@ -112,7 +114,11 @@ const normalizeAgentInstallCommandResponse = (
throw new Error('Invalid agent install command response');
}
return { command };
const token = optionalTrimmedString(response.token);
if (token && command.includes(token)) {
throw new Error('Install credentials must be entered separately from the command');
}
return { command, ...(token ? { token } : {}) };
};
const normalizeProxmoxSetupCommandResponse = (
@@ -156,6 +162,14 @@ const normalizeProxmoxSetupCommandResponse = (
if (!setupToken) {
throw new Error('Invalid Proxmox setup response setup token');
}
if (
[url, downloadURL, command, commandWithEnv, commandWithoutEnv ?? ''].some((value) =>
value.includes(setupToken),
) ||
/[?&]setup_token=/.test(downloadURL)
) {
throw new Error('Setup credentials must be entered separately from commands and URLs');
}
if (!tokenHint) {
throw new Error('Invalid Proxmox setup response token hint');
}
@@ -174,6 +188,7 @@ const normalizeProxmoxSetupCommandResponse = (
commandWithoutEnv,
expires,
tokenHint,
setupToken,
};
};
@@ -161,7 +161,7 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
describe('ResourceDetailDrawer change history section', () => {
it('keeps current readings separate from stored history samples', () => {
expect(guestDrawerHistorySource).toContain('currentMetrics');
expect(guestDrawerHistorySource).toContain('Collecting history');
expect(guestDrawerHistorySource).toContain('No stored history in this range');
expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints');
});
@@ -241,8 +241,8 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
</span>
</label>
<p class="text-[11px] text-muted italic">
No token fields are needed here. The node appears in Pulse automatically after
the agent starts.
Run the command first, then paste the separately revealed token at its silent
prompt. The node appears in Pulse after the agent starts.
</p>
</div>
</Show>
@@ -286,7 +286,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
await state.copyQuickSetupCommand(
'pve',
true,
'Command copied to clipboard! Run it on the server. The one-time setup token is already embedded.',
'Command copied to clipboard! Run it on the server. Paste the separate token only at the silent prompt.',
);
}}
class="absolute top-2 right-2 p-1.5 text-slate-400 hover:text-slate-200 bg-surface-hover rounded-md transition-colors"
@@ -318,10 +318,11 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
}
>
<div class="pr-10 font-sans text-left">
<p class="font-medium text-base-content">Credentialed command ready</p>
<p class="font-medium text-base-content">Credential-free command ready</p>
<p class="mt-1 text-xs leading-5 text-muted">
Use Copy command to place the runnable command on your clipboard. The
one-time setup token is intentionally not shown on this page.
one-time setup token is shown separately. Run the command first, then
paste the token only at its silent prompt, never into a shell command.
</p>
</div>
</Show>
@@ -424,7 +425,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
</li>
</ul>
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
Fully automatic: no manual token copying needed.
The monitoring API token is registered automatically.
</p>
</div>
</div>
@@ -728,8 +729,8 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
</span>
</label>
<p class="text-xs text-muted">
No token fields are needed here. The server appears in Pulse automatically after
the agent connects.
Run the command first, then paste the separately revealed token at its silent
prompt. The server appears in Pulse after the agent connects.
</p>
</div>
</Show>
@@ -757,7 +758,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
await state.copyQuickSetupCommand(
'pbs',
false,
'Command copied to clipboard! Run it on the server. The one-time setup token is already embedded.',
'Command copied to clipboard! Run it on the server. Paste the separate token only at the silent prompt.',
);
}}
class="absolute top-2 right-2 p-1.5 text-slate-400 hover:text-slate-200 bg-surface-hover rounded-md transition-colors"
@@ -790,10 +791,11 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
}
>
<div class="pr-10 font-sans text-left">
<p class="font-medium text-base-content">Credentialed command ready</p>
<p class="font-medium text-base-content">Credential-free command ready</p>
<p class="mt-1 text-xs leading-5 text-muted">
Use Copy command to place the runnable command on your clipboard. The
one-time setup token is intentionally not shown on this page.
one-time setup token is shown separately. Run the command first, then
paste the token only at its silent prompt, never into a shell command.
</p>
</div>
</Show>
@@ -895,7 +897,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
</li>
</ul>
<p class="text-xs text-green-600 dark:text-green-400 mt-2 font-semibold">
Fully automatic: no manual token copying needed.
The monitoring API token is registered automatically.
</p>
</div>
</div>
@@ -90,13 +90,11 @@ describe('NodeModalSetupGuideSection', () => {
quickSetupTokenHint: 'set…123',
});
expect(screen.getByText('Credentialed command ready')).toBeInTheDocument();
expect(screen.getByText('Credential-free command ready')).toBeInTheDocument();
expect(
screen.getByText(/Use Copy command to place the runnable command on your clipboard/i),
).toBeInTheDocument();
expect(
screen.getByText(/one-time setup token is intentionally not shown/i),
).toBeInTheDocument();
expect(screen.getByText(/one-time setup token is shown separately/i)).toBeInTheDocument();
expect(screen.queryByText(/curl -fsSL/i)).not.toBeInTheDocument();
expect(screen.getByText('set…123')).toBeInTheDocument();
});
@@ -1813,11 +1813,10 @@ describe('settings architecture guardrails', () => {
);
expect(nodeModalSetupGuideSectionSource).toContain('setupCommandButtonTitle');
expect(nodeModalSetupGuideSectionSource).toContain('disabled={setupHandoffDisabled()}');
expect(nodeModalSetupGuideSectionSource).toContain('Credentialed command ready');
expect(nodeModalSetupGuideSectionSource).toContain(
'one-time setup token is intentionally not shown on this page',
);
expect(nodeModalStateSource).toContain('data.commandWithEnv');
expect(nodeModalSetupGuideSectionSource).toContain('Credential-free command ready');
expect(nodeModalSetupGuideSectionSource).toContain('one-time setup token is shown separately');
expect(nodeModalStateSource).toContain('copyToClipboard(data.command)');
expect(nodeModalStateSource).toContain('showSetupToken(data)');
expect(nodeModalStateSource).not.toContain('quickSetupPreviewCommand');
expect(nodeModalStatusFooterSource).toContain('guidedSetupOnlyMode');
expect(nodeModalStatusFooterSource).toContain('props.saveDisabled');
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { NodeConfig } from '@/types/nodes';
import { notificationStore } from '@/stores/notifications';
@@ -6,6 +6,7 @@ import { NodesAPI } from '@/api/nodes';
import type { ProxmoxSetupCommandResponse } from '@/api/nodes';
import { copyToClipboard } from '@/utils/clipboard';
import { logger } from '@/utils/logger';
import { showTokenReveal } from '@/stores/tokenReveal';
import {
buildNodeModalMonitoringPayload,
getNodeModalDefaultFormData,
@@ -71,6 +72,14 @@ export const useNodeModalState = (props: NodeModalProps) => {
setQuickSetupExpiry(null);
};
let bootstrapGeneration = 0;
const bootstrapActive = (generation: number) =>
generation === bootstrapGeneration && props.isOpen !== false;
onCleanup(() => {
bootstrapGeneration++;
clearQuickSetupState();
});
const copyCommand = async (command: string, successMessage = 'Command copied!') => {
if (await copyToClipboard(command)) {
notificationStore.success(successMessage);
@@ -78,6 +87,7 @@ export const useNodeModalState = (props: NodeModalProps) => {
};
const copyProxmoxAgentInstallCommand = async (type: 'pve' | 'pbs', successMessage: string) => {
const generation = bootstrapGeneration;
try {
setLoadingAgentCommand(true);
setAgentCommandError(null);
@@ -87,7 +97,15 @@ export const useNodeModalState = (props: NodeModalProps) => {
enableCommands: false,
insecure: agentInstallInsecure(),
});
if (!bootstrapActive(generation)) return;
setAgentInstallCommand(data.command);
if (data.token) {
showTokenReveal({
token: data.token,
source: type,
note: 'Run the copied install command first. Then copy this token and paste it only at the silent “Pulse agent token” prompt. It is not part of the command.',
});
}
const copied = await copyToClipboard(data.command);
if (copied) {
notificationStore.success(successMessage);
@@ -98,12 +116,13 @@ export const useNodeModalState = (props: NodeModalProps) => {
setAgentCommandError(copyFailureMessage);
notificationStore.error(copyFailureMessage);
} catch (error) {
if (!bootstrapActive(generation)) return;
logger.error('[Host Telemetry Agent] Error:', error);
const message = error instanceof Error ? error.message : 'Failed to generate install command';
setAgentCommandError(message);
notificationStore.error(message);
} finally {
setLoadingAgentCommand(false);
if (bootstrapActive(generation)) setLoadingAgentCommand(false);
}
};
@@ -113,7 +132,8 @@ export const useNodeModalState = (props: NodeModalProps) => {
const loadQuickSetupBootstrap = async (
type: 'pve' | 'pbs',
backupPerms: boolean,
): Promise<ProxmoxSetupCommandResponse> => {
): Promise<ProxmoxSetupCommandResponse | null> => {
const generation = bootstrapGeneration;
const host = formData().host?.trim() ?? '';
if (!host) {
notificationStore.error('Please enter the Endpoint URL first');
@@ -135,6 +155,8 @@ export const useNodeModalState = (props: NodeModalProps) => {
backupPerms,
name: formData().name?.trim() || undefined,
});
if (!bootstrapActive(generation) || quickSetupCacheKey(type, backupPerms) !== cacheKey)
return null;
setQuickSetupBootstrap({ cacheKey, response });
setQuickSetupTokenHint(response.tokenHint);
setQuickSetupExpiry(response.expires);
@@ -146,18 +168,22 @@ export const useNodeModalState = (props: NodeModalProps) => {
backupPerms: boolean,
successMessage: string,
) => {
const generation = bootstrapGeneration;
logger.debug('[Quick Setup] Copy button clicked');
try {
logger.debug('[Quick Setup] Generating setup URL for host', {
host: formData().host,
});
const data = await loadQuickSetupBootstrap(type, backupPerms);
if (await copyToClipboard(data.commandWithEnv)) {
if (!data || !bootstrapActive(generation)) return;
showSetupToken(data);
if (await copyToClipboard(data.command)) {
notificationStore.success(successMessage);
return;
}
throw new Error('Failed to copy to clipboard');
} catch (error) {
if (!bootstrapActive(generation)) return;
logger.error('[Quick Setup] Error:', error);
clearQuickSetupState();
if (!(error instanceof Error && error.message === PROXMOX_SETUP_HOST_REQUIRED_MESSAGE)) {
@@ -166,12 +192,24 @@ export const useNodeModalState = (props: NodeModalProps) => {
}
};
const showSetupToken = (bootstrap: ProxmoxSetupCommandResponse) => {
showTokenReveal({
token: bootstrap.setupToken,
source: bootstrap.type,
note: `Run the copied setup command (or downloaded script) first. Then copy this token and paste it only at the silent “Pulse setup token” prompt. It expires at ${new Date(bootstrap.expires * 1000).toLocaleTimeString()}. Never paste it into a shell command.`,
});
};
const setupScriptRunHint = (fileName: string) => `bash ${fileName}`;
const downloadProxmoxSetupScript = async (type: 'pve' | 'pbs', backupPerms = false) => {
const generation = bootstrapGeneration;
try {
const bootstrap = await loadQuickSetupBootstrap(type, backupPerms);
if (!bootstrap || !bootstrapActive(generation)) return;
const data = await NodesAPI.downloadProxmoxSetupScript(bootstrap);
if (!bootstrapActive(generation)) return;
showSetupToken(bootstrap);
const blob = new Blob([data.content], {
type: data.contentType,
@@ -189,6 +227,7 @@ export const useNodeModalState = (props: NodeModalProps) => {
`Script downloaded! Upload it to your server and run: ${setupScriptRunHint(data.fileName)}`,
);
} catch (error) {
if (!bootstrapActive(generation)) return;
logger.error('Failed to download script:', error);
notificationStore.error('Failed to download script. Please check your connection.');
}
@@ -215,6 +254,12 @@ export const useNodeModalState = (props: NodeModalProps) => {
const editingNode = props.editingNode;
const prefillNode = props.prefillNode;
if (isOpen === false || key !== previousResetKey || nodeType !== previousNodeType) {
bootstrapGeneration++;
clearQuickSetupState();
setLoadingAgentCommand(false);
}
if (key !== undefined && key !== previousResetKey) {
previousResetKey = key;
setFormData(() => getNodeModalDefaultFormData(props.nodeType));
@@ -556,8 +556,8 @@ describe('GuestDrawer', () => {
await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalled());
const utilizationChart = screen.getAllByTestId('guest-history-group-chart')[0];
expect(utilizationChart).toHaveTextContent('CPU25.0%');
expect(utilizationChart).toHaveTextContent('Collecting history');
expect(utilizationChart).toHaveTextContent('CPU25.0%current');
expect(utilizationChart).toHaveTextContent('No stored history in this range');
expect(utilizationChart.querySelector('path')).toBeNull();
});
@@ -274,9 +274,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
});
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1));
const singleObservation = createMemo(() =>
observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null,
);
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
const bounds = () => props.timeBounds;
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
const hasStoredData = createMemo(() => observationTimes().length > 0 && bounds() !== null);
const hoveredSeries = createMemo(() => {
const timestamp = activeTimestamp();
const rangeBounds = bounds();
@@ -309,6 +313,11 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
const currentValue = props.currentMetrics?.[item.metric];
return {
...item,
isCurrent:
activeTimestamp() === null &&
item.points.length === 0 &&
typeof currentValue === 'number' &&
Number.isFinite(currentValue),
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: activeTimestamp() !== null
@@ -369,12 +378,18 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<div class="flex flex-wrap justify-end gap-x-3 gap-y-1 text-[11px] text-muted">
<For each={displaySeries()}>
{(item) => (
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1"
data-history-current={item.isCurrent ? item.metric : undefined}
>
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" fill={item.color} />
</svg>
<span class="font-medium text-base-content">{item.label}</span>
<span>{item.valueLabel}</span>
<Show when={item.isCurrent}>
<span>current</span>
</Show>
</span>
)}
</For>
@@ -421,26 +436,48 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
<Show when={bounds()}>
{(rangeBounds) => (
<For each={drawableSeries()}>
{(item) => (
<path
d={buildGuestDrawerHistoryPath(
item.points,
scale(),
rangeBounds().startTime,
rangeBounds().endTime,
GUEST_DRAWER_HISTORY_CHART_WIDTH,
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
)}
fill="none"
stroke={item.color}
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
)}
</For>
<>
<For each={drawableSeries()}>
{(item) => (
<path
d={buildGuestDrawerHistoryPath(
item.points,
scale(),
rangeBounds().startTime,
rangeBounds().endTime,
GUEST_DRAWER_HISTORY_CHART_WIDTH,
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
)}
fill="none"
stroke={item.color}
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
)}
</For>
<For each={singlePointSeries()}>
{(item) => (
<circle
aria-hidden="true"
data-history-observation={item.metric}
cx={getGuestDrawerHistoryX(
item.points[0].timestamp,
rangeBounds().startTime,
rangeBounds().endTime,
)}
cy={getGuestDrawerHistoryY(item.points[0].value, scale())}
r="3.5"
fill={item.color}
stroke="currentColor"
stroke-width="1"
class="text-surface"
vector-effect="non-scaling-stroke"
/>
)}
</For>
</>
)}
</Show>
<Show when={hoverX() !== null && hoveredSeries().length > 0}>
@@ -471,9 +508,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
</Show>
</svg>
<Show when={!hasDrawableData() && !props.loading}>
<Show when={!hasStoredData() && !props.loading}>
<div class="absolute inset-x-8 inset-y-2 flex items-center justify-center rounded-sm bg-surface/80 text-xs text-muted">
Collecting history
No stored history in this range
</div>
</Show>
<Show when={props.loading}>
@@ -515,6 +552,19 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<p id={`${inspectionId}-description`} class="sr-only">
{chartDescription()}
</p>
<Show when={!props.loading && singleObservation()}>
{(observation) => (
<p class="mt-2 text-[10px] text-muted">
<span>Single observation. No trend yet.</span>
<time
class="block tabular-nums"
dateTime={new Date(observation().timestamp).toISOString()}
>
{new Date(observation().timestamp).toLocaleString()}
</time>
</p>
)}
</Show>
<Show when={observationTimes().length > 1}>
<div class="mt-2">
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">
@@ -57,7 +57,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
await screen.findByText('Failed to load history data');
const retry = screen.getByRole('button', { name: 'Retry history' });
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
expect(container).not.toHaveTextContent('private diagnostic detail');
expect(paths(container)).toHaveLength(0);
@@ -204,7 +204,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(screen.getByText('Failed to load history data')).toBeInTheDocument();
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
expect(paths(container)).toHaveLength(0);
},
);
@@ -0,0 +1,211 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 7,
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
const start = Date.UTC(2026, 9, 1, 11);
const time = start + 30 * 60_000;
const end = start + 60 * 60_000;
const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' };
const point = (timestamp = time, value = 0) => ({ timestamp, value, min: value, max: value });
const response = (
metrics: AllMetricsHistoryResponse['metrics'] = {},
): AllMetricsHistoryResponse => ({
...target,
range: '1h',
start,
end,
source: 'store',
metrics,
});
const mount = (currentMetrics: Record<string, number | undefined> = {}) =>
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
groups={HOST_METRICS_HISTORY_GROUPS}
currentMetrics={currentMetrics}
/>
));
const group = (id = 'utilization') =>
screen
.getAllByTestId('guest-history-group-chart')
.find((chart) => chart.dataset.historyGroup === id)!;
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
it.each([
['cpu', 'utilization', 'CPU 0.0%'],
['netin', 'network', 'In 0 B/s'],
['temperature', 'thermals', 'CPU 0°C'],
])(
'plots a lone measured zero for %s, without a synthetic trend or collecting claim',
async (metric, id, label) => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValue(response({ [metric]: [point()] }));
mount({ [metric]: 99 });
await waitFor(() => expect(dots()).toHaveLength(1));
const chart = group(id);
const dot = chart.querySelector(`[data-history-observation="${metric}"]`)!;
expect(Number(dot.getAttribute('cx'))).toBeCloseTo(193);
expect(Number(dot.getAttribute('cy'))).toBeCloseTo(74);
expect(chart.querySelector('path')).toBeNull();
expect(chart.querySelector('[data-history-current]')).toBeNull();
expect(chart).not.toHaveTextContent('No stored history in this range');
expect(chart).toHaveTextContent('Single observation. No trend yet.');
expect(chart.querySelector('time.block')).toHaveAttribute(
'datetime',
new Date(time).toISOString(),
);
expect(within(chart).getByRole('img')).toHaveAccessibleDescription(
new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(fetch).toHaveBeenCalledTimes(1);
},
);
it('draws a lone series alongside a trend and labels only the live fallback', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(start, 10), point(end, 20)],
memory: [point(time, 0)],
}),
);
mount({ cpu: 99, memory: 98, disk: 88 });
await waitFor(() => expect(dots()).toHaveLength(1));
expect(group().querySelectorAll('path')).toHaveLength(1);
expect(group().querySelector('[data-history-observation]')).toHaveAttribute(
'data-history-observation',
'memory',
);
expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1);
expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent(
'Disk88.0%current',
);
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
const slider = within(group()).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '1' } });
expect(group()).toHaveTextContent('CPU-');
expect(group()).toHaveTextContent('Memory0.0%');
expect(group()).toHaveTextContent('Disk-');
expect(group().querySelector('[data-history-current]')).toBeNull();
expect(dots()).toHaveLength(1);
});
it('does not connect single observations from different series or borrow their times', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(start, 10)],
memory: [point(end, 20)],
}),
);
mount();
await waitFor(() => expect(dots()).toHaveLength(2));
expect(group().querySelector('path')).toBeNull();
expect(group().querySelector('[data-history-observation="cpu"]')).toHaveAttribute('cx', '34');
expect(group().querySelector('[data-history-observation="memory"]')).toHaveAttribute(
'cx',
'352',
);
const slider = within(group()).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(slider).toHaveAttribute(
'aria-valuetext',
`${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk no observation.`,
);
});
it.each(['empty', 'invalid'] as const)(
'reports a successful %s read honestly and never plots current readings',
async (kind) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response(
kind === 'empty'
? {}
: {
cpu: [point(NaN, 12)],
memory: [point(time, Infinity)],
},
),
);
mount({ cpu: 0, memory: Infinity, disk: undefined });
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(4),
);
expect(group().querySelector('[data-history-current="cpu"]')).toHaveTextContent(
'CPU0.0%current',
);
expect(document.querySelectorAll('[data-history-current]')).toHaveLength(1);
expect(dots()).toHaveLength(0);
expect(document.querySelectorAll('path')).toHaveLength(0);
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
},
);
it('retains a lone observation after failed refresh and removes its marker and caption when a trend arrives', async () => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
.mockRejectedValueOnce(new Error('private diagnostic'))
.mockResolvedValueOnce(response({ cpu: [point(time, 0), point(end, 5)] }));
mount();
await waitFor(() => expect(dots()).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await screen.findByText('History refresh failed. Showing previously loaded history.');
expect(dots()).toHaveLength(1);
expect(group()).toHaveTextContent('Single observation. No trend yet.');
expect(document.body).not.toHaveTextContent('private diagnostic');
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
await screen.findByRole('slider');
expect(group().querySelectorAll('path')).toHaveLength(1);
expect(dots()).toHaveLength(0);
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
expect(fetch).toHaveBeenCalledTimes(3);
});
it.each(['target', 'range'] as const)(
'clears former observations and captions during an uncached %s replacement',
async (replacement) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
.mockImplementationOnce(() => new Promise(() => {}));
const [current, setTarget] = createSignal(target);
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
render(() => (
<GuestDrawerHistory
target={current()}
range={range()}
groups={HOST_METRICS_HISTORY_GROUPS}
/>
));
await waitFor(() => expect(dots()).toHaveLength(1));
if (replacement === 'target') setTarget({ ...target, resourceId: 'other-host' });
else setRange('6h');
await waitFor(() => expect(screen.getAllByText('Loading history')).toHaveLength(4));
expect(dots()).toHaveLength(0);
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
expect(document.querySelectorAll('time')).toHaveLength(0);
},
);
});
@@ -177,7 +177,9 @@ describe('GuestDrawerHistory shared time window', () => {
it('keeps an empty valid query window without inventing observations or a trend', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} }));
mount();
await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(4),
);
expect(screen.queryByRole('slider')).toBeNull();
expect(document.querySelectorAll('path')).toHaveLength(0);
expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4);
@@ -63,11 +63,17 @@ export function TableBody(props: TableBodyProps) {
export type TableRowProps = JSX.HTMLAttributes<HTMLTableRowElement>;
// WebKit touch needs a native click target on otherwise-static table rows.
// Keep actions document-delegated: nested controls must run first and retain
// their existing stopPropagation behaviour instead of opening the row too.
const nativeRowClickTarget = () => undefined;
export function TableRow(props: TableRowProps) {
const [local, rest] = splitProps(props, ['class', 'children']);
return (
<tr
class={`group transition-colors duration-150 hover:bg-surface-hover ${local.class || ''}`}
on:click={rest.onClick ? nativeRowClickTarget : undefined}
{...rest}
>
{local.children}
@@ -103,6 +103,23 @@ describe('PulseDataGrid', () => {
expect(onRowClick).toHaveBeenCalledTimes(1);
});
it('inherits the native touch click target from the shared TableRow', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
try {
render(() => (
<PulseDataGrid<TestRow>
data={[{ id: '1', name: 'Touch tower' }]}
columns={[{ key: 'name', label: 'Name' }]}
keyExtractor={(row) => row.id}
onRowClick={vi.fn()}
/>
));
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
} finally {
nativeListener.mockRestore();
}
});
it('does not trigger the row handler when an interactive child is clicked', () => {
const onRowClick = vi.fn();
const onRemove = vi.fn();
@@ -1,5 +1,6 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import tableSource from '@/components/shared/Table.tsx?raw';
import {
@@ -11,6 +12,127 @@ import {
TableRow,
} from '@/components/shared/Table';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('TableRow touch activation', () => {
it('marks clickable rows as native click targets without changing row semantics', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
const onClick = vi.fn();
render(() => (
<Table>
<TableBody>
<TableRow onClick={onClick}>
<TableCell>Tap resource</TableCell>
</TableRow>
</TableBody>
</Table>
));
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
const row = screen.getByRole('row');
expect(row).not.toHaveAttribute('tabindex');
expect(row).not.toHaveAttribute('aria-expanded');
fireEvent.click(screen.getByText('Tap resource'));
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(row, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
});
it('does not make static rows native click targets', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
render(() => (
<Table>
<TableBody>
<TableRow>
<TableCell>Static resource</TableCell>
</TableRow>
</TableBody>
</Table>
));
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(false);
});
it('lets delegated child actions stop the row before its action runs', () => {
const onClick = vi.fn();
const onChild = vi.fn();
render(() => (
<Table>
<TableBody>
<TableRow onClick={onClick}>
<TableCell>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onChild();
}}
>
Child action
</button>
</TableCell>
</TableRow>
</TableBody>
</Table>
));
fireEvent.click(screen.getByRole('button', { name: 'Child action' }));
expect(onChild).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});
it('removes the native target when the row action is withdrawn', () => {
const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
const onClick = vi.fn();
const [enabled, setEnabled] = createSignal(true);
render(() => (
<Table>
<TableBody>
<TableRow onClick={enabled() ? onClick : undefined}>
<TableCell>Changing resource</TableCell>
</TableRow>
</TableBody>
</Table>
));
fireEvent.click(screen.getByText('Changing resource'));
setEnabled(false);
expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
fireEvent.click(screen.getByText('Changing resource'));
expect(onClick).toHaveBeenCalledTimes(1);
setEnabled(true);
fireEvent.click(screen.getByText('Changing resource'));
expect(onClick).toHaveBeenCalledTimes(2);
});
it('preserves bound delegated handlers and caller-owned native handlers', () => {
const onClick = vi.fn();
const onNative = vi.fn();
render(() => (
<Table>
<TableBody>
<TableRow
on:click={(event) => {
event.stopPropagation();
onNative();
}}
>
<TableCell>Native action</TableCell>
</TableRow>
<TableRow onClick={[onClick, 'second-identity']}>
<TableCell>Bound action</TableCell>
</TableRow>
</TableBody>
</Table>
));
fireEvent.click(screen.getByText('Native action'));
expect(onNative).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Bound action'));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick.mock.calls[0][0]).toBe('second-identity');
});
});
describe('TableBody', () => {
it('keeps the shared table wrapper CSP-safe', () => {
expect(tableSource).toContain('touch-scroll');
@@ -48,12 +48,24 @@ describe('tokenRevealStore', () => {
const state = tokenRevealStore.state();
expect(state).not.toBeNull();
expect(state!.token).toBe('secret-token');
expect(state!.record.id).toBe('token-1');
expect(state!.record!.id).toBe('token-1');
expect(state!.source).toBe('settings');
expect(state!.note).toBe('Test note');
expect(state!.issuedAt).toBeDefined();
});
it('reveals a temporary setup token without inventing an API token record', () => {
showTokenReveal({
token: 'synthetic-setup-token',
source: 'pve',
note: 'Private input only',
});
expect(tokenRevealStore.state()?.token).toBe('synthetic-setup-token');
expect(tokenRevealStore.state()?.record).toBeUndefined();
dismissTokenReveal();
expect(tokenRevealStore.state()).toBeNull();
});
it('sets issuedAt to current timestamp', () => {
const before = Date.now();
showTokenReveal({
+1 -1
View File
@@ -3,7 +3,7 @@ import type { APITokenRecord } from '@/api/security';
export interface TokenRevealPayload {
token: string;
record: APITokenRecord;
record?: APITokenRecord;
source?: string;
note?: string;
}
@@ -223,6 +223,17 @@ describe('docsLinks', () => {
expect(apiReference).not.toContain('Mobile Remote Access');
});
it('ships credential-free Proxmox setup instructions and a separate token prompt', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shipped = readFileSync(path.join(frontendRoot, 'public', 'docs', 'API.md'), 'utf8');
const setup = apiReference.split('### Setup Script URL')[1].split('### Auto-Register')[0];
expect(shipped).toBe(apiReference);
expect(setup).toContain('identical credential-free commands');
expect(setup).toContain('`downloadURL` equals the tokenless `url`');
expect(setup).toContain('paste the token only at its prompt');
expect(setup).not.toContain('token-bearing `commandWithEnv`');
});
it('ships the per-alert snooze and resume API contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
+39 -31
View File
@@ -2150,7 +2150,7 @@ smoke_test_pve_auto_register_token() {
local smoke_status=0
set +e
smoke_output=$(curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes" 2>&1)
smoke_output=$(printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes" 2>&1)
smoke_status=$?
set -e
@@ -2252,10 +2252,12 @@ print(json.dumps({"type": "pve", "host": host, "backupPerms": backup}))
PY
)
echo "$setup_payload" > /tmp/pulse-auto-register-request.json 2>/dev/null || true
# Bootstrap request/response are not retained in shared /tmp diagnostics.
local pulse_url="http://${pulse_ip}:${pulse_port}"
set +xv
local setup_response
if ! setup_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"); then
AUTO_NODE_REGISTER_ERROR="setup token request failed"
@@ -2263,8 +2265,7 @@ PY
return
fi
# Persist for debugging when running interactively
echo "$setup_response" > /tmp/pulse-auto-register-response.json 2>/dev/null || true
# The response contains a one-time credential; do not persist it as a diagnostic.
local setup_token
local setup_type
@@ -2278,17 +2279,18 @@ PY
local setup_token_hint
local setup_expires
local setup_expiry_state
IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$setup_response" "$pulse_url" "$normalized_host_url" <<'PY'
IFS=$'\t' read -r setup_token setup_type setup_host setup_url setup_download_url setup_script_name setup_command setup_command_with_env setup_command_without_env setup_token_hint setup_expires setup_expiry_state <<<"$(python3 - "$pulse_url" "$normalized_host_url" "$backup_perms" 3<<<"$setup_response" <<'PY'
import json, sys
import time
from urllib.parse import quote
try:
data = json.loads(sys.argv[1])
data = json.load(__import__("os").fdopen(3))
except Exception:
print("\t\t\t\t\t\t\t\t\t\t")
sys.exit(0)
pulse_url = sys.argv[2]
host = sys.argv[3]
pulse_url = sys.argv[1]
host = sys.argv[2]
backup_query = "backup_perms=true&" if sys.argv[3] == "true" else ""
expires_raw = data.get("expires", "")
expiry_state = ""
try:
@@ -2305,15 +2307,14 @@ setup_script_name = str(data.get("scriptFileName", ""))
setup_command = str(data.get("command", ""))
setup_command_with_env = str(data.get("commandWithEnv", ""))
setup_command_without_env = str(data.get("commandWithoutEnv", ""))
expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
if setup_token:
expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"
else:
expected_download_url = ""
expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"
expected_download_url = expected_setup_url
legacy_download_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve" if setup_token else ""
modern = setup_download_url == expected_download_url
expected_script_name = "pulse-setup-pve.sh"
if setup_url != expected_setup_url:
setup_url = ""
if setup_download_url != expected_download_url:
if setup_download_url not in (expected_download_url, legacy_download_url):
setup_download_url = ""
if setup_script_name != expected_script_name:
setup_script_name = ""
@@ -2345,15 +2346,17 @@ for _field_name, _value, _requires_token in command_fields:
else:
setup_command_without_env = ""
continue
if _requires_token:
if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:
if _field_name == "command":
setup_command = ""
else:
setup_command_with_env = ""
continue
elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:
setup_command_without_env = ""
if modern:
valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value
else:
valid = "PULSE_SETUP_TOKEN_FILE=" not in _value and (("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value))
if not valid:
if _field_name == "command":
setup_command = ""
elif _field_name == "commandWithEnv":
setup_command_with_env = ""
else:
setup_command_without_env = ""
if not token_hint or token_hint == setup_token:
token_hint = ""
print("\t".join([
@@ -2373,7 +2376,9 @@ print("\t".join([
PY
)" || setup_token=""
local expected_setup_url="${pulse_url}/api/setup-script?host=$(python3 - <<'PY' "$normalized_host_url"
local backup_query=""
[[ "$backup_perms" == "true" ]] && backup_query="backup_perms=true&"
local expected_setup_url="${pulse_url}/api/setup-script?${backup_query}host=$(python3 - <<'PY' "$normalized_host_url"
from urllib.parse import quote
import sys
print(quote(sys.argv[1], safe=''))
@@ -2384,16 +2389,18 @@ import sys
print(quote(sys.argv[1], safe=''))
PY
)&type=pve"
local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$setup_token"
local expected_download_url="$(python3 - <<'PY' "$normalized_host_url" "$pulse_url" "$backup_perms" 3<<<"$setup_token"
from urllib.parse import quote
import sys
host, pulse_url, setup_token = sys.argv[1:]
print(f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve")
host, pulse_url, backup_perms = sys.argv[1:]
backup_query = "backup_perms=true&" if backup_perms == "true" else ""
setup_token = __import__("os").fdopen(3).read().strip()
print(f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve")
PY
)"
local expected_script_name="pulse-setup-pve.sh"
if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || [[ "$setup_download_url" != "$expected_download_url" ]] || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then
if [[ -z "$setup_token" ]] || [[ "$setup_type" != "pve" ]] || [[ "$setup_host" != "$normalized_host_url" ]] || [[ "$setup_url" != "$expected_setup_url" ]] || { [[ "$setup_download_url" != "$expected_download_url" ]] && [[ "$setup_download_url" != "$expected_setup_url" ]]; } || [[ "$setup_script_name" != "$expected_script_name" ]] || [[ -z "$setup_command" ]] || [[ -z "$setup_command_with_env" ]] || [[ -z "$setup_command_without_env" ]] || [[ -z "$setup_token_hint" ]] || [[ -z "$setup_expires" ]] || [[ "$setup_expiry_state" != "live" ]]; then
AUTO_NODE_REGISTER_ERROR="missing setup token"
print_warn "Pulse did not return a setup token; skipping automatic node registration"
return
@@ -2512,9 +2519,10 @@ PY
fi
local register_payload
register_payload=$(python3 - <<'PY' "$normalized_host_url" "$token_id" "$token_value" "$server_name" "$setup_token"
register_payload=$(python3 - "$normalized_host_url" "$token_id" "$server_name" 3<<<"$(printf '%s\n%s\n' "$token_value" "$setup_token")" <<'PY'
import json, sys
host, token_id, token_value, server_name, setup_token = sys.argv[1:]
host, token_id, server_name = sys.argv[1:]
token_value, setup_token = __import__("os").fdopen(3).read().splitlines()
print(json.dumps({
"type": "pve",
"host": host,
@@ -2528,7 +2536,7 @@ PY
)
local register_response
if ! register_response=$(curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"); then
if ! register_response=$(printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-); then
AUTO_NODE_REGISTER_ERROR="auto-register request failed"
print_warn "Pulse auto-registration request failed; skipping automatic node registration"
return
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// Read what the guide actually sends, rather than maintaining a second client.
// Authentication, licensing and CSRF are tested by the existing route tests;
// these documentation tests exercise the real handlers and persistent values.
func adminDocBodies(t *testing.T, name, heading string) [][]byte {
t.Helper()
contents, err := os.ReadFile(filepath.Join("..", "..", "docs", name+".md"))
if err != nil {
t.Fatal(err)
}
_, section, found := strings.Cut(string(contents), "### "+heading+"\n")
if !found {
t.Fatalf("missing %s heading %q", name, heading)
}
if next := strings.Index(section, "\n##"); next >= 0 {
section = section[:next]
}
// Both shell heredocs and API schema blocks contain standalone JSON.
re := regexp.MustCompile("(?s)(?:<<'JSON'\\n|```json\\n)(.*?)(?:\\nJSON|\\n```)")
var bodies [][]byte
for _, match := range re.FindAllStringSubmatch(section, -1) {
if !json.Valid([]byte(match[1])) {
t.Fatalf("invalid JSON in %s/%s", name, heading)
}
bodies = append(bodies, []byte(match[1]))
}
if len(bodies) == 0 {
t.Fatalf("missing request JSON in %s/%s", name, heading)
}
return bodies
}
func TestAdminDocsCustomRoleLifecycle(t *testing.T) {
dataPath := t.TempDir()
resetPersistentAuthStoresForTests()
t.Cleanup(resetPersistentAuthStoresForTests)
InitPersistentAuthStores(dataPath)
const fixtureSession = "admin-docs-jane-session-fixture"
GetSessionStore().CreateSession(fixtureSession, time.Hour, "fixture", "127.0.0.1", "jane")
fixtureCSRF := GetCSRFStore().GenerateCSRFToken(fixtureSession)
manager, err := auth.NewFileManager(dataPath)
if err != nil {
t.Fatal(err)
}
previous := auth.GetManager()
auth.SetManager(manager)
t.Cleanup(func() { auth.SetManager(previous) })
h := NewRBACHandlers(&config.Config{AuthUser: "alice"})
// Exact-base guide control: creating/updating the built-in operator fails.
legacy := httptest.NewRecorder()
h.HandleRoles(legacy, withUser(httptest.NewRequest(http.MethodPost, "/api/admin/roles",
strings.NewReader(`{"id":"operator","name":"Operator"}`)), "alice"))
if legacy.Code != http.StatusInternalServerError {
t.Fatalf("legacy built-in mutation was not rejected: %d", legacy.Code)
}
assignments := adminDocBodies(t, "RBAC", "Setting Roles for a User")
if len(assignments) != 2 {
t.Fatalf("want assign and clear examples, got %d", len(assignments))
}
steps := []struct {
method, path string
body []byte
handler http.HandlerFunc
status int
}{
{http.MethodPost, "/api/admin/roles", adminDocBodies(t, "RBAC", "Creating a Role")[0], h.HandleRoles, http.StatusOK},
{http.MethodPut, "/api/admin/roles/alert-manager", adminDocBodies(t, "RBAC", "Updating a Role")[0], h.HandleRoles, http.StatusOK},
{http.MethodPut, "/api/admin/users/jane/roles", assignments[0], h.HandleUserRoleActions, http.StatusNoContent},
{http.MethodPut, "/api/admin/users/jane/roles", assignments[1], h.HandleUserRoleActions, http.StatusNoContent},
{http.MethodDelete, "/api/admin/users/jane", nil, h.HandleUserRoleActions, http.StatusNoContent},
{http.MethodDelete, "/api/admin/roles/alert-manager", nil, h.HandleRoles, http.StatusNoContent},
}
for _, step := range steps {
rec := httptest.NewRecorder()
step.handler(rec, withUser(httptest.NewRequest(step.method, step.path, bytes.NewReader(step.body)), "alice"))
if rec.Code != step.status {
t.Fatalf("documented %s %s: got %d: %s", step.method, step.path, rec.Code, rec.Body.String())
}
if step.method == http.MethodPut && step.path == "/api/admin/roles/alert-manager" {
role, exists := manager.GetRole("alert-manager")
if !exists || role.IsBuiltIn || len(role.Permissions) != 4 {
t.Fatalf("custom role update did not persist its permissions: %+v", role)
}
}
if step.method == http.MethodPut && strings.HasSuffix(step.path, "/jane/roles") {
assignment, exists := manager.GetUserAssignment("jane")
var want struct {
RoleIDs []string `json:"roleIds"`
}
if err := json.Unmarshal(step.body, &want); err != nil {
t.Fatal(err)
}
if !exists || strings.Join(assignment.RoleIDs, ",") != strings.Join(want.RoleIDs, ",") {
t.Fatalf("assignment did not persist the complete role list: %+v", assignment)
}
}
if step.method == http.MethodDelete && step.path == "/api/admin/users/jane" {
if _, exists := manager.GetUserAssignment("jane"); exists {
t.Fatal("user removal retained the role assignment")
}
if GetSessionStore().GetSession(fixtureSession) != nil || GetCSRFStore().ValidateCSRFToken(fixtureSession, fixtureCSRF) {
t.Fatal("user removal retained the session or its CSRF token")
}
}
}
if _, exists := manager.GetRole("alert-manager"); exists {
t.Fatal("custom role deletion did not persist")
}
if role, exists := manager.GetRole("operator"); !exists || !role.IsBuiltIn {
t.Fatal("examples altered the built-in role")
}
}
func TestAdminDocsOrganizationSchemasAndAcceptance(t *testing.T) {
t.Setenv("PULSE_DEV", "true")
wasEnabled := IsMultiTenantEnabled()
SetMultiTenantEnabled(true)
t.Cleanup(func() { SetMultiTenantEnabled(wasEnabled) })
persistence := config.NewMultiTenantPersistence(t.TempDir())
h := NewOrgHandlers(persistence, nil)
call := func(method, path, user, orgID string, body []byte, handler http.HandlerFunc) *httptest.ResponseRecorder {
req := withUser(httptest.NewRequest(method, path, bytes.NewReader(body)), user)
req.SetPathValue("id", orgID)
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
check := func(rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Fatalf("documented request: got %d, want %d: %s", rec.Code, status, rec.Body.String())
}
}
// Exact-base guide controls: wrong creation fields and member PATCH fail.
check(call(http.MethodPost, "/api/orgs", "alice", "", []byte(`{"name":"Production Datacenter","description":"EU production infrastructure"}`), h.HandleCreateOrg), http.StatusBadRequest)
check(call(http.MethodPatch, "/api/orgs/production-datacenter/members/user-id", "alice", "production-datacenter", []byte(`{"role":"admin"}`), h.HandleInviteMember), http.StatusMethodNotAllowed)
creation := adminDocBodies(t, "MULTI_TENANT", "Creating an Organization")[0]
check(call(http.MethodPost, "/api/orgs", "alice", "", creation, h.HandleCreateOrg), http.StatusCreated)
check(call(http.MethodPost, "/api/orgs", "bob", "", []byte(`{"id":"other-org-id","displayName":"Other estate"}`), h.HandleCreateOrg), http.StatusCreated)
members := adminDocBodies(t, "MULTI_TENANT", "Managing Members")
if len(members) != 2 {
t.Fatalf("want invitation and existing-member update, got %d", len(members))
}
invitation := call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[0], h.HandleInviteMember)
check(invitation, http.StatusAccepted)
var invited organizationAccessMutationResponse
if err := json.Unmarshal(invitation.Body.Bytes(), &invited); err != nil || invited.Kind != "invitation" || invited.Member != nil {
t.Fatalf("new member was not pending: %s", invitation.Body.String())
}
acceptInvitationForTest(t, h, "production-datacenter", "user-id")
check(call(http.MethodPost, "/api/orgs/production-datacenter/members", "alice", "production-datacenter", members[1], h.HandleInviteMember), http.StatusOK)
org, err := persistence.LoadOrganization("production-datacenter")
if err != nil || organizationRoleForUser(org, "user-id") != models.OrgRoleAdmin {
t.Fatalf("documented role update did not persist: %v", err)
}
check(call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", []byte(`{"targetOrgId":"other-org-id","resourceType":"host","resourceId":"resource-id","role":"viewer"}`), h.HandleCreateShare), http.StatusBadRequest)
shareBody := adminDocBodies(t, "MULTI_TENANT", "Sharing Resources")[0]
created := call(http.MethodPost, "/api/orgs/production-datacenter/shares", "alice", "production-datacenter", shareBody, h.HandleCreateShare)
check(created, http.StatusCreated)
var share models.OrganizationShare
if err := json.Unmarshal(created.Body.Bytes(), &share); err != nil || share.Status != models.OrganizationShareStatusPending || share.AccessRole != models.OrgRoleViewer {
t.Fatalf("documented share grant was not pending/viewer: %s", created.Body.String())
}
accepted := acceptIncomingShareForTest(t, h, "other-org-id", "bob", share.ID)
if accepted.Status != models.OrganizationShareStatusAccepted {
t.Fatal("share acceptance did not persist")
}
}
+4 -61
View File
@@ -3,7 +3,6 @@ package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
@@ -142,19 +141,7 @@ func buildContainerRuntimeAgentInstallCommand(baseURL string, token string, enab
}
func buildSetupScriptCommand(scriptURL string, token string) string {
curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | "
bashCommand := "bash"
sudoCommand := "sudo bash"
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " "
bashCommand = envPrefix + bashCommand
sudoCommand = "sudo env " + envPrefix + "bash"
}
return curlCommand +
`{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand +
`; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand +
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
return configapi.BuildSetupScriptCommand(scriptURL, token)
}
func buildSetupScriptTokenHint(token string) string {
@@ -166,40 +153,11 @@ func buildSetupScriptTokenHint(token string) string {
}
func buildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string {
query := url.Values{}
query.Set("type", strings.TrimSpace(installType))
if trimmedHost := strings.TrimSpace(host); trimmedHost != "" {
query.Set("host", trimmedHost)
}
if trimmedPulseURL := strings.TrimSpace(pulseURL); trimmedPulseURL != "" {
query.Set("pulse_url", trimmedPulseURL)
}
if backupPerms && strings.TrimSpace(installType) == "pve" {
query.Set("backup_perms", "true")
}
return normalizeAgentInstallBaseURL(baseURL) + "/api/setup-script?" + query.Encode()
return configapi.BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
}
func buildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string {
downloadURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
trimmedToken := strings.TrimSpace(setupToken)
if trimmedToken == "" {
return downloadURL
}
parsed, err := url.Parse(downloadURL)
if err != nil {
return downloadURL
}
query := parsed.Query()
query.Set("setup_token", trimmedToken)
parsed.RawQuery = query.Encode()
return parsed.String()
return configapi.BuildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken)
}
func buildSetupScriptFileName(installType string) string {
@@ -207,22 +165,7 @@ func buildSetupScriptFileName(installType string) string {
}
func buildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) setupScriptInstallArtifact {
scriptURL := buildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
commandWithEnv := buildSetupScriptCommand(scriptURL, setupToken)
return setupScriptInstallArtifact{
Type: strings.TrimSpace(installType),
Host: strings.TrimSpace(host),
URL: scriptURL,
DownloadURL: buildSetupScriptDownloadURL(baseURL, installType, host, pulseURL, backupPerms, setupToken),
ScriptFileName: buildSetupScriptFileName(installType),
Command: commandWithEnv,
CommandWithEnv: commandWithEnv,
CommandWithoutEnv: buildSetupScriptCommand(scriptURL, ""),
Expires: expiresAt,
SetupToken: strings.TrimSpace(setupToken),
TokenHint: buildSetupScriptTokenHint(setupToken),
}
return configapi.BuildSetupScriptInstallArtifact(baseURL, installType, host, pulseURL, backupPerms, setupToken, expiresAt)
}
func resolveConfigAgentInstallBaseURL(req *http.Request, cfg *config.Config, hostedMode bool) string {
@@ -89,12 +89,12 @@ func TestBuildProxmoxAgentInstallCommand(t *testing.T) {
IncludeInstallType: true,
})
require.Contains(t, command, posixShellQuote("https://pulse.example.com/install.sh"))
require.Contains(t, command, "printf %s "+posixShellQuote("token-123")+` > "$token_file"`)
require.NotContains(t, command, "token-123")
require.Contains(t, command, `--token-file "$token_file"`)
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `rm -rf -- "$token_dir"`)
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
require.Contains(t, command, `sudo bash -c`)
require.Contains(t, command, `rmdir -- "$token_dir"`)
require.Contains(t, command, "pbs")
require.NotContains(t, command, "--enable-commands")
}
@@ -107,7 +107,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesInsecureForPlainHTTP(t *testing
})
require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655"))
require.Contains(t, command, "http://pulse.example.com:7655")
require.Contains(t, command, "--insecure")
}
@@ -121,7 +121,7 @@ func TestBuildProxmoxAgentInstallCommand_IncludesExplicitInsecureForSelfSignedHT
})
require.Contains(t, command, "curl -kfsSL "+posixShellQuote("https://pulse.example.com:7655/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com:7655"))
require.Contains(t, command, "https://pulse.example.com:7655")
require.Contains(t, command, "--insecure")
}
@@ -135,8 +135,8 @@ func TestBuildProxmoxAgentInstallCommand_UsesPrivilegeEscalationWrapper(t *testi
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`)
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then`)
require.Contains(t, command, `printf %s 'token-123' | sudo tee "$token_file" >/dev/null`)
require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' | sudo bash -s --`)
require.NotContains(t, command, "token-123")
require.Contains(t, command, `curl -fsSL 'https://pulse.example.com/install.sh' -o "$install_script"`)
require.Contains(t, command, `echo "Root privileges required. Run as root (su -) and retry." >&2`)
}
@@ -148,8 +148,8 @@ func TestBuildProxmoxAgentInstallCommand_OmitsTokenWhenNotProvided(t *testing.T)
IncludeInstallType: true,
})
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com"))
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pbs"))
require.Contains(t, command, "https://pulse.example.com")
require.Contains(t, command, "pbs")
require.NotContains(t, command, "--token")
}
@@ -164,10 +164,10 @@ func TestBuildProxmoxAgentInstallCommand_ShellEscapesArguments(t *testing.T) {
})
require.Contains(t, command, posixShellQuote(baseURL+"/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote(baseURL))
require.Contains(t, command, "printf %s "+posixShellQuote(token)+` > "$token_file"`)
require.Contains(t, command, "--url")
require.NotContains(t, command, token)
require.Contains(t, command, `--token-file "$token_file"`)
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
require.Contains(t, command, "pve")
}
func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T) {
@@ -179,10 +179,11 @@ func TestBuildProxmoxAgentInstallCommand_NormalizesTrailingSlashes(t *testing.T)
})
require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
require.Contains(t, command, "https://pulse.example.com/base")
require.Contains(t, command, `--token-file "$token_file"`)
require.NotContains(t, command, "//install.sh")
require.NotContains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base/"))
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
require.NotContains(t, command, posixShellQuote("https://pulse.example.com/base/"))
}
func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testing.T) {
@@ -195,17 +196,17 @@ func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testin
})
require.Contains(t, command, "--enable-proxmox")
require.Contains(t, command, "--proxmox-type "+posixShellQuote("pve"))
require.Contains(t, command, "pve")
require.Contains(t, command, "--enable-commands")
require.Contains(t, command, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`)
require.Contains(t, command, `rm -rf -- "$token_dir"`)
require.Contains(t, command, `rmdir -- "$token_dir"`)
}
func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *testing.T) {
command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com/base", "token-123", true)
require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base"))
require.Contains(t, command, "https://pulse.example.com/base")
require.Contains(t, command, "--token "+posixShellQuote("token-123"))
require.Contains(t, command, "--enable-docker")
require.Contains(t, command, "--enable-host")
@@ -220,7 +221,7 @@ func TestBuildContainerRuntimeAgentInstallCommand_OmitsTokenAndAddsInsecureForHT
command := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "", true)
require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh"))
require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655"))
require.Contains(t, command, "http://pulse.example.com:7655")
require.NotContains(t, command, "--token")
require.Contains(t, command, "--insecure")
}
@@ -246,18 +247,18 @@ func TestContainerRuntimeAgentScopesFollowHostMode(t *testing.T) {
func TestBuildSetupScriptCommand_UsesFailFastQuotedTransport(t *testing.T) {
command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local", "token-123")
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+" | ")
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`)
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`)
require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`)
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local")+` -o "$install_script"`)
require.NotContains(t, command, "token-123")
require.Contains(t, command, `PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1"`)
require.Contains(t, command, `else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`)
}
func TestBuildSetupScriptCommand_OmitsTokenWhenNotProvided(t *testing.T) {
command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pbs", "")
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+" | ")
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then bash`)
require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo bash`)
require.Contains(t, command, "curl -fsSL "+posixShellQuote("https://pulse.example.com/api/setup-script?type=pbs")+` -o "$install_script"`)
require.Contains(t, command, `if [ "$(id -u)" -eq 0 ]; then`)
require.Contains(t, command, `sudo bash -c`)
require.NotContains(t, command, "PULSE_SETUP_TOKEN=")
}
@@ -293,10 +294,11 @@ func TestBuildSetupScriptInstallArtifact_UsesSharedBackendShape(t *testing.T) {
require.Equal(t, "pve", artifact.Type)
require.Equal(t, "https://pve1.local:8006", artifact.Host)
require.Contains(t, artifact.URL, "/api/setup-script?")
require.Contains(t, artifact.DownloadURL, "setup_token=setup-token-123")
require.Equal(t, artifact.URL, artifact.DownloadURL)
require.Equal(t, "pulse-setup-pve.sh", artifact.ScriptFileName)
require.Equal(t, artifact.Command, artifact.CommandWithEnv)
require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'")
require.NotContains(t, artifact.CommandWithEnv, "setup-token-123")
require.Contains(t, artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=")
require.NotContains(t, artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=")
require.Equal(t, expiresAt, artifact.Expires)
require.Equal(t, "setup-token-123", artifact.SetupToken)
@@ -331,7 +333,7 @@ fi`,
})
require.Contains(t, script, `SETUP_SCRIPT_URL="https://pulse.example/api/setup-script?backup_perms=true&host=https%3A%2F%2Fpve1.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve"`)
require.Contains(t, script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'https://pulse.example/api/setup-script?backup_perms=true&host=https%3A%2F%2Fpve1.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve'"'"' | `)
require.Contains(t, script, "PULSE_BOOTSTRAP_COMMAND_WITH_ENV="+posixShellQuote(artifact.CommandWithEnv))
require.Contains(t, script, `PULSE_SETUP_TOKEN="${PULSE_SETUP_TOKEN:-setup-token-123}"`)
require.Contains(t, script, `pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin`)
require.Contains(t, script, `pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin`)
@@ -542,10 +542,10 @@ func TestHandleAgentInstallCommand(t *testing.T) {
if resp.Token == "" || resp.Command == "" {
t.Fatalf("expected token and command in response")
}
if !bytes.Contains([]byte(resp.Command), []byte(resp.Token)) {
t.Fatalf("expected command to include token")
if bytes.Contains([]byte(resp.Command), []byte(resp.Token)) || !strings.Contains(resp.Command, `--token-file "$token_file"`) {
t.Fatal("credential must be entered privately, not included in copied command")
}
if !bytes.Contains([]byte(resp.Command), []byte("--proxmox-type "+posixShellQuote("pve"))) {
if resp.Command != BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "http://example.com:8080", Token: resp.Token, InstallType: "pve", IncludeInstallType: true, EnableCommands: true}) {
t.Fatalf("expected command to include proxmox type")
}
if len(cfg.APITokens) != 1 {
@@ -633,7 +633,7 @@ func TestHandleAgentInstallCommand_NormalizesType(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !bytes.Contains([]byte(resp.Command), []byte("--proxmox-type "+posixShellQuote("pbs"))) {
if resp.Command != BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "http://example.com:8080", Token: resp.Token, InstallType: "pbs", IncludeInstallType: true}) {
t.Fatalf("expected normalized proxmox type in command, got: %s", resp.Command)
}
}
@@ -435,7 +435,7 @@ func TestPVESetupScript_UsesFailFastRetryGuidance(t *testing.T) {
}
script := rr.Body.String()
if !containsString(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | `) {
if !containsString(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV=`+posixShellQuote(BuildSetupScriptCommand("http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve", ""))) {
t.Fatalf("expected canonical bootstrap command owner in setup script, got: %s", truncate(script, 700))
}
if !containsString(script, `echo " $PULSE_BOOTSTRAP_COMMAND_WITH_ENV"`) {
@@ -952,7 +952,8 @@ func TestPVESetupScriptSmokeTestsCreatedTokenBeforeAutoRegistration(t *testing.T
for _, needle := range []string{
`smoke_test_pve_token() {`,
`curl -kfsS --retry 2 --retry-delay 1`,
`Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`,
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl`,
`-H @-`,
`${HOST_URL%/}/api2/json/nodes`,
`TOKEN_READY=false`,
`if smoke_test_pve_token; then`,
@@ -60,12 +60,10 @@ func TestHandleSetupScriptURL(t *testing.T) {
if tokenHint == token {
t.Errorf("expected tokenHint to mask setup token, got %q", tokenHint)
}
if !strings.Contains(downloadURL, "setup_token=") {
t.Errorf("expected downloadURL to embed setup token, got %q", downloadURL)
}
if !strings.Contains(downloadURL, token) {
t.Errorf("expected downloadURL to contain setup token, got %q", downloadURL)
if downloadURL != url || strings.Contains(downloadURL, token) {
t.Errorf("downloadURL must match the credential-free script URL")
}
respType, ok := resp["type"].(string)
if !ok || respType != "pve" {
t.Errorf("expected canonical type, got %v", resp["type"])
@@ -92,16 +90,12 @@ func TestHandleSetupScriptURL(t *testing.T) {
t.Errorf("expected URL to contain public host, got %s", url)
}
quotedURL := posixShellQuote(url)
if !strings.Contains(command, "curl -fsSL "+quotedURL+" | ") ||
!strings.Contains(command, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) ||
!strings.Contains(command, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote(token)+` bash`) {
t.Errorf("expected shell-quoted command, got %s", command)
}
if !strings.Contains(commandWithoutEnv, "curl -fsSL "+quotedURL+" | ") ||
!strings.Contains(commandWithoutEnv, `if [ "$(id -u)" -eq 0 ]; then bash`) ||
!strings.Contains(commandWithoutEnv, `elif command -v sudo >/dev/null 2>&1; then sudo bash`) {
t.Errorf("expected shell-quoted commandWithoutEnv, got %s", commandWithoutEnv)
for _, candidate := range []string{command, commandWithoutEnv} {
if !strings.Contains(candidate, "curl -fsSL "+quotedURL+` -o "$install_script"`) || strings.Contains(candidate, token) || !strings.Contains(candidate, "PULSE_SETUP_TOKEN_FILE=") || !strings.Contains(candidate, "sudo bash -c") {
t.Error("setup command must download completely, prompt privately, and pass only the token file")
}
}
},
},
{
@@ -296,7 +290,7 @@ func TestHandleSetupScriptURL_PreservesConfiguredPublicURLSchemeOnLoopback(t *te
}
command, _ := response["command"].(string)
if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+" | ") || !strings.Contains(command, `sudo env PULSE_SETUP_TOKEN=`) {
if !strings.Contains(command, "curl -fsSL "+posixShellQuote(urlValue)+` -o "$install_script"`) || !strings.Contains(command, `sudo bash -c`) {
t.Fatalf("command = %q, want quoted setup-script URL", command)
}
}
@@ -323,8 +317,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi
if artifact.URL == "" || !strings.Contains(artifact.URL, "/api/setup-script?") {
t.Fatalf("url = %q, want canonical setup-script url", artifact.URL)
}
if artifact.DownloadURL == "" || !strings.Contains(artifact.DownloadURL, "setup_token=setup-token-123") {
t.Fatalf("downloadURL = %q, want setup token embedded", artifact.DownloadURL)
if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, "setup-token-123") {
t.Fatalf("downloadURL = %q, want credential-free URL", artifact.DownloadURL)
}
if artifact.ScriptFileName != "pulse-setup-pve.sh" {
t.Fatalf("scriptFileName = %q, want canonical filename", artifact.ScriptFileName)
@@ -332,8 +326,8 @@ func TestBuildSetupScriptInstallArtifact_UsesCanonicalTransportContract(t *testi
if artifact.Command != artifact.CommandWithEnv {
t.Fatalf("command = %q, commandWithEnv = %q, want identical canonical env command", artifact.Command, artifact.CommandWithEnv)
}
if !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN='setup-token-123'") {
t.Fatalf("commandWithEnv = %q, want setup token env transport", artifact.CommandWithEnv)
if strings.Contains(artifact.CommandWithEnv, "setup-token-123") || !strings.Contains(artifact.CommandWithEnv, "PULSE_SETUP_TOKEN_FILE=") {
t.Fatalf("commandWithEnv = %q, want private token-file transport", artifact.CommandWithEnv)
}
if strings.Contains(artifact.CommandWithoutEnv, "PULSE_SETUP_TOKEN=") {
t.Fatalf("commandWithoutEnv = %q, want no setup token env transport", artifact.CommandWithoutEnv)
@@ -51,8 +51,12 @@ func TestHostedRuntimeMintsAgentInstallTokensWithoutLocalAuth(t *testing.T) {
if len(cfg.APITokens) != 1 {
t.Fatalf("persisted API tokens = %d, want 1", len(cfg.APITokens))
}
if resp.Command != "" && !strings.Contains(resp.Command, resp.Token) {
t.Fatalf("install command does not carry the minted token: %s", resp.Command)
if tc.name == "proxmox agent" {
if strings.Contains(resp.Command, resp.Token) || !strings.Contains(resp.Command, `--token-file "$token_file"`) {
t.Fatal("hosted Proxmox credential must use separate private input")
}
} else if resp.Command != "" && !strings.Contains(resp.Command, resp.Token) {
t.Fatal("generic host command lost its existing credential transport")
}
})
}
+48 -41
View File
@@ -1,7 +1,6 @@
package configapi
import (
"fmt"
"strings"
)
@@ -16,60 +15,68 @@ type AgentInstallCommandOptions struct {
type agentInstallCommandOptions = AgentInstallCommandOptions
// BuildProxmoxAgentInstallCommand never puts the credential in shell source.
// Token only selects whether private terminal input is required. The plaintext
// remains in the authenticated response's separate token field.
func BuildProxmoxAgentInstallCommand(opts AgentInstallCommandOptions) string {
baseURL := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
installScriptURL := baseURL + "/install.sh"
curlFlags := "-fsSL"
if opts.Insecure {
curlFlags = "-kfsSL"
}
token := strings.TrimSpace(opts.Token)
command := fmt.Sprintf("curl %s %s | bash -s -- \\\n --url %s \\\n --enable-proxmox", curlFlags, posixShellQuote(installScriptURL), posixShellQuote(baseURL))
trustArgs := ""
if opts.Insecure || strings.HasPrefix(strings.ToLower(baseURL), "http://") {
command += " \\\n --insecure"
trustArgs = " --insecure"
}
args := " --url " + posixShellQuote(baseURL) + " --enable-proxmox"
if opts.IncludeInstallType {
command += fmt.Sprintf(" \\\n --proxmox-type %s", posixShellQuote(opts.InstallType))
args += " --proxmox-type " + posixShellQuote(opts.InstallType)
}
if opts.EnableCommands {
command += " \\\n --enable-commands"
args += " --enable-commands"
}
if token == "" {
return withPrivilegeEscalation(command)
if strings.TrimSpace(opts.Token) != "" {
args += ` --token-file "$token_file"`
}
args += trustArgs + " --non-interactive"
preflight := "bash \"$install_script\" --url " + posixShellQuote(baseURL) +
" --preflight-only --output json --non-interactive" + trustArgs + ";"
return privateBootstrapCommand(baseURL+"/install.sh", curlFlags, preflight,
"bash \"$1\""+args+";", strings.TrimSpace(opts.Token) != "", "Pulse agent token")
}
rootCommand := command + " \\\n --token-file \"$token_file\""
sudoCommand := strings.Replace(command, "| bash -s --", "| sudo bash -s --", 1) + " \\\n --token-file \"$token_file\""
return fmt.Sprintf(`(
set -e
token_dir=""
cleanup() {
if [ -n "${token_dir:-}" ]; then
if [ "$(id -u)" -eq 0 ]; then
rm -rf -- "$token_dir"
elif command -v sudo >/dev/null 2>&1; then
sudo rm -rf -- "$token_dir" >/dev/null 2>&1 || true
fi
fi
}
trap cleanup EXIT HUP INT TERM
if [ "$(id -u)" -eq 0 ]; then
token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
umask 077
printf %%s %s > "$token_file"
%s
elif command -v sudo >/dev/null 2>&1; then
token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)
token_file="$token_dir/token"
printf %%s %s | sudo tee "$token_file" >/dev/null
sudo chmod 0600 "$token_file"
%s
else
echo "Root privileges required. Run as root (su -) and retry." >&2
exit 1
fi
)`, posixShellQuote(token), rootCommand, posixShellQuote(token), sudoCommand)
// privateBootstrapCommand uses POSIX outer grammar for single-line paste hosts.
// The privileged Bash child owns both the silent input and its 0700/0600
// handoff. Neither sudo nor a child command receives the credential in argv or
// environment. Failed/partial downloads and preflight never reach that child.
func privateBootstrapCommand(scriptURL, curlFlags, preflight, run string, needsToken bool, prompt string) string {
privileged := []string{"set +xv;", "set -eu;", "umask 077;"}
if needsToken {
privileged = append(privileged,
`token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX);`,
`token_file="$token_dir/token";`,
`cleanup() { unset pulse_token; rm -f -- "$token_file"; rmdir -- "$token_dir"; };`,
`trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`,
`if ! IFS= read -r -s -p `+posixShellQuote(prompt+" (paste at this prompt, not in the command): ")+` pulse_token </dev/tty; then echo "A terminal is required to enter the token. No installation was attempted." >&2; exit 1; fi;`,
`printf '\n' >/dev/tty;`,
`if [ -z "$pulse_token" ] || [ "${#pulse_token}" -gt 4096 ]; then echo "A non-empty token of at most 4096 characters is required." >&2; exit 1; fi;`,
`printf %s "$pulse_token" > "$token_file"; unset pulse_token;`,
)
}
privileged = append(privileged, run)
child := "bash -c " + posixShellQuote(strings.Join(privileged, " ")) + ` pulse-bootstrap "$install_script";`
return strings.Join([]string{
"(", "set +xv;", "set -eu;", "umask 077;",
`if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then sudo -v; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi;`,
`bootstrap_dir=$(mktemp -d /tmp/pulse-bootstrap.XXXXXX);`,
`install_script="$bootstrap_dir/install.sh";`,
`cleanup() { rm -f -- "$install_script"; rmdir -- "$bootstrap_dir"; };`,
`trap cleanup EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM;`,
"curl " + curlFlags + " " + posixShellQuote(strings.TrimSpace(scriptURL)) + ` -o "$install_script";`,
preflight,
`if [ "$(id -u)" -eq 0 ]; then ` + child + " else sudo " + child + " fi;",
")",
}, " ")
}
func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string {
+377 -70
View File
@@ -1,84 +1,73 @@
package configapi
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) {
if testing.Short() {
t.Skip("executes the generated POSIX shell command")
}
// A real controlling terminal is necessary: stdin must not accidentally become
// a credential channel for a downloaded/piped script. The token travels only
// over the PTY after the prompt appears, not Python/child argv or environment.
const bootstrapPTYRunner = `
import errno, fcntl, json, os, pty, select, signal, subprocess, sys, termios, time
p = json.load(sys.stdin)
master, slave = pty.openpty()
def session():
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
history = p.get("history")
env = os.environ.copy()
if history:
env.update(HISTFILE=history, HISTSIZE="100", HISTFILESIZE="100", HISTCONTROL="", PS1="PULSE_TEST$ ")
program = ["bash", "--noprofile", "--norc", "-i"] if history else ["sh", "-c", p["command"]]
child = subprocess.Popen(program, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, env=env)
if history: os.write(master, (p["command"] + "\n").encode())
os.close(slave)
output = b""
sent = False
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
ready, _, _ = select.select([master], [], [], .1)
if ready:
try: chunk = os.read(master, 65536)
except OSError as e:
if e.errno == errno.EIO: break
raise
if not chunk: break
output += chunk
if output.endswith(b"(paste at this prompt, not in the command): ") and not sent:
os.write(master, (p["input"] + "\n").encode())
sent = True
if history: os.write(master, b"exit\n")
if child.poll() is not None and not ready: break
else:
os.killpg(child.pid, signal.SIGKILL)
output += b"\nPTY fixture deadline expired\n"
code = child.wait()
os.close(master)
sys.stdout.buffer.write(output)
sys.exit(code if code >= 0 else 128 - code)
`
fixtureDir := t.TempDir()
binDir := filepath.Join(fixtureDir, "bin")
if err := os.Mkdir(binDir, 0o700); err != nil {
t.Fatal(err)
}
installerPath := filepath.Join(fixtureDir, "installer.sh")
capturePath := filepath.Join(fixtureDir, "captured-token")
writeExecutable(t, installerPath, `#!/usr/bin/env bash
set -e
token_file=""
while [ "$#" -gt 0 ]; do
case "$1" in
--token-file) token_file="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$token_file" ]
[ "$(stat -c %a "$token_file" 2>/dev/null || stat -f %Lp "$token_file")" = "600" ]
parent_dir=$(dirname "$token_file")
[ "$(stat -c %a "$parent_dir" 2>/dev/null || stat -f %Lp "$parent_dir")" = "700" ]
[ "$(stat -c %u "$token_file" 2>/dev/null || stat -f %u "$token_file")" = "$(stat -c %u "$parent_dir" 2>/dev/null || stat -f %u "$parent_dir")" ]
cat "$token_file" > "$FAKE_CAPTURE"
`)
writeExecutable(t, filepath.Join(binDir, "curl"), `#!/bin/sh
cat "$FAKE_INSTALLER"
`)
writeExecutable(t, filepath.Join(binDir, "sudo"), `#!/bin/sh
exec "$@"
`)
writeExecutable(t, filepath.Join(binDir, "id"), `#!/bin/sh
if [ "$1" = "-u" ]; then
printf '%s\n' "$FAKE_ID_UID"
else
exec /usr/bin/id "$@"
fi
`)
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{
BaseURL: "https://pulse.example",
Token: "token-123",
InstallType: "pve",
IncludeInstallType: true,
})
for _, fakeUID := range []string{"0", "1000"} {
t.Run("uid_"+fakeUID, func(t *testing.T) {
if err := os.Remove(capturePath); err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
cmd := exec.Command("bash", "-c", command)
cmd.Env = append(os.Environ(),
"PATH="+binDir+":"+os.Getenv("PATH"),
"FAKE_CAPTURE="+capturePath,
"FAKE_ID_UID="+fakeUID,
"FAKE_INSTALLER="+installerPath,
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generated command failed: %v\n%s\ncommand:\n%s", err, output, command)
}
got, err := os.ReadFile(capturePath)
if err != nil {
t.Fatal(err)
}
if string(got) != "token-123" {
t.Fatalf("captured token = %q, want token-123", got)
}
})
func runBootstrap(t *testing.T, command, token string, env []string, tty bool) ([]byte, error) {
t.Helper()
var cmd *exec.Cmd
if tty {
payload, err := json.Marshal(map[string]string{"command": command, "input": token})
if err != nil {
t.Fatal(err)
}
cmd = exec.Command("python3", "-c", bootstrapPTYRunner)
cmd.Stdin = strings.NewReader(string(payload))
} else {
cmd = exec.Command("sh", "-c", command)
}
cmd.Env = append(os.Environ(), env...)
return cmd.CombinedOutput()
}
func writeExecutable(t *testing.T, path, body string) {
@@ -87,3 +76,321 @@ func writeExecutable(t *testing.T, path, body string) {
t.Fatal(err)
}
}
func bootstrapFixture(t *testing.T, installer string) (string, []string) {
t.Helper()
root := t.TempDir()
bin := filepath.Join(root, "bin")
if err := os.Mkdir(bin, 0700); err != nil {
t.Fatal(err)
}
writeExecutable(t, filepath.Join(root, "installer.sh"), installer)
writeExecutable(t, filepath.Join(bin, "curl"), `#!/bin/sh
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
out=""
while [ "$#" -gt 0 ]; do
case "$1" in -o) out="$2"; shift 2 ;; *) shift ;; esac
done
printf %s "$out" > "$FAKE_ROOT/download-path"
cp "$FAKE_ROOT/installer.sh" "$out"
exit "${FAKE_CURL_EXIT:-0}"
`)
writeExecutable(t, filepath.Join(bin, "sudo"), `#!/bin/sh
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
if [ "$1" = "-v" ]; then exit "${FAKE_SUDO_EXIT:-0}"; fi
exec "$@"
`)
writeExecutable(t, filepath.Join(bin, "id"), `#!/bin/sh
if [ "$1" = "-u" ]; then printf '%s\n' "$FAKE_ID_UID"; else exec /usr/bin/id "$@"; fi
`)
writeExecutable(t, filepath.Join(bin, "mktemp"), `#!/bin/sh
path=$(/usr/bin/mktemp "$@") || exit 1
printf '%s\n' "$path" >> "$FAKE_ROOT/private-directories"
printf '%s\n' "$path"
`)
return root, []string{"PATH=" + bin + ":" + os.Getenv("PATH"), "FAKE_ROOT=" + root, "FAKE_ID_UID=0", "PULSE_SETUP_TOKEN=", "PULSE_TOKEN="}
}
const recordingInstaller = `#!/bin/bash
set -eu
printf '%s\n' "$@" >> "$FAKE_ROOT/argv"
if [[ " $* " == *" --preflight-only "* ]]; then
printf preflight > "$FAKE_ROOT/preflight"
exit "${FAKE_PREFLIGHT_EXIT:-0}"
fi
printf '%s:%s' "${PULSE_TOKEN:-}" "${PULSE_SETUP_TOKEN:-}" > "$FAKE_ROOT/secret-env"
token_file="${PULSE_SETUP_TOKEN_FILE:-}"
while [ "$#" -gt 0 ]; do
case "$1" in --token-file) token_file="$2"; shift 2 ;; *) shift ;; esac
done
[ -n "$token_file" ]
[ "$(stat -c %a "$token_file")" = 600 ]
[ "$(stat -c %a "$(dirname "$token_file")")" = 700 ]
[ "$(stat -c %u "$token_file")" = "$(stat -c %u "$(dirname "$token_file")")" ]
printf %s "$token_file" > "$FAKE_ROOT/token-path"
cat "$token_file" > "$FAKE_ROOT/captured-token"
exit "${FAKE_INSTALL_EXIT:-0}"
`
func TestPrivateBootstrapKeepsSecretsOutOfCommands(t *testing.T) {
secret := "synthetic-secret-0123456789"
artifact := BuildSetupScriptInstallArtifact("https://pulse.example/base", "pbs", "https://pbs.example:8007", "https://pulse.example/base", false, secret, 1900000000)
if artifact.DownloadURL != artifact.URL || strings.Contains(artifact.DownloadURL, secret) {
t.Fatal("credential-bearing download")
}
for name, command := range map[string]string{
"setup": artifact.Command,
"setup-with-env-alias": artifact.CommandWithEnv,
"setup-without-env-alias": artifact.CommandWithoutEnv,
"agent-pve": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pve", IncludeInstallType: true}),
"agent-pbs": BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: secret, InstallType: "pbs", IncludeInstallType: true}),
} {
t.Run(name, func(t *testing.T) {
if strings.Contains(command, secret) || strings.ContainsAny(command, "\r\n") || strings.Contains(command, "PULSE_SETUP_TOKEN=") || strings.Contains(command, "--token '") || strings.Contains(command, "| bash") {
t.Fatal("copied command exposes a credential or executes a partial fetch")
}
if !strings.Contains(command, "read -r -s -p") || !strings.Contains(command, "sudo bash -c") || !strings.Contains(command, " -o \"$install_script\"") {
t.Fatal("missing private prompt, sudo or complete download")
}
})
}
}
func TestBuildProxmoxAgentInstallCommandExecutesTrustedRootAndSudoTokenBootstrap(t *testing.T) {
for _, kind := range []string{"agent", "setup"} {
for _, uid := range []string{"0", "1000"} {
t.Run(kind+"_uid_"+uid, func(t *testing.T) {
root, env := bootstrapFixture(t, recordingInstaller)
env = append(env, "FAKE_ID_UID="+uid)
token := strings.Repeat("a", 32)
command := BuildSetupScriptCommand("https://pulse.example/api/setup-script?type=pbs", token)
if kind == "agent" {
command = BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pbs", IncludeInstallType: true})
}
out, err := runBootstrap(t, command, token, env, true)
if err != nil {
t.Fatalf("bootstrap: %v\n%s", err, out)
}
captured, err := os.ReadFile(filepath.Join(root, "captured-token"))
if err != nil {
t.Fatal(err)
}
if string(captured) != token {
t.Fatal("private-file token differs")
}
args, _ := os.ReadFile(filepath.Join(root, "argv"))
secretEnv, _ := os.ReadFile(filepath.Join(root, "secret-env"))
if strings.Contains(string(args), token) || strings.Contains(string(out), token) || string(secretEnv) != ":" {
t.Fatal("token leaked into argv, environment or terminal output")
}
for _, record := range []string{"token-path", "download-path"} {
path, err := os.ReadFile(filepath.Join(root, record))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(string(path)); !os.IsNotExist(err) {
t.Fatalf("handoff remains: %s", record)
}
if _, err := os.Stat(filepath.Dir(string(path))); !os.IsNotExist(err) {
t.Fatalf("handoff directory remains: %s", record)
}
}
_, err = os.Stat(filepath.Join(root, "preflight"))
if kind == "agent" && err != nil {
t.Fatal("agent preflight omitted")
}
if kind == "setup" && !os.IsNotExist(err) {
t.Fatal("native setup ran agent preflight")
}
})
}
}
}
func TestPrivateBootstrapFailsBeforeInstallAndCleansHandoffs(t *testing.T) {
for _, tc := range []struct {
name string
env string
tty bool
input string
}{
{"no-terminal", "", false, ""}, {"blank-token", "", true, ""}, {"interrupt", "", true, "\x03"},
{"partial-download", "FAKE_CURL_EXIT=22", false, ""}, {"wrong-installer-or-preflight", "FAKE_PREFLIGHT_EXIT=44", false, ""},
{"sudo-refusal", "FAKE_SUDO_EXIT=1", false, ""}, {"install-failure", "FAKE_INSTALL_EXIT=9", true, strings.Repeat("b", 32)},
} {
t.Run(tc.name, func(t *testing.T) {
root, env := bootstrapFixture(t, recordingInstaller)
env = append(env, tc.env)
if tc.name == "sudo-refusal" {
env = append(env, "FAKE_ID_UID=1000")
}
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: "synthetic-not-in-command", InstallType: "pve", IncludeInstallType: true})
out, err := runBootstrap(t, command, tc.input, env, tc.tty)
if err == nil {
t.Fatalf("failure appeared successful: %s", out)
}
if tc.input != "" && tc.input != "\x03" && strings.Contains(string(out), tc.input) {
t.Fatal("token echoed on failure")
}
_, err = os.Stat(filepath.Join(root, "captured-token"))
if tc.name != "install-failure" && !os.IsNotExist(err) {
t.Fatal("installer ran before prerequisite succeeded")
}
directories, _ := os.ReadFile(filepath.Join(root, "private-directories"))
for _, path := range strings.Fields(string(directories)) {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("failure left a private bootstrap directory")
}
}
for _, record := range []string{"download-path", "token-path"} {
p, err := os.ReadFile(filepath.Join(root, record))
if os.IsNotExist(err) {
continue
}
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Dir(string(p))); !os.IsNotExist(err) {
t.Fatalf("failure left %s directory", record)
}
}
})
}
}
func TestSetupPrivateFileRejectsUnsafeInputsBeforeNativeMutation(t *testing.T) {
for _, tc := range []string{"valid", "public-mode", "public-parent", "symlink", "oversized", "malformed", "missing", "directory"} {
t.Run(tc, func(t *testing.T) {
root := t.TempDir()
if err := os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
file := filepath.Join(root, "token")
token := strings.Repeat("c", 32)
body := token
if tc == "oversized" {
body = strings.Repeat("c", 4097)
}
if tc == "malformed" {
body = "do not echo this credential"
}
if err := os.WriteFile(file, []byte(body), 0600); err != nil {
t.Fatal(err)
}
switch tc {
case "public-mode":
os.Chmod(file, 0644)
case "public-parent":
os.Chmod(root, 0755)
case "symlink":
file = filepath.Join(root, "link")
if err := os.Symlink(filepath.Join(root, "token"), file); err != nil {
t.Fatal(err)
}
case "missing":
file = filepath.Join(root, "absent")
case "directory":
file = root
}
cmd := exec.Command("bash", "-c", setupTokenFilePrelude+`printf %s "$PULSE_SETUP_TOKEN" > "$FAKE_CAPTURE"`)
capture := filepath.Join(root, "native-mutation-marker")
cmd.Env = append(os.Environ(), "PULSE_SETUP_TOKEN_FILE="+file, "FAKE_CAPTURE="+capture)
out, err := cmd.CombinedOutput()
if strings.Contains(string(out), body) {
t.Fatal("invalid secret echoed")
}
if tc == "valid" {
if err != nil {
t.Fatalf("valid file: %v %s", err, out)
}
data, e := os.ReadFile(capture)
if e != nil || string(data) != token {
t.Fatal("private input lost")
}
} else {
if err == nil {
t.Fatal("unsafe file accepted")
}
if _, e := os.Stat(capture); !os.IsNotExist(e) {
t.Fatal("native action followed unsafe input")
}
}
})
}
}
func TestPrivateBootstrapShellQuotesDataWithoutPuttingTokenInSource(t *testing.T) {
root, env := bootstrapFixture(t, recordingInstaller)
injected := filepath.Join(root, "must-not-exist")
base := `https://pulse.example/' ; touch ` + injected + ` ; #`
token := `tok'en-with-shell-' ; touch ` + injected
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: base, Token: token, InstallType: "pbs", IncludeInstallType: true})
if strings.Contains(command, token) {
t.Fatal("credential interpolated")
}
out, err := runBootstrap(t, command, token, env, true)
if err != nil {
t.Fatalf("escaped bootstrap failed: %v %s", err, out)
}
args, err := os.ReadFile(filepath.Join(root, "argv"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(args), "--url\n"+base+"\n") || !strings.Contains(string(args), "--proxmox-type\npbs\n") {
t.Fatal("quoted arguments changed")
}
if _, err := os.Stat(injected); !os.IsNotExist(err) {
t.Fatal("argument was executed as shell source")
}
}
func TestPrivateBootstrapInteractiveHistoryContainsCommandButNotToken(t *testing.T) {
root, env := bootstrapFixture(t, recordingInstaller)
token := strings.Repeat("d", 32)
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: token, InstallType: "pve", IncludeInstallType: true})
history := filepath.Join(root, "shell-history")
payload, err := json.Marshal(map[string]string{"command": command, "input": token, "history": history})
if err != nil {
t.Fatal(err)
}
cmd := exec.Command("python3", "-c", bootstrapPTYRunner)
cmd.Stdin = strings.NewReader(string(payload))
cmd.Env = append(os.Environ(), env...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("interactive bootstrap: %v %s", err, out)
}
hist, err := os.ReadFile(history)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(hist), command) {
t.Fatal("history control did not record the copied command")
}
if strings.Contains(string(hist), token) || strings.Contains(string(out), token) {
t.Fatal("silent input entered shell history or terminal output")
}
}
// The public server installer is not the telemetry installer. If it is served
// accidentally at the agent path, its real parser rejects --url before token
// input or privileged installation; a complete but wrong fetch is not success.
func TestPrivateAgentBootstrapRejectsActualServerInstaller(t *testing.T) {
source, err := os.ReadFile(filepath.Join("..", "..", "..", "install.sh"))
if err != nil {
t.Fatal(err)
}
root, env := bootstrapFixture(t, string(source))
command := BuildProxmoxAgentInstallCommand(AgentInstallCommandOptions{BaseURL: "https://pulse.example", Token: "synthetic-token", InstallType: "pve", IncludeInstallType: true})
out, err := runBootstrap(t, command, "", env, false)
if err == nil || !strings.Contains(string(out), "Unknown option: --url") {
t.Fatal("wrong installer was not rejected by its real parser")
}
dirs, _ := os.ReadFile(filepath.Join(root, "private-directories"))
for _, path := range strings.Fields(string(dirs)) {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("wrong-installer failure left a private directory")
}
}
}
@@ -0,0 +1,144 @@
package configapi
import (
"bytes"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// Real TLS/curl, issuance, downloaded native PBS script, private handoff and
// registration. Only the appliance CLI and EUID guard are modeled: Core's
// ordinary proof guest is deliberately not a system-install/root capability.
func TestPrivatePBSBootstrapPipelineTLSAndRegistration(t *testing.T) {
curlPath, err := exec.LookPath("curl")
if err != nil {
t.Fatal("curl required for the TLS bootstrap acceptance test")
}
for _, status := range []int{200, 401, 403, 503} {
t.Run(http.StatusText(status), func(t *testing.T) {
root, env := bootstrapFixture(t, "")
cfg := &config.Config{DataPath: t.TempDir()}
handler := newTestConfigHandlers(t, cfg)
var registration AutoRegisterRequest
var registrationMu sync.Mutex
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/setup-script" {
if r.URL.Query().Has("setup_token") {
t.Error("download URL contains setup token")
}
rr := httptest.NewRecorder()
handler.HandleSetupScript(rr, r)
if rr.Code != 200 {
t.Errorf("download status %d", rr.Code)
}
for k, values := range rr.Header() {
w.Header()[k] = values
}
// Preserve every operation except the unavailable actual root
// identity check. All PBS CLI calls below are local fixtures.
script := strings.Replace(rr.Body.String(), `if [ "$EUID" -ne 0 ]; then`, `if false; then`, 1)
w.Write([]byte(script))
return
}
if r.URL.Path == "/api/auto-register" {
var input AutoRegisterRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
registrationMu.Lock()
registration = input
registrationMu.Unlock()
body, _ := json.Marshal(input)
r.Body = http.NoBody
if status != 200 {
w.WriteHeader(status)
// An adverse server response must not turn the request's
// credential into diagnostic output on the terminal.
w.Write(body)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
handler.HandleAutoRegister(w, r)
return
}
w.WriteHeader(404)
}))
defer server.Close()
cfg.PublicURL = server.URL
ca := filepath.Join(root, "ca.pem")
if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/setup-script-url", strings.NewReader(`{"type":"pbs","host":"`+server.URL+`"}`))
rr := httptest.NewRecorder()
handler.HandleSetupScriptURL(rr, request)
if rr.Code != 200 {
t.Fatalf("issuance status %d", rr.Code)
}
var artifact SetupScriptInstallArtifact
if err := json.Unmarshal(rr.Body.Bytes(), &artifact); err != nil {
t.Fatal(err)
}
if strings.Contains(artifact.Command, artifact.SetupToken) || artifact.DownloadURL != artifact.URL {
t.Fatal("unsafe issued artifact")
}
// Record only process argument/secret-export presence; never dump
// the environment, credential file, or registration body to logs.
writeExecutable(t, filepath.Join(root, "bin", "curl"), "#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"$FAKE_ROOT/argv\"\nexec "+posixShellQuote(curlPath)+" \"$@\"\n")
writeExecutable(t, filepath.Join(root, "bin", "proxmox-backup-manager"), `#!/bin/sh
printf '%s\n' "$@" >> "$FAKE_ROOT/native-argv"
case "$*" in
"user generate-token "*) printf '%s\n' '{"value":"synthetic-native-PBS-secret"}' ;;
esac
`)
if status == 200 {
out, err := runBootstrap(t, artifact.Command, "", env, false)
if err == nil {
t.Fatal("untrusted TLS was accepted")
}
if _, err := os.Stat(filepath.Join(root, "native-argv")); !os.IsNotExist(err) {
t.Fatal("native mutation preceded trusted download")
}
if strings.Contains(string(out), artifact.SetupToken) {
t.Fatal("setup credential leaked on TLS failure")
}
}
env = append(env, "CURL_CA_BUNDLE="+ca)
out, err := runBootstrap(t, artifact.Command, artifact.SetupToken, env, true)
if status == 200 && err != nil {
t.Fatalf("trusted bootstrap: %v\n%s", err, out)
}
if status != 200 && err == nil {
t.Fatalf("HTTP %d appeared successful", status)
}
args, _ := os.ReadFile(filepath.Join(root, "argv"))
if strings.Contains(string(args), artifact.SetupToken) || strings.Contains(string(args), "synthetic-native-PBS-secret") || strings.Contains(string(out), artifact.SetupToken) {
t.Fatal("credential leaked through args or failed-response output")
}
registrationMu.Lock()
defer registrationMu.Unlock()
config.Mu.Lock()
defer config.Mu.Unlock()
if registration.AuthToken != artifact.SetupToken || registration.TokenValue != "synthetic-native-PBS-secret" || registration.Type != "pbs" || registration.Host != server.URL {
t.Fatal("registration lost credential, host or product binding")
}
if status == 200 && len(cfg.PBSInstances) != 1 {
t.Fatal("registration did not create one PBS source")
}
if status != 200 && len(cfg.PBSInstances) != 0 {
t.Fatal("failed registration created a source")
}
})
}
}
@@ -22,20 +22,11 @@ type SetupScriptInstallArtifact struct {
type setupScriptInstallArtifact = SetupScriptInstallArtifact
func BuildSetupScriptCommand(scriptURL string, token string) string {
curlCommand := "curl -fsSL " + posixShellQuote(strings.TrimSpace(scriptURL)) + " | "
bashCommand := "bash"
sudoCommand := "sudo bash"
if trimmedToken := strings.TrimSpace(token); trimmedToken != "" {
envPrefix := "PULSE_SETUP_TOKEN=" + posixShellQuote(trimmedToken) + " "
bashCommand = envPrefix + bashCommand
sudoCommand = "sudo env " + envPrefix + "bash"
}
return curlCommand +
`{ if [ "$(id -u)" -eq 0 ]; then ` + bashCommand +
`; elif command -v sudo >/dev/null 2>&1; then ` + sudoCommand +
`; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`
// The legacy token argument is intentionally not interpolated. All command
// aliases now prompt privately; the separate SetupToken field is the reveal.
func BuildSetupScriptCommand(scriptURL string, _ string) string {
return privateBootstrapCommand(scriptURL, "-fsSL", "",
`PULSE_SETUP_TOKEN_FILE="$token_file" bash "$1";`, true, "Pulse setup token")
}
func BuildSetupScriptURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool) string {
@@ -53,20 +44,9 @@ func BuildSetupScriptURL(baseURL string, installType string, host string, pulseU
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/api/setup-script?" + query.Encode()
}
func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string) string {
downloadURL := BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
trimmedToken := strings.TrimSpace(setupToken)
if trimmedToken == "" {
return downloadURL
}
parsed, err := url.Parse(downloadURL)
if err != nil {
return downloadURL
}
query := parsed.Query()
query.Set("setup_token", trimmedToken)
parsed.RawQuery = query.Encode()
return parsed.String()
// Downloads contain no credential, including in the request URL or script.
func BuildSetupScriptDownloadURL(baseURL string, installType string, host string, pulseURL string, backupPerms bool, _ string) string {
return BuildSetupScriptURL(baseURL, installType, host, pulseURL, backupPerms)
}
func BuildSetupScriptInstallArtifact(baseURL string, installType string, host string, pulseURL string, backupPerms bool, setupToken string, expiresAt int64) SetupScriptInstallArtifact {
+60 -17
View File
@@ -33,11 +33,54 @@ func DeriveSetupScriptServerName(serverHost string) string {
return strings.Split(trimmedHost, ":")[0]
}
// setupTokenFilePrelude reads only a bounded private credential, never an
// argument or exported secret. Legacy downloaded scripts with an embedded
// query token remain readable, but new artifacts do not generate such URLs.
const setupTokenFilePrelude = `set +xv
export -n PULSE_SETUP_TOKEN 2>/dev/null || true
if [ -n "${PULSE_SETUP_TOKEN_FILE:-}" ]; then
setup_token_file="$PULSE_SETUP_TOKEN_FILE"
if [ ! -f "$setup_token_file" ] || [ -L "$setup_token_file" ] || [ ! -r "$setup_token_file" ]; then
echo "Setup token file must be a readable private regular file." >&2
exit 1
fi
setup_token_mode=$(stat -c %a "$setup_token_file" 2>/dev/null || stat -f %Lp "$setup_token_file")
setup_token_owner=$(stat -c %u "$setup_token_file" 2>/dev/null || stat -f %u "$setup_token_file")
setup_token_parent=$(dirname -- "$setup_token_file")
if [ -L "$setup_token_parent" ] || [ "$(stat -c %a "$setup_token_parent" 2>/dev/null || stat -f %Lp "$setup_token_parent")" != "700" ] || [ "$(stat -c %u "$setup_token_parent" 2>/dev/null || stat -f %u "$setup_token_parent")" != "$EUID" ]; then
echo "Setup token file requires a private directory owned by the current user." >&2
exit 1
fi
setup_token_size=$(wc -c < "$setup_token_file")
if [ "$setup_token_mode" != "600" ] || [ "$setup_token_owner" != "$EUID" ] || [ "$setup_token_size" -gt 4096 ]; then
echo "Setup token file must be owned by the current user, mode 0600, and at most 4096 bytes." >&2
exit 1
fi
PULSE_SETUP_TOKEN=$(cat -- "$setup_token_file")
if [[ ! "$PULSE_SETUP_TOKEN" =~ ^[a-fA-F0-9]{32,128}$ ]]; then
echo "Setup token file contains an invalid setup token." >&2
exit 1
fi
unset setup_token_file setup_token_mode setup_token_owner setup_token_size setup_token_parent
fi
`
func RenderSetupScript(serverType string, ctx SetupScriptRenderContext) string {
var script string
if strings.TrimSpace(serverType) == "pve" {
return renderPVESetupScript(ctx)
script = renderPVESetupScript(ctx)
} else {
script = renderPBSSetupScript(ctx)
}
return renderPBSSetupScript(ctx)
script = strings.Replace(script, "#!/bin/bash\n", "#!/bin/bash\n"+setupTokenFilePrelude, 1)
// A rejected setup credential or failed attempted registration is not a
// successful bootstrap. PVE's explicit Audit/Repair exit stays separate.
return script + `
if [ "${SETUP_TOKEN_INVALID:-false}" = true ] || [ "${TOKEN_READY:-false}" != true ] || { [ -n "${PULSE_SETUP_TOKEN:-}" ] && [ "${AUTO_REG_SUCCESS:-false}" != true ]; }; then
exit 1
fi
`
}
func renderPVESetupScript(ctx SetupScriptRenderContext) string {
@@ -488,7 +531,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then
UNREGISTER_RC=$?
if [ "$UNREGISTER_RC" -ne 0 ]; then
echo " ⚠️ Pulse server teardown request failed."
echo " Response: $UNREGISTER_RESPONSE"
echo " Check the Pulse connection and retry; response details are not printed."
echo " Remove the node from Pulse manually if it remains listed."
elif echo "$UNREGISTER_RESPONSE" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"success"'; then
if echo "$UNREGISTER_RESPONSE" | grep -Eq '"removed"[[:space:]]*:[[:space:]]*true'; then
@@ -498,7 +541,7 @@ if [[ $MAIN_ACTION =~ ^(3|[Rr]|remove)$ ]]; then
fi
else
echo " ⚠️ Pulse server teardown did not confirm success."
echo " Response: $UNREGISTER_RESPONSE"
echo " Check the Pulse connection and retry; response details are not printed."
echo " Remove the node from Pulse manually if it remains listed."
fi
else
@@ -684,8 +727,8 @@ create_pve_token() {
}
smoke_test_pve_token() {
if SMOKE_OUTPUT=$(curl -kfsS --retry 2 --retry-delay 1 \
-H "Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE" \
if SMOKE_OUTPUT=$(printf 'Authorization: PVEAPIToken=%%s=%%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl -kfsS --retry 2 --retry-delay 1 \
-H @- \
"${HOST_URL%%/}/api2/json/nodes" 2>&1); then
SMOKE_RC=0
else
@@ -710,13 +753,13 @@ attempt_auto_registration() {
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r PULSE_SETUP_TOKEN
IFS= read -r -s PULSE_SETUP_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf "Pulse setup token: " >/dev/tty
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
IFS= read -r PULSE_SETUP_TOKEN </dev/tty || true
IFS= read -r -s PULSE_SETUP_TOKEN </dev/tty || true
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
printf "\n" >/dev/tty
fi
@@ -753,13 +796,13 @@ attempt_auto_registration() {
AUTO_REG_SUCCESS=false
if [ "$REGISTER_RC" -ne 0 ]; then
echo "⚠️ Auto-registration request failed before success confirmation."
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
@@ -769,7 +812,7 @@ attempt_auto_registration() {
echo ""
else
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
fi
@@ -1682,13 +1725,13 @@ else
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r PULSE_SETUP_TOKEN
IFS= read -r -s PULSE_SETUP_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf "Pulse setup token: " >/dev/tty
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
IFS= read -r PULSE_SETUP_TOKEN </dev/tty || true
IFS= read -r -s PULSE_SETUP_TOKEN </dev/tty || true
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
printf "\n" >/dev/tty
fi
@@ -1731,13 +1774,13 @@ else
:
elif [ "$REGISTER_RC" -ne 0 ]; then
echo "⚠️ Auto-registration request failed before success confirmation."
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
elif [ "$REGISTER_STATUS" = "401" ] || [ "$REGISTER_STATUS" = "403" ]; then
SETUP_TOKEN_INVALID=true
echo "Error: Auto-registration failed - authentication required"
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "The provided Pulse setup token was invalid or expired"
echo "Get a fresh setup token from Pulse Settings → Infrastructure and rerun this script."
@@ -1746,7 +1789,7 @@ else
echo "Successfully registered with Pulse monitoring."
else
echo "⚠️ Auto-registration failed. Finish registration manually in Pulse Settings → Infrastructure."
echo " Response: $REGISTER_RESPONSE"
echo " Registration failed; check the connection and setup-token expiry in Pulse."
echo ""
echo "📝 Use the token details below in Pulse Settings → Infrastructure to finish registration."
fi
@@ -1761,7 +1804,7 @@ proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"
echo ""
echo "✅ Setup complete!"
echo "Setup result:"
if [ "$AUTO_REG_SUCCESS" = true ]; then
echo "Successfully registered with Pulse monitoring."
echo "Data will appear in your dashboard within 10 seconds."
+46 -65
View File
@@ -1504,7 +1504,8 @@ fi`
`pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin`,
`pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin`,
`smoke_test_pve_token() {`,
`Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`,
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$PULSE_TOKEN_ID" "$TOKEN_VALUE" | curl`,
`-H @-`,
`${HOST_URL%/}/api2/json/nodes`,
`if smoke_test_pve_token; then`,
} {
@@ -6907,18 +6908,13 @@ func TestContract_DiagnosticsDockerPrepareTokenPreservesExplicitWorkloadOnlyMode
func TestContract_SetupScriptURLCommandUsesFailFastQuotedTransport(t *testing.T) {
url := "https://pulse.example.com/api/setup-script?type=pve&host=pve1.local"
got := buildSetupScriptCommand(url, "token-123")
if !strings.Contains(got, "curl -fsSL "+posixShellQuote(url)+" | ") {
t.Fatalf("setup-script command missing canonical fail-fast transport: %s", got)
for _, required := range []string{"curl -fsSL " + posixShellQuote(url) + ` -o "$install_script"`, "sudo bash -c", "PULSE_SETUP_TOKEN_FILE="} {
if !strings.Contains(got, required) {
t.Fatalf("missing secure bootstrap fragment %s", required)
}
}
if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) {
t.Fatalf("setup-script command missing direct-root execution path: %s", got)
}
if !strings.Contains(got, `elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN=`+posixShellQuote("token-123")+` bash`) {
t.Fatalf("setup-script command missing sudo execution path: %s", got)
}
if strings.Contains(got, "curl -sSL ") {
t.Fatalf("setup-script command preserved stale non-fail-fast curl transport: %s", got)
if strings.Contains(got, "token-123") || strings.Contains(got, "PULSE_SETUP_TOKEN=") || strings.Contains(got, "| bash") {
t.Fatal("bootstrap credential or partial script can reach the copied command")
}
}
@@ -6942,12 +6938,10 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) {
}
script := rec.Body.String()
if !strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | `) {
t.Fatalf("setup script missing canonical bootstrap command owner: %s", script)
}
if strings.Contains(script, `PULSE_BOOTSTRAP_COMMAND_WITH_ENV='curl -fsSL '"'"'http://sentinel-url:7656/api/setup-script?host=http%3A%2F%2Fsentinel-host%3A8006&pulse_url=http%3A%2F%2Fsentinel-url%3A7656&type=pve'"'"' | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN=`) {
t.Fatalf("setup script bootstrap command should defer setup token to runtime hydration, got: %s", script)
if !strings.Contains(script, "PULSE_BOOTSTRAP_COMMAND_WITH_ENV="+posixShellQuote(buildSetupScriptCommand(buildSetupScriptURL("http://sentinel-url:7656", "pve", "http://sentinel-host:8006", "http://sentinel-url:7656", false), ""))) {
t.Fatal("setup script must use the shared credential-free retry command")
}
if !strings.Contains(script, `echo " $PULSE_BOOTSTRAP_COMMAND_WITH_ENV"`) {
t.Fatalf("setup script missing bootstrap-command retry guidance: %s", script)
}
@@ -10250,7 +10244,7 @@ func TestContract_ProxmoxInstallCommandIncludesInsecureForPlainHTTP(t *testing.T
IncludeInstallType: true,
})
if !strings.Contains(got, "--url "+posixShellQuote("http://pulse.example.com:7655")) {
if !strings.Contains(got, "http://pulse.example.com:7655") {
t.Fatalf("install command missing canonical base URL: %s", got)
}
if !strings.Contains(got, "--insecure") {
@@ -10272,13 +10266,13 @@ func TestContract_ProxmoxInstallCommandUsesPrivilegeEscalationWrapper(t *testing
if !strings.Contains(got, `if [ "$(id -u)" -eq 0 ]; then`) {
t.Fatalf("install command missing root-or-sudo wrapper: %s", got)
}
if !strings.Contains(got, `sudo bash -s --`) {
if !strings.Contains(got, `sudo bash -c`) {
t.Fatalf("install command missing sudo fallback: %s", got)
}
if !strings.Contains(got, `token_dir=$(sudo mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) {
if !strings.Contains(got, `token_dir=$(mktemp -d /tmp/pulse-agent-bootstrap.XXXXXX)`) {
t.Fatalf("install command missing root-owned sudo token bootstrap: %s", got)
}
if !strings.Contains(got, `rm -rf -- "$token_dir"`) {
if !strings.Contains(got, `rmdir -- "$token_dir"`) {
t.Fatalf("install command missing ephemeral token cleanup: %s", got)
}
}
@@ -10294,7 +10288,7 @@ func TestContract_OptionalAuthProxmoxInstallCommandOmitsToken(t *testing.T) {
if strings.Contains(got, "--token") {
t.Fatalf("optional-auth install command preserved token flag: %s", got)
}
if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com")) {
if !strings.Contains(got, "https://pulse.example.com") {
t.Fatalf("optional-auth install command missing canonical base URL: %s", got)
}
}
@@ -10310,7 +10304,7 @@ func TestContract_ProxmoxInstallCommandNormalizesTrailingSlashBaseURL(t *testing
if !strings.Contains(got, posixShellQuote("https://pulse.example.com/base/install.sh")) {
t.Fatalf("install command missing normalized install script URL: %s", got)
}
if !strings.Contains(got, "--url "+posixShellQuote("https://pulse.example.com/base")) {
if !strings.Contains(got, "https://pulse.example.com/base") {
t.Fatalf("install command missing normalized base URL: %s", got)
}
if !strings.Contains(got, `--token-file "$token_file"`) {
@@ -11206,40 +11200,26 @@ func TestContract_ResetFirstRunSecurityClearsEnvBackedStatus(t *testing.T) {
}
func TestContract_SetupScriptURLResponseJSONSnapshot(t *testing.T) {
payload := map[string]any{
"type": "pve",
"host": "https://pve.local:8006",
"url": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve",
"downloadURL": "https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&setup_token=setup-token-123&type=pve",
"scriptFileName": "pulse-setup-pve.sh",
"command": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
"commandWithEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
"commandWithoutEnv": "curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006&pulse_url=https%3A%2F%2Fpulse.example&type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo >/dev/null 2>&1; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" >&2; exit 1; fi; }",
"expires": int64(1900000000),
"setupToken": "setup-token-123",
"tokenHint": "set…123",
}
got, err := json.Marshal(payload)
artifact := buildSetupScriptInstallArtifact("https://pulse.example", "pve", "https://pve.local:8006", "https://pulse.example", false, "setup-token-123", 1900000000)
payload, err := json.Marshal(artifact)
if err != nil {
t.Fatalf("marshal setup-script-url response: %v", err)
t.Fatal(err)
}
var got map[string]any
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatal(err)
}
for _, field := range []string{"command", "commandWithEnv", "commandWithoutEnv"} {
command, ok := got[field].(string)
if !ok || command != artifact.Command || strings.Contains(command, artifact.SetupToken) || !strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") {
t.Fatalf("unsafe %s", field)
}
delete(got, field)
}
want := map[string]any{"type": "pve", "host": "https://pve.local:8006", "url": artifact.URL, "downloadURL": artifact.URL, "scriptFileName": "pulse-setup-pve.sh", "expires": float64(1900000000), "setupToken": "setup-token-123", "tokenHint": "set…123"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("bootstrap envelope = %#v, want %#v", got, want)
}
const want = `{
"command":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
"commandWithEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then PULSE_SETUP_TOKEN='setup-token-123' bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo env PULSE_SETUP_TOKEN='setup-token-123' bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
"commandWithoutEnv":"curl -fsSL 'https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve' | { if [ \"$(id -u)\" -eq 0 ]; then bash; elif command -v sudo \u003e/dev/null 2\u003e\u00261; then sudo bash; else echo \"Root privileges required. Run as root (su -) and retry.\" \u003e\u00262; exit 1; fi; }",
"downloadURL":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026setup_token=setup-token-123\u0026type=pve",
"expires":1900000000,
"host":"https://pve.local:8006",
"scriptFileName":"pulse-setup-pve.sh",
"setupToken":"setup-token-123",
"tokenHint":"set…123",
"type":"pve",
"url":"https://pulse.example/api/setup-script?host=https%3A%2F%2Fpve.local%3A8006\u0026pulse_url=https%3A%2F%2Fpulse.example\u0026type=pve"
}`
assertJSONSnapshot(t, got, want)
}
func TestContract_PublicSecurityStatusIncludesDemoPresentationPolicy(t *testing.T) {
@@ -23296,8 +23276,8 @@ func TestContract_RequestOriginCannotRetargetTokenBearingCommands(t *testing.T)
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Token == "" || !strings.Contains(response.Command, response.Token) {
t.Fatalf("fresh token was not carried by install command: %#v", response)
if response.Token == "" || strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, `--token-file "$token_file"`) {
t.Fatal("fresh credential must be returned separately and entered privately")
}
if !strings.Contains(response.Command, tc.wantBaseURL+"/install.sh") ||
!strings.Contains(response.Command, "--url "+posixShellQuote(tc.wantBaseURL)) {
@@ -23347,9 +23327,10 @@ func TestContract_RequestOriginCannotRetargetTokenBearingCommands(t *testing.T)
t.Fatalf("decode setup artifact: %v", err)
}
if artifact.SetupToken == "" ||
!strings.Contains(artifact.DownloadURL, artifact.SetupToken) ||
!strings.Contains(artifact.Command, artifact.SetupToken) {
t.Fatalf("setup token was not carried by canonical artifact: %#v", artifact)
strings.Contains(artifact.DownloadURL, artifact.SetupToken) ||
strings.Contains(artifact.Command, artifact.SetupToken) ||
artifact.DownloadURL != artifact.URL || !strings.Contains(artifact.Command, "PULSE_SETUP_TOKEN_FILE=") {
t.Fatal("canonical artifact must reveal the setup credential separately from commands and URLs")
}
if !strings.HasPrefix(artifact.URL, autoDetectedURL+"/api/setup-script?") ||
!strings.HasPrefix(artifact.DownloadURL, autoDetectedURL+"/api/setup-script?") {
@@ -24111,7 +24092,7 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode install response: %v", err)
}
if response.Token == "" || !strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, wantBaseURL+"/install.sh") {
if response.Token == "" || strings.Contains(response.Command, response.Token) || !strings.Contains(response.Command, `--token-file "$token_file"`) || !strings.Contains(response.Command, wantBaseURL+"/install.sh") {
t.Fatalf("install response did not bind fresh token to configured URL %q: %#v", wantBaseURL, response)
}
if strings.Contains(response.Command, "attacker") || len(cfg.APITokens) != 2 {
@@ -24143,8 +24124,8 @@ func TestContract_HostedInstallerOriginsFailClosedAtRouter(t *testing.T) {
if downloadRec.Code != http.StatusOK {
t.Fatalf("setup script status = %d, want %d: %s", downloadRec.Code, http.StatusOK, downloadRec.Body.String())
}
if !strings.Contains(downloadRec.Body.String(), wantBaseURL) || !strings.Contains(downloadRec.Body.String(), artifact.SetupToken) || strings.Contains(downloadRec.Body.String(), "attacker") {
t.Fatalf("rendered setup script did not preserve configured token target")
if !strings.Contains(downloadRec.Body.String(), wantBaseURL) || strings.Contains(downloadRec.Body.String(), artifact.SetupToken) || !strings.Contains(downloadRec.Body.String(), "PULSE_SETUP_TOKEN_FILE") || strings.Contains(downloadRec.Body.String(), "attacker") {
t.Fatal("download must preserve the configured target and private-input contract without embedding a setup credential")
}
})
}
@@ -25096,8 +25077,8 @@ func TestContract_HostedRuntimeAgentInstallCommandCarriesToken(t *testing.T) {
if strings.TrimSpace(token) == "" {
t.Fatalf("%s: hosted install payload has no token: %s", body, rec.Body.String())
}
if command, _ := payload["command"].(string); command != "" && !strings.Contains(command, token) {
t.Fatalf("%s: install command does not carry the minted token", body)
if command, _ := payload["command"].(string); command != "" && (strings.Contains(command, token) || !strings.Contains(command, `--token-file "$token_file"`)) {
t.Fatalf("%s: minted credential must be entered privately, not embedded", body)
}
}
}
@@ -61,9 +61,9 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes
require.Equal(t, orgID, resp.OrgID)
require.Contains(t, resp.Command, "https://cloud.example.com/install.sh")
require.Contains(t, resp.Command, "printf %s "+posixShellQuote(resp.Token)+` > "$token_file"`)
require.NotContains(t, resp.Command, resp.Token)
require.Contains(t, resp.Command, `--token-file "$token_file"`)
require.Contains(t, resp.Command, "--proxmox-type "+posixShellQuote("pbs"))
require.Contains(t, resp.Command, "--proxmox-type "+strings.ReplaceAll(posixShellQuote("pbs"), "'", `'"'"'`))
require.Regexp(t, regexp.MustCompile(`^[a-f0-9]{64}$`), resp.Token)
@@ -146,7 +146,7 @@ func TestGenerateHostedTenantAgentInstallCommandEnforcesHostedOrgBoundary(t *tes
require.NotEmpty(t, result.Token)
require.NotEmpty(t, result.TokenID)
require.Contains(t, result.Command, "https://acme.cloud.example.com/install.sh")
require.Contains(t, result.Command, "--proxmox-type "+posixShellQuote("pbs"))
require.Contains(t, result.Command, "--proxmox-type "+strings.ReplaceAll(posixShellQuote("pbs"), "'", `'"'"'`))
tokens, err := persistence.LoadAPITokens()
require.NoError(t, err)
+14 -1
View File
@@ -133,6 +133,12 @@ func validateSetupScriptCommand(command string, expectedScriptURL string, setupT
if !strings.Contains(trimmedCommand, `elif command -v sudo >/dev/null 2>&1; then`) {
return fmt.Errorf("command missing sudo path")
}
// New servers separate reveal from private input. Accept the old coherent
// artifact only for rolling upgrades against an older server; never mix
// its token-bearing URL with the new command format.
if strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN_FILE=") && !strings.Contains(trimmedCommand, setupToken) && !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") {
return nil
}
if tokenRequired {
if !strings.Contains(trimmedCommand, "PULSE_SETUP_TOKEN=") || !strings.Contains(trimmedCommand, setupToken) {
return fmt.Errorf("command missing setup token transport")
@@ -1494,7 +1500,7 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct
parsedURL.RawQuery = query.Encode()
expectedDownloadURL = parsedURL.String()
}
if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL {
if strings.TrimSpace(parsed.DownloadURL) != expectedDownloadURL && strings.TrimSpace(parsed.DownloadURL) != expectedScriptURL {
return "", fmt.Errorf("setup token response downloadURL mismatch")
}
if strings.TrimSpace(parsed.TokenHint) == "" {
@@ -1503,6 +1509,13 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct
if strings.TrimSpace(parsed.TokenHint) == strings.TrimSpace(parsed.SetupToken) {
return "", fmt.Errorf("setup token response tokenHint must mask setupToken")
}
modern := strings.TrimSpace(parsed.DownloadURL) == expectedScriptURL
for _, command := range []string{parsed.Command, parsed.CommandWithEnv, parsed.CommandWithoutEnv} {
if modern != strings.Contains(command, "PULSE_SETUP_TOKEN_FILE=") {
return "", fmt.Errorf("setup-script command and download credential transports do not match")
}
}
if err := validateSetupScriptCommand(parsed.Command, expectedScriptURL, strings.TrimSpace(parsed.SetupToken), true); err != nil {
return "", fmt.Errorf("setup token response command invalid: %w", err)
}
+46
View File
@@ -3,9 +3,11 @@
package hostagent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -1910,3 +1912,47 @@ func TestRunAllFailsWhenEveryDetectedTypeFails(t *testing.T) {
t.Fatalf("results = %#v, want none", results)
}
}
func TestProxmoxSetupFetchTokenAcceptsCoherentPrivateOrLegacyServerTransport(t *testing.T) {
for _, product := range []string{"pve", "pbs"} {
for _, mode := range []string{"private", "legacy", "mixed"} {
t.Run(product+"_"+mode, func(t *testing.T) {
base, host, token := "https://pulse.example", "https://node.example:8006", "setup-token-123"
var artifact map[string]any
if err := json.Unmarshal([]byte(canonicalSetupScriptURLResponseJSON(base, product, host, token)), &artifact); err != nil {
t.Fatal(err)
}
if mode != "legacy" {
command := `curl -fsSL '` + artifact["url"].(string) + `' -o "$install_script"; if [ "$(id -u)" -eq 0 ]; then :; elif command -v sudo >/dev/null 2>&1; then :; fi; PULSE_SETUP_TOKEN_FILE="$token_file" bash "$install_script"`
artifact["downloadURL"] = artifact["url"]
artifact["command"] = command
artifact["commandWithEnv"] = command
artifact["commandWithoutEnv"] = command
if mode == "mixed" {
artifact["commandWithEnv"] = canonicalSetupScriptCommand(artifact["url"].(string), token)
}
}
body, err := json.Marshal(artifact)
if err != nil {
t.Fatal(err)
}
p := &ProxmoxSetup{pulseURL: base, httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/api/setup-script-url" {
t.Errorf("unexpected endpoint %s", req.URL.Path)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body)), Header: make(http.Header)}, nil
})}}
got, err := p.fetchSetupToken(context.Background(), proxmoxProductType(product), host)
if mode == "mixed" {
if err == nil {
t.Fatal("mixed private/legacy credential transport accepted")
}
return
}
if err != nil || got != token {
t.Fatalf("%s metadata refused: %v", mode, err)
}
})
}
}
}
+31
View File
@@ -3,6 +3,7 @@ package mock
import (
"math"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mockmodel"
@@ -218,6 +219,36 @@ type MetricSampler struct {
roles map[string]string
}
// runtimeMetricSampler caches only derived, immutable roles. The graph data
// version changes on both metric ticks and structural updates, so new series
// observe the current canonical fixture while existing samplers remain bound
// to their captured graph. Private graph/global persona updates cannot replace
// this canonical snapshot.
var runtimeMetricSampler struct {
sync.Mutex
version uint64
sampler MetricSampler
}
// CurrentMetricSampler avoids cloning the entire demo estate and resolving
// every resource's persona for every chart series. Graph publication and the
// version read share dataMu; the cached role map is never mutated after return.
func CurrentMetricSampler() MetricSampler {
dataMu.RLock()
defer dataMu.RUnlock()
if !enabled.Load() {
return MetricSampler{}
}
runtimeMetricSampler.Lock()
defer runtimeMetricSampler.Unlock()
version := fixtureDataVersion.Load()
if runtimeMetricSampler.sampler.roles == nil || runtimeMetricSampler.version != version {
runtimeMetricSampler.sampler = NewMetricSampler(mockGraph)
runtimeMetricSampler.version = version
}
return runtimeMetricSampler.sampler
}
func NewMetricSampler(graph FixtureGraph) MetricSampler {
return MetricSampler{roles: buildMetricRoleRegistry(graph)}
}
+118
View File
@@ -1,7 +1,10 @@
package mock
import (
"fmt"
"math"
"reflect"
"sync"
"testing"
"time"
@@ -89,3 +92,118 @@ func TestMetricSamplerRemainsBoundToFixtureGraph(t *testing.T) {
}
}
}
// Keep the graph and its version coherent, just like runtime publication. Do
// not restore an old version: existing readers may still hold its sampler.
func installMetricSamplerGraphForTest(tb testing.TB, graph FixtureGraph) {
tb.Helper()
dataMu.Lock()
previousGraph, previousEnabled := mockGraph, enabled.Load()
mockGraph = graph
enabled.Store(true)
fixtureDataVersion.Add(1)
dataMu.Unlock()
tb.Cleanup(func() {
dataMu.Lock()
mockGraph = previousGraph
enabled.Store(previousEnabled)
fixtureDataVersion.Add(1)
dataMu.Unlock()
})
}
func TestCurrentMetricSamplerTracksGraphVersionWithoutGlobalPersonaLeak(t *testing.T) {
graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}}
installMetricSamplerGraphForTest(t, graph)
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
timestamps := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
initial := CurrentMetricSampler()
want := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps)
if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
t.Fatal("cached sampler differs from graph-bound reference")
}
previousRegistry := currentMetricRoleRegistry()
t.Cleanup(func() { setMetricRoleRegistry(previousRegistry) })
setMetricRoleRegistry(map[string]string{metricRoleRegistryKey("container", "neutral-155"): metricRoleDatabase})
if !reflect.DeepEqual(CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
t.Fatal("private/global persona update replaced canonical graph sampling")
}
if allocs := testing.AllocsPerRun(100, func() { _ = CurrentMetricSampler() }); allocs != 0 {
t.Fatalf("warm sampler acquisition allocates %v times; graph must not be cloned per series", allocs)
}
dataMu.Lock()
mockGraph.State.Containers[0].Name = "database-primary"
fixtureDataVersion.Add(1)
dataMu.Unlock()
updated := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", timestamps)
reference := NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("container", "neutral-155", "memory", timestamps)
if !reflect.DeepEqual(updated, reference) || reflect.DeepEqual(updated, want) {
t.Fatal("new graph version did not refresh its immutable persona snapshot")
}
if !reflect.DeepEqual(initial.SampleMetricSeries("container", "neutral-155", "memory", timestamps), want) {
t.Fatal("publishing a new sampler mutated an already-returned sampler")
}
enabled.Store(false)
if got := CurrentMetricSampler(); got.roles != nil {
t.Fatal("disabled mock mode retained canonical roles")
}
}
func TestCurrentMetricSamplerConcurrentSeriesRemainSnapshotBound(t *testing.T) {
graph := FixtureGraph{State: models.StateSnapshot{Containers: []models.Container{{ID: "neutral-155", Name: "backup-orchestrator"}}}}
backup := NewMetricSampler(graph)
graph.State.Containers[0].Name = "database-primary"
database := NewMetricSampler(graph)
installMetricSamplerGraphForTest(t, graph)
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
first, second := backup.SampleMetricSeries("container", "neutral-155", "memory", times), database.SampleMetricSeries("container", "neutral-155", "memory", times)
var workers sync.WaitGroup
for i := 0; i < 8; i++ {
workers.Add(1)
go func() {
defer workers.Done()
for j := 0; j < 50; j++ {
got := CurrentMetricSampler().SampleMetricSeries("container", "neutral-155", "memory", times)
if !reflect.DeepEqual(got, first) && !reflect.DeepEqual(got, second) {
t.Error("one series mixed two graph revisions")
return
}
}
}()
}
for i := 0; i < 50; i++ {
name := "backup-orchestrator"
if i%2 == 0 {
name = "database-primary"
}
dataMu.Lock()
mockGraph.State.Containers[0].Name = name
fixtureDataVersion.Add(1)
dataMu.Unlock()
}
workers.Wait()
}
func BenchmarkCanonicalMetricSeriesSamplerReuse(b *testing.B) {
graph := FixtureGraph{State: models.StateSnapshot{VMs: make([]models.VM, 1000)}}
for i := range graph.State.VMs {
graph.State.VMs[i] = models.VM{ID: fmt.Sprintf("neutral-%d", i), Name: "database-primary"}
}
installMetricSamplerGraphForTest(b, graph)
at := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
times := []time.Time{at.Add(-time.Minute), at, at.Add(time.Minute)}
_ = CurrentMetricSampler()
b.Run("legacy-cloned-graph-per-series", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = NewMetricSampler(CurrentFixtureGraph()).SampleMetricSeries("vm", "neutral-1", "memory", times)
}
})
b.Run("current-immutable-sampler", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = CurrentMetricSampler().SampleMetricSeries("vm", "neutral-1", "memory", times)
}
})
}
+1 -1
View File
@@ -525,7 +525,7 @@ func normalizeMockMetricTimestamp(at time.Time, interval time.Duration) time.Tim
func canonicalMetricSeries(resourceType, resourceID, metric string, timestamps []time.Time) []float64 {
return canonicalMetricSeriesWithSampler(
mock.NewMetricSampler(mock.CurrentFixtureGraph()),
mock.CurrentMetricSampler(),
resourceType,
resourceID,
metric,
@@ -549,7 +549,7 @@ func TestReleaseControlPlaneFilesExist(t *testing.T) {
"Direction changes must be normalized",
"Do not wait for a special governance prompt",
"stable or GA promotion readiness",
"v6-product-lane-expansion",
"v6-release-reliability",
"v6-ga-promotion",
"v6-rc-stabilization",
"available_candidate_lane_queue",
+23 -5
View File
@@ -1,6 +1,10 @@
package repoctl
import "testing"
import (
"regexp"
"strings"
"testing"
)
func TestSetupBootstrapDocsStayOnCanonicalArtifactContract(t *testing.T) {
apiRel := "docs/API.md"
@@ -17,10 +21,24 @@ func TestSetupBootstrapDocsStayOnCanonicalArtifactContract(t *testing.T) {
pbsRel := "docs/PBS.md"
pbsDoc := readRepoFile(t, pbsRel)
assertContainsAll(t, pbsRel, pbsDoc, []string{
`curl -fsSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="<setup-token>" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN="<setup-token>" bash; else echo "Root privileges required. Run as root (su -) and retry." >&2; exit 1; fi; }`,
"Pulse generates that full command for you from **Settings → Infrastructure**",
"### Method 1: API-Only Connection (Recommended)",
"proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs",
"proxmox-backup-manager acl update / Audit --auth-id 'pulse-monitor@pbs!pulse-token'",
`chmod 600 "$HOME/.config/pulse/pbs-agent-token"`,
`--output "$HOME/.config/pulse/pbs-agent-install.sh"`,
"https://pulse.example.com/install.sh",
`--token-file "$HOME/.config/pulse/pbs-agent-token"`,
"Stop if the download fails",
"Do not bypass certificate checks",
})
assertContainsNone(t, pbsRel, pbsDoc, []string{
`curl -sSL "http://<pulse-ip>:7655/api/setup-script?type=pbs&host=https://<pbs-ip>:8007&pulse_url=http://<pulse-ip>:7655" | bash`,
// Inspect executable fences, not prose that warns against unsafe flags.
commands := strings.Join(regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllString(pbsDoc, -1), "\n")
assertContainsNone(t, pbsRel, commands, []string{
"PULSE_SETUP_TOKEN=",
"sudo env PULSE_SETUP_TOKEN=",
"curl -k",
"--insecure",
`--token "`,
"| bash",
})
}
+41
View File
@@ -176,6 +176,47 @@ links, must be 260 characters or fewer. Use no semicolon or em dash characters.]
section when there is none.]
EOF
# v6.4.6 and later use the current reader-facing format, not the old forced
# outcome/dash list. The renderer still validates the exact version and safety.
if python3 - "$VERSION" <<'PY'
import re, sys
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?", sys.argv[1])
sys.exit(0 if match and tuple(map(int, match.groups())) >= (6, 4, 6) else 1)
PY
then
read -r -d '' NOTE_FORMAT <<EOF || true
# Pulse v${VERSION} Release Notes
[One short paragraph explaining the customer outcome of this release.]
## Highlights
- [One to three plain-text outcomes, each 140 characters or fewer. No markup,
links or issue references in this short list.]
## Proxmox, PBS and backups
- [Plain-language change, where users notice it and why it matters.]
[Choose only relevant change groups: Alerts and notifications, Disks and
storage, Proxmox, PBS and backups, TrueNAS, vSphere and Docker, Install, updates
and agents, Updates and agents, Pulse Pro, AI and hosted, Monitoring and service
health, Security, Other improvements. A narrow patch may use What's improved
instead. Do not create empty groups or repeat the same change between groups.
Each full bullet must be 260 characters or fewer. No semicolons or em dashes.]
## Known issues
[Only unresolved user symptoms with an actionable workaround or next step.
Omit this section when there are none.]
## Before you upgrade
[Only compatibility, signing, companion-app or required operator information.
Omit this section when there is none. No internal verification status.]
EOF
fi
# Strip accidental markdown fences and anything before the release title.
clean_notes() {
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \
@@ -1147,15 +1147,15 @@ func TestCurrentStablePatchReleasePacketTracksInstallMetadata(t *testing.T) {
releaseNotesPath := repoFile("docs", "releases", "RELEASE_NOTES_v"+version+".md")
changelogPath := repoFile("docs", "releases", "V6_CHANGELOG_v"+version+".md")
assertFileContainsAllNormalized(t, releaseNotesPath,
"`v"+version+"` is a stable patch release",
"`v"+previous+"`",
"## What's improved",
"not Authenticode-signed",
"Unknown Publisher warning",
"does not require a companion mobile release",
"rollback target is stable `v"+previous+"`",
)
// The changelog below binds stable maturity and promotion lineage. Public
// notes bind the version and operator safety, not one author's boilerplate.
notes, err := os.ReadFile(releaseNotesPath)
if err != nil {
t.Fatal(err)
}
for _, issue := range stablePatchReleaseNotesIssues(string(notes), version, previous) {
t.Errorf("%s: %s", releaseNotesPath, issue)
}
changelogRequired := make([]string, 0, 7)
changelogRequired = append(changelogRequired,
"Version: `v"+version+"`",
@@ -0,0 +1,93 @@
package installtests
import (
"encoding/json"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/configapi"
)
// Exercise the real install.sh metadata parser with the actual current server
// envelope and coherent older-server envelopes. This does not install a server
// or execute an artifact's command string. Credentials enter Python via FD3.
func TestRootInstallerPrivateBootstrapMetadata(t *testing.T) {
source, err := os.ReadFile(filepath.Join("..", "..", "install.sh"))
if err != nil {
t.Fatal(err)
}
marker := `3<<<"$setup_response" <<'PY'` + "\n"
_, body, ok := strings.Cut(string(source), marker)
if !ok {
t.Fatal("setup parser must receive response on private FD3")
}
parser, _, ok := strings.Cut(body, "\nPY\n")
if !ok {
t.Fatal("setup parser end missing")
}
const pulseURL = "http://10.1.2.3:7655"
const host = "https://pve.example:8006"
const token = "0123456789abcdef0123456789abcdef"
for _, backup := range []bool{false, true} {
for _, transport := range []string{"modern", "legacy", "mixed"} {
t.Run(transport+"_backup_"+strconv.FormatBool(backup), func(t *testing.T) {
artifact := configapi.BuildSetupScriptInstallArtifact(pulseURL, "pve", host, pulseURL, backup, token, time.Now().Add(time.Hour).Unix())
if transport != "modern" {
download, err := url.Parse(artifact.URL)
if err != nil {
t.Fatal(err)
}
query := download.Query()
query.Set("setup_token", token)
download.RawQuery = query.Encode()
artifact.DownloadURL = download.String()
if transport == "legacy" {
command := `curl '` + artifact.URL + `' | { if [ "$(id -u)" -eq 0 ]; then env PULSE_SETUP_TOKEN='` + token + `' bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_SETUP_TOKEN='` + token + `' bash; fi; }`
artifact.Command = command
artifact.CommandWithEnv = command
artifact.CommandWithoutEnv = strings.ReplaceAll(command, "env PULSE_SETUP_TOKEN='"+token+"' ", "")
}
}
data, err := json.Marshal(artifact)
if err != nil {
t.Fatal(err)
}
input := filepath.Join(t.TempDir(), "response")
if err := os.WriteFile(input, data, 0600); err != nil {
t.Fatal(err)
}
file, err := os.Open(input)
if err != nil {
t.Fatal(err)
}
defer file.Close()
cmd := exec.Command("python3", "-", pulseURL, host, strconv.FormatBool(backup))
cmd.Stdin = strings.NewReader(parser)
cmd.ExtraFiles = []*os.File{file}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("parser failed: %v", err)
}
fields := strings.Split(strings.TrimSuffix(string(out), "\n"), "\t")
if len(fields) != 12 {
t.Fatal("unexpected metadata fields")
}
if transport == "mixed" {
if fields[6] != "" || fields[7] != "" || fields[8] != "" {
t.Fatal("mixed transport accepted")
}
return
}
if fields[0] != token || fields[1] != "pve" || fields[2] != host || fields[3] != artifact.URL || fields[4] != artifact.DownloadURL || fields[6] != artifact.Command || fields[7] != artifact.CommandWithEnv || fields[8] != artifact.CommandWithoutEnv || fields[11] != "live" {
t.Fatal("root installer discarded valid bootstrap metadata or token")
}
})
}
}
}
@@ -0,0 +1,110 @@
package installtests
import (
"os"
"regexp"
"strings"
"testing"
)
// These are reader-facing change sections, not internal qualification headings.
var releaseNoteChangeHeadings = map[string]bool{
"what's improved": true, "what’s improved": true,
"alerts and notifications": true, "disks and storage": true,
"proxmox, pbs and backups": true, "truenas, vsphere and docker": true,
"install, updates and agents": true, "updates and agents": true,
"pulse pro, ai and hosted": true, "monitoring and service health": true,
"security": true, "other improvements": true,
}
func stablePatchReleaseNotesIssues(notes, version, previous string) []string {
var issues []string
lines := strings.Split(strings.TrimSpace(notes), "\n")
title := regexp.MustCompile(`^# Pulse v` + regexp.QuoteMeta(version) + `(?: Release Notes)?$`)
if !title.MatchString(strings.TrimSpace(lines[0])) {
issues = append(issues, "release title must name the exact current version")
}
changesSection, hasChanges := false, false
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "## ") {
changesSection = releaseNoteChangeHeadings[strings.ToLower(strings.TrimPrefix(line, "## "))]
} else if strings.HasPrefix(line, "#") {
changesSection = false
} else if changesSection && strings.HasPrefix(line, "- ") && len(strings.TrimSpace(line[2:])) > 0 {
hasChanges = true
}
}
if !hasChanges {
issues = append(issues, "notes need non-empty user-facing change bullets")
}
normalized := strings.ToLower(strings.Join(strings.Fields(notes), " "))
for _, required := range []string{
"not authenticode-signed", "unknown publisher warning",
"rollback target is stable `v" + previous + "`",
} {
if !strings.Contains(normalized, required) {
issues = append(issues, "missing operator safety disclosure: "+required)
}
}
compatibleMobile := strings.Contains(normalized, "does not require a companion mobile release") ||
strings.Contains(normalized, "pulse mobile works with this release unchanged") ||
strings.Contains(normalized, "pulse mobile is being retired on 31 march 2027. it keeps working until then.")
if !compatibleMobile {
issues = append(issues, "missing companion mobile compatibility or approved retirement disclosure")
}
return issues
}
func TestStablePatchReleaseNotesAcceptAuthoredPublishedCopy(t *testing.T) {
// Exact public source b50aeb6a8d9e38ab362b23f4ed15a010f675a90f.
// A fixture is not permission to edit or re-render the published release.
notes, err := os.ReadFile("testdata/release-notes-v6.4.5-authored.md")
if err != nil {
t.Fatal(err)
}
if issues := stablePatchReleaseNotesIssues(string(notes), "6.4.5", "6.4.1"); len(issues) > 0 {
t.Fatal(issues)
}
}
func TestStablePatchReleaseNotesKeepIdentityAndSafety(t *testing.T) {
notes := "# Pulse v6.4.6 Release Notes\n\nPulse updates and History are clearer.\n\n" +
"## What's improved\n\n- History stays with the current host.\n\n" +
"## Before you upgrade\n\nWindows binaries are not Authenticode-signed. " +
"Windows may show an Unknown Publisher warning. " +
"This does not require a companion mobile release. " +
"The rollback target is stable `v6.4.5`.\n"
if issues := stablePatchReleaseNotesIssues(notes, "6.4.6", "6.4.5"); len(issues) > 0 {
t.Fatal(issues)
}
for _, change := range []struct{ name, from, to string }{
{"wrong title", "# Pulse v6.4.6", "# Pulse v6.4.5"},
{"no changes", "- History stays with the current host.", ""},
{"internal changes only", "## What's improved", "## Qualification"},
{"signing omitted", "not Authenticode-signed", "signed"},
{"publisher warning omitted", "Unknown Publisher warning", "warning"},
{"mobile omitted", "does not require a companion mobile release", "requires a new mobile build"},
{"wrong rollback", "rollback target is stable `v6.4.5`", "rollback target is stable `v6.4.1`"},
} {
t.Run(change.name, func(t *testing.T) {
if issues := stablePatchReleaseNotesIssues(strings.ReplaceAll(notes, change.from, change.to), "6.4.6", "6.4.5"); len(issues) == 0 {
t.Fatal("unsafe or mismatched notes accepted")
}
})
}
// Preserve the Markdown title/heading while exercising wrapped safety text.
wrapped := strings.ReplaceAll(notes, "Windows binaries are", "Windows\nbinaries are")
if issues := stablePatchReleaseNotesIssues(wrapped, "6.4.6", "6.4.5"); len(issues) > 0 {
t.Fatal(issues)
}
for _, disclosure := range []string{
"Pulse Mobile works with this release unchanged",
"Pulse Mobile is being retired on 31 March 2027. It keeps working until then.",
} {
candidate := strings.ReplaceAll(notes, "This does not require a companion mobile release", disclosure)
if issues := stablePatchReleaseNotesIssues(candidate, "6.4.6", "6.4.5"); len(issues) > 0 {
t.Fatal(issues)
}
}
}
+11 -9
View File
@@ -583,11 +583,12 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
`str(expires_raw)`,
`expiry_state = "live"`,
`expires_int > int(time.time())`,
`expected_setup_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`,
`expected_download_url = f"{pulse_url}/api/setup-script?host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&setup_token={quote(setup_token, safe='')}&type=pve"`,
`expected_setup_url = f"{pulse_url}/api/setup-script?{backup_query}host={quote(host, safe='')}&pulse_url={quote(pulse_url, safe='')}&type=pve"`,
`expected_download_url = expected_setup_url`,
`setup_download_url not in (expected_download_url, legacy_download_url)`,
`expected_script_name = "pulse-setup-pve.sh"`,
`setup_url != expected_setup_url`,
`setup_download_url != expected_download_url`,
`modern = setup_download_url == expected_download_url`,
`setup_script_name != expected_script_name`,
`not setup_command`,
`not setup_command_with_env`,
@@ -596,8 +597,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
`if not _value or expected_setup_url not in _value:`,
`'if [ "$(id -u)" -eq 0 ]; then' not in _value`,
`'elif command -v sudo >/dev/null 2>&1; then' not in _value`,
`if "PULSE_SETUP_TOKEN=" not in _value or setup_token not in _value:`,
`elif "PULSE_SETUP_TOKEN=" in _value or setup_token in _value:`,
`valid = "PULSE_SETUP_TOKEN_FILE=" in _value and "PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value`,
`valid = "PULSE_SETUP_TOKEN_FILE=" not in _value and (("PULSE_SETUP_TOKEN=" in _value and setup_token in _value) if _requires_token else ("PULSE_SETUP_TOKEN=" not in _value and setup_token not in _value))`,
`not token_hint or token_hint == setup_token`,
`[[ "$setup_type" != "pve" ]]`,
`[[ "$setup_host" != "$normalized_host_url" ]]`,
@@ -609,7 +610,8 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
`[[ -z "$setup_command_without_env" ]]`,
`[[ -z "$setup_token_hint" ]]`,
`[[ "$setup_expiry_state" != "live" ]]`,
`host, token_id, token_value, server_name, setup_token = sys.argv[1:]`,
`host, token_id, server_name = sys.argv[1:]`,
`token_value, setup_token = __import__("os").fdopen(3).read().splitlines()`,
`"tokenId": token_id`,
`"tokenValue": token_value`,
`"authToken": setup_token`,
@@ -625,7 +627,7 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) {
`[[ "$register_status" != "success" ]] || [[ "$register_action" != "use_token" ]] || [[ "$register_type" != "pve" ]] || [[ "$register_source" != "script" ]]`,
`AUTO_NODE_REGISTERED_NAME="$register_node_name"`,
`curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/setup-script-url" -H "Content-Type: application/json" -d "$setup_payload"`,
`curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`,
`printf %s "$register_payload" | curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`,
`token_output=$(pveum user token add pulse-monitor@pve "$token_name" --privsep 1 2>&1)`,
`pveum aclmod / -token "$token_id" -role PVEAuditor`,
`pveum aclmod / -token "$token_id" -role PulseMonitor`,
@@ -1200,7 +1202,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test
for _, needle := range []string{
`smoke_test_pve_auto_register_token() {`,
`curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes"`,
`printf 'Authorization: PVEAPIToken=%s=%s\n' "$token_id" "$token_value" | curl --retry 2 --retry-delay 1 -kfsS -H @- "${host_url%/}/api2/json/nodes"`,
`AUTO_NODE_REGISTER_ERROR="token smoke check failed"`,
`smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`,
} {
@@ -1210,7 +1212,7 @@ func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *test
}
smokeCallIdx := strings.Index(script, `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`)
registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`)
registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d @-`)
if smokeCallIdx < 0 || registerIdx < 0 || smokeCallIdx > registerIdx {
t.Fatalf("expected token smoke check to run before /api/auto-register (smoke=%d register=%d)", smokeCallIdx, registerIdx)
}
@@ -0,0 +1,80 @@
# Pulse v6.4.5
The first stable release since v6.4.1. It brings everyone the fixes that have been in the v6.4.5 previews over the past month: quieter and more accurate alerts, fewer disk writes, working updates and Windows agent installs, and better PBS, TrueNAS, vSphere and Docker support.
## Highlights
- **Far fewer false disk-wear alerts.** Endurance counters no longer raise false disk-wear warnings, disk I/O totals are no longer inflated, and a recovered or excluded disk stops re-alerting (#2112).
- **Stable updates work again.** The updater reads the full release information and offers the right build, including on ARMv6 and ARMv7 (#2282).
- **Windows agent installs and auto-updates work again.** Downloads no longer fail with HTTP 404 (#1820, #2125).
- **Machines with the same name stay separate.** Hosts, Docker, Kubernetes and Proxmox systems that share a short name or token no longer get their alerts, metrics or actions mixed up (#1753, #1930).
- **Less writing to SSD and flash storage.** Pulse no longer rewrites unchanged alert state on every cycle (#1966).
- **Large alert histories no longer freeze Pulse.** They load gradually instead of blocking the server, and a slow start now shows its status with a retry button (#2129, #2146).
## Alerts and notifications
- A problem that continues after its parent recovers now gets its own notification, and grouped alerts no longer repeat (#2336).
- Many alerts recovering at once are grouped into one notification instead of flooding your notification queue (#2160).
- Notifications due at startup wait until your webhook, email and Apprise settings have loaded, so they are no longer dropped (#2160).
- A warning that becomes critical is now delivered, escalations respect current acknowledgements and snoozes, and an automatic acknowledgement survives the alert firing again (#1801, #2173).
- Removed or suppressed resources stop creating alerts (#2237).
- The Alerts overview counts now match the incidents shown, alert-card footers line up, and the Alert Thresholds list keeps every host visible while scrolling (#2119, #2130).
## Disks and storage
- The same USB disk seen by both Proxmox and the agent can now be shown once when one source lacks a stable ID, while ambiguous or conflicting disks stay separate (#2076).
- VMs and containers show the physical disks, SMART and RAID details reported by their linked agent (#2263).
- Long mount paths in the Filesystems panel are shown in full instead of being cut to identical prefixes (#2121).
## Proxmox, PBS and backups
- PBS host and config backups no longer trigger repeated guest backup-age alerts, and a guest backed up locally and copied to PBS is counted once (#1741, #1721, #2136).
- PBS History keeps each host and datastore separate across refreshes (#1723).
- PBS datastore health uses your warning and critical thresholds (#1448).
- Proxmox LXC memory uses the reading from an online Pulse agent inside the container, which excludes the page cache, when the agent's total matches the container's limit (#2148).
- The resource drawer stays on the tab you picked during live refreshes (#1723).
- Your "Verify SSL Certificate" choice for a node or PBS is kept through agent re-registration and cluster changes (#2140).
## TrueNAS, vSphere and Docker
- TrueNAS: AMD temperatures are no longer inflated, TrueNAS CORE memory is read correctly, CPU and memory charts keep their data, and idle sessions poll less often (#2122, #2077, #1893).
- vSphere: enrichment works again with vSphere 8.0.3 and shows the actual API error instead of HTTP 500 (#2070).
- Docker: containers such as PostgreSQL no longer show a false "update available" (#2110).
## Install, updates and agents
- A failed update no longer leaves stale backups behind, and a successful one is no longer reported as failed (#2127, #2128).
- FreeBSD and pfSense agent installs verify checksums without GNU tools, and the copied install command keeps its line breaks (#2123).
- Agents that re-enroll with a changed identity heal cleanly without a burst of events, and a removed agent stays removed (#2113, #1586).
- Upgrades from v5 keep your existing hosts (#1913).
- The upgrade guide warns that on some Proxmox community-script containers `/bin/update` is the community-scripts updater, and shows the signed Pulse installer instead (#2129).
- Previews stuck on the old broken self-updater may need one manual, version-pinned update (#2282).
## Pulse Pro, AI and hosted
- AI Patrol caches its system prompt, so repeated runs cost less (#2118), and a saved Patrol objective whose watcher was lost or rejected is restored instead of going unchecked (#2147).
- AI knowledge saves no longer conflict when made quickly one after another.
- Ollama Basic Auth, the configured-admin setting and security setup choices survive saving and restarting.
- Provider (MSP) installs keep each client's networks separate and client workspaces healthy through upgrades, and agent install tokens are created in the right client workspace (#2247, #2226, #2209).
- Organization owners can reach the settings their organization allows (#2208).
## Other improvements
- Availability views with 20 or more checks open in the fleet view, and your chosen table or fleet view stays put across refreshes and shared links.
- The metrics store drops replayed samples and is more robust across shutdowns, rollups and upgrades.
## Known issues
- **PBS History for a replaced PBS host can reappear.** If a PBS host's identity changes, its old History may show again. No monitoring data is lost. The fix is planned for v6.4.6 (#2343).
- **The update screen can stay on "Downloading update… 10%".** The update still completes in the background. Refresh the page after a minute to see the new version. A fix is planned for v6.4.6.
- Some PBS History layouts that combine the API and an agent are still not fully resolved (#1723).
- Disk writes are much lower but not yet at their final level on busy installs. Keep an eye on write activity if you run on flash storage, and some older duplicate incidents may remain (#1966).
- A small number of v5 to v6 upgrade problems remain for specific setups (#1913).
## Before you upgrade
- Back up your Pulse data directory and configuration, and keep the backup until you have checked everything works.
- This release includes the changes from v6.4.2, which was never published. If you use SSO only, map at least one trusted identity-provider group to the built-in `admin` role before upgrading so you keep admin access.
- Windows Unified Agent binaries are not Authenticode-signed while SignPath remains unavailable, so Windows may show an Unknown Publisher warning. Verify downloads with the published checksums and detached signatures.
- Pulse Mobile works with this release unchanged.
- The rollback target is stable `v6.4.1`. On systemd and Proxmox LXC installs, use `sudo /bin/update --version v6.4.1`. For Docker Compose, pin `rcourtman/pulse:6.4.1` and recreate the container.
@@ -176,6 +176,7 @@ class ReleaseNoteVisualPlanTest(unittest.TestCase):
"Args",
(),
{
"version": "6.4.0",
"rollback_target": "v6.3.2",
"rollback_command": "sudo /bin/update --version v6.3.2",
},
+40 -19
View File
@@ -28,6 +28,13 @@ _CUSTOMER_SECTION_HEADINGS = {
"before you upgrade",
"known issues",
}
_CUSTOMER_CHANGE_GROUPS = {
"alerts and notifications", "disks and storage",
"proxmox, pbs and backups", "truenas, vsphere and docker",
"install, updates and agents", "updates and agents",
"pulse pro, ai and hosted", "monitoring and service health",
"security", "other improvements",
}
_INTERNAL_RELEASE_LANGUAGE_RE = re.compile(
r"\b(?:"
r"readiness assertions?"
@@ -251,16 +258,23 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
f"the release summary must be {_MAX_CUSTOMER_SUMMARY_LENGTH} characters or fewer"
)
# New packets follow the grouped, plain-language release story. Retain the
# historical validator for older packets without rewriting published prose.
grouped = (_release_core(version) or (0, 0, 0)) >= (6, 4, 6)
allowed_headings = _CUSTOMER_SECTION_HEADINGS | (
_CUSTOMER_CHANGE_GROUPS | {"highlights"} if grouped else set()
)
headings: dict[str, int] = {}
for index, line in enumerate(lines):
heading = re.fullmatch(r"##[ \t]+(.+?)\s*", line)
if not heading:
continue
normalized = re.sub(r"\s+", " ", heading.group(1)).lower()
if normalized not in _CUSTOMER_SECTION_HEADINGS:
if normalized not in allowed_headings:
raise ReleaseBodyIntegrityError(
"customer-facing release notes may only use What's improved, "
"Fixes, Before you upgrade, and Known issues sections"
"supported customer change groups, Highlights, Before you upgrade, "
"and Known issues sections"
)
if normalized in headings:
raise ReleaseBodyIntegrityError(
@@ -272,28 +286,35 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
(key for key in ("what's improved", "what’s improved") if key in headings),
None,
)
if improvements_key is None:
change_keys = [key for key in headings if key in _CUSTOMER_CHANGE_GROUPS] if grouped else []
if improvements_key:
change_keys.append(improvements_key)
if not change_keys:
raise ReleaseBodyIntegrityError(
"customer-facing release notes must contain a What's improved section"
"customer-facing release notes must contain a What's improved section "
"or a supported customer change group"
)
improvements = _flat_bullet_items(
_section_lines(text, headings[improvements_key]),
"What's improved",
)
if not improvements:
raise ReleaseBodyIntegrityError(
"What's improved must contain at least one bullet"
)
for item in improvements:
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
seen_changes: set[str] = set()
for key in change_keys:
improvements = _flat_bullet_items(_section_lines(text, headings[key]), key)
if not improvements:
raise ReleaseBodyIntegrityError(
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
)
if not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
raise ReleaseBodyIntegrityError(
"What's improved bullets must start with a short bold outcome followed by a dash"
f"{key} must contain at least one bullet"
)
for item in improvements:
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
raise ReleaseBodyIntegrityError(
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
)
if not grouped and not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
raise ReleaseBodyIntegrityError(
"What's improved bullets must start with a short bold outcome followed by a dash"
)
normalized_item = re.sub(r"\s+", " ", item).casefold()
if grouped and normalized_item in seen_changes:
raise ReleaseBodyIntegrityError("customer change groups must not repeat a change")
seen_changes.add(normalized_item)
if "fixes" in headings:
if _requires_single_change_list(version):
@@ -4,6 +4,8 @@
from __future__ import annotations
import re
import json
import os
import subprocess
import textwrap
import tempfile
@@ -31,6 +33,123 @@ def _discover_rc_draft_packet_paths() -> tuple[str, ...]:
class RenderReleaseBodyTest(unittest.TestCase):
def grouped_notes(self, version: str = "6.4.6-rc.1") -> str:
return f"""# Pulse v{version} Release Notes
PBS History stays with the current host and update progress is clearer.
## Highlights
- Replaced PBS hosts no longer return in History.
- Update progress follows the work until it finishes.
## Proxmox, PBS and backups
- History no longer brings back a replaced PBS host (#2343).
## Updates and agents
- Update progress no longer stays on Downloading 10% after the update completes.
## Known issues
- Some combined API and agent PBS layouts still lack History. Use the API view.
## Before you upgrade
Back up your data directory and keep the backup until you have checked the update.
"""
def test_next_patch_accepts_grouped_plain_language_notes(self) -> None:
for version in ("6.4.6-rc.1", "6.4.6", "6.5.0-rc.1", "6.5.0"):
with self.subTest(version=version):
notes = self.grouped_notes(version)
render_release_body.validate_release_notes_shape(notes, version)
args = type("Args", (), {"version": version,
"rollback_target": "v6.4.5",
"rollback_command": "sudo /bin/update --version v6.4.5"})()
body = "\n\n".join((notes.strip(),
render_release_body.build_installation_section(version),
render_release_body.build_rollback_section(args))) + "\n"
render_release_body.validate_release_body_shape(body, version, expected_body=body)
self.assertIn("## Proxmox, PBS and backups", body)
self.assertEqual(body.count("## Roll back\n"), 1)
self.assertIn("only when `/bin/update` was installed by the Pulse server installer", body)
def test_grouped_notes_keep_structure_and_customer_safety(self) -> None:
valid = self.grouped_notes()
cases = {
"wrong version": valid.replace("v6.4.6-rc.1", "v6.4.5"),
"empty group": valid.replace("- History no longer brings back a replaced PBS host (#2343).", ""),
"internal section": valid.replace("## Updates and agents", "## Release Qualification"),
"internal prose": valid.replace("after the update completes.", "after exact-SHA release gates passed."),
"long change": valid.replace("History no longer brings back a replaced PBS host (#2343).", "x" * 261),
"duplicate section": valid + "\n## Updates and agents\n\n- Another change.\n",
"duplicate change": valid.replace("Update progress no longer stays on Downloading 10% after the update completes.",
"History no longer brings back a replaced PBS host (#2343)."),
"flattened": valid.replace("\n\n## Updates and agents\n\n", " ## Updates and agents "),
"highlights only": "# Pulse v6.4.6-rc.1 Release Notes\n\nHistory is clearer.\n\n## Highlights\n\n- History stays with its host.\n",
}
for name, notes in cases.items():
with self.subTest(name=name):
with self.assertRaises(render_release_body.ReleaseBodyIntegrityError):
render_release_body.validate_release_notes_shape(notes, "6.4.6-rc.1")
def test_metadata_and_renderer_change_group_names_stay_aligned(self) -> None:
go_source = (_REPO_ROOT / "scripts/installtests/release_notes_contract_test.go").read_text()
group_map = go_source.split("var releaseNoteChangeHeadings = map[string]bool{", 1)[1].split("}", 1)[0]
groups = set(re.findall(r'"([^"]+)":\s*true', group_map))
self.assertEqual(groups, render_release_body._CUSTOMER_CHANGE_GROUPS | {"what's improved", "what’s improved"})
def test_generator_prompts_and_renderer_agree_on_grouped_notes(self) -> None:
# Invoke the actual shell authoring path with a deterministic, local
# model double. No model service, credentials or network are used.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
scripts = root / "scripts/release_control"
scripts.mkdir(parents=True)
(scripts / "render_release_body.py").write_text(
(_REPO_ROOT / "scripts/release_control/render_release_body.py").read_text())
def git(*args: str) -> None:
subprocess.run(["git", "-c", "user.name=Release Note Test",
"-c", "user.email=notes@example.invalid", *args], cwd=root,
check=True, capture_output=True)
git("init", "-b", "main")
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "previous stable")
git("tag", "v6.4.5")
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "candidate changes")
bin_dir = root / "bin"
bin_dir.mkdir()
model = bin_dir / "codex"
model.write_text("""#!/usr/bin/env python3
import json, os, pathlib, sys
args = sys.argv[1:]
assert 'OPENAI_API_KEY' not in os.environ
with open(os.environ['NOTE_TEST_PROMPTS'], 'a') as trace:
trace.write(json.dumps(args[-1]) + '\\n')
pathlib.Path(args[args.index('-o') + 1]).write_text(pathlib.Path(os.environ['NOTE_TEST_NOTES']).read_text())
""")
model.chmod(0o755)
notes = root / "notes.md"
notes.write_text(self.grouped_notes())
prompts = root / "prompts.jsonl"
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
"NOTE_TEST_NOTES": str(notes), "NOTE_TEST_PROMPTS": str(prompts),
"OPENAI_API_KEY": "synthetic-value-must-be-scrubbed"}
for key in ("SAVE_TO_FILE", "RELEASE_NOTES_TRACE_DIR", "RELEASE_NOTE_VISUAL_PLAN_FILE"):
env.pop(key, None)
result = subprocess.run(["bash", str(_REPO_ROOT / "scripts/generate-release-notes.sh"),
"6.4.6-rc.1", "v6.4.5"], cwd=root, env=env,
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, notes.read_text())
recorded = [json.loads(line) for line in prompts.read_text().splitlines()]
self.assertEqual(len(recorded), 4) # research, draft, review, omission
for prompt in recorded[1:]:
self.assertIn("## Highlights", prompt)
self.assertIn("## Proxmox, PBS and backups", prompt)
self.assertIn("No internal verification status", prompt)
def test_v642_security_packet_keeps_both_admin_boundaries_visible(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.2.md"
@@ -489,6 +489,8 @@ if module.MAX_RECEIPT_BYTES <= 0:
def test_manifest_contract_binds_transitive_harness_and_production_boundary(self) -> None:
manifest = json.loads((Path(__file__).with_name("secure_runtime_rootful_source_manifest_v1.json")).read_text(encoding="utf-8"))
required = {
"scripts/installtests/release_notes_contract_test.go",
"scripts/installtests/testdata/release-notes-v6.4.5-authored.md",
"scripts/installtests/secure_runtime_rootful_qualification_test.go",
"scripts/installtests/secure_runtime_rootless_qualification_test.go",
"scripts/installtests/secure_runtime_systemd_lab_test.go",
@@ -506,6 +508,7 @@ if module.MAX_RECEIPT_BYTES <= 0:
}
self.assertTrue(compiled_test_inputs.issubset(manifest["exact_paths"]))
recursive_roots = set(manifest["recursive_roots"])
unbound_dependencies = set()
for target, include_tests in (
("./scripts/installtests", True),
("./cmd/pulse-agent", False),
@@ -527,12 +530,16 @@ if module.MAX_RECEIPT_BYTES <= 0:
if not raw_directory or directory == repo_root / "scripts" / "installtests":
continue
relative = directory.relative_to(repo_root).as_posix()
self.assertTrue(
any(relative == root or relative.startswith(root + "/") for root in recursive_roots),
f"compiled dependency for {target} is outside the source manifest: {relative}",
)
if not any(relative == root or relative.startswith(root + "/") for root in recursive_roots):
unbound_dependencies.add(f"{target}: {relative}")
self.assertFalse(
unbound_dependencies,
"compiled dependencies outside the source manifest: " + ", ".join(sorted(unbound_dependencies)),
)
self.assertIn("internal/agenthelper", manifest["recursive_roots"])
self.assertIn("pkg/auth", manifest["recursive_roots"])
self.assertIn("internal/filesystemprobe", manifest["recursive_roots"])
self.assertIn("pkg/agents/filesystem", manifest["recursive_roots"])
if __name__ == "__main__":
@@ -28,12 +28,14 @@
"scripts/installtests/provider_msp_deploy_test.go",
"scripts/installtests/pulse_auto_update_test.go",
"scripts/installtests/release_ldflags_test.go",
"scripts/installtests/release_notes_contract_test.go",
"scripts/installtests/root_install_sh_test.go",
"scripts/installtests/safe_profile_migration_test.go",
"scripts/installtests/secure_runtime_platform_matrix_test.go",
"scripts/installtests/secure_runtime_rootful_qualification_test.go",
"scripts/installtests/secure_runtime_rootless_qualification_test.go",
"scripts/installtests/secure_runtime_systemd_lab_test.go",
"scripts/installtests/testdata/release-notes-v6.4.5-authored.md",
"scripts/installtests/testdata/secure_runtime_docker_fixture.go",
"scripts/installtests/umask_unix_test.go",
"scripts/installtests/uninstall_sensor_proxy_test.go",
@@ -61,6 +63,7 @@
"internal/config",
"internal/crypto",
"internal/dockeragent",
"internal/filesystemprobe",
"internal/hostagent",
"internal/hostmetrics",
"internal/kubernetesagent",
@@ -90,6 +93,7 @@
"internal/vmware",
"pkg/auth",
"pkg/agents/docker",
"pkg/agents/filesystem",
"pkg/agents/host",
"pkg/agents/kubernetes",
"pkg/aicontracts",
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Execute the administration guides with synthetic credentials and loopback.
Run with pulse-worker-source-proof; these fixtures do not perform real role,
tenant or audit operations. The Go documentation tests check the actual handlers.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import stat
import tempfile
import unittest
from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server
DOCS = ROOT / "docs"
GUIDES = ("RBAC", "AUDIT_LOGGING", "MULTI_TENANT")
EXPECTED = {
"RBAC": (
("POST", "/api/admin/roles", {
"id": "alert-manager", "name": "Alert Manager",
"description": "Can view and manage alerts",
"permissions": [{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"}],
}),
("GET", "/api/admin/roles", None),
("PUT", "/api/admin/roles/alert-manager", {
"name": "Alert Manager", "description": "Updated description",
"permissions": [{"action": "read", "resource": "alerts"},
{"action": "write", "resource": "alerts"},
{"action": "read", "resource": "nodes"},
{"action": "read", "resource": "ai"}],
}),
("DELETE", "/api/admin/roles/alert-manager", None),
("GET", "/api/admin/users", None),
("PUT", "/api/admin/users/jane/roles", {"roleIds": ["alert-manager", "viewer"]}),
("PUT", "/api/admin/users/jane/roles", {"roleIds": []}),
("DELETE", "/api/admin/users/jane", None),
),
"AUDIT_LOGGING": (
("GET", "/api/audit?limit=50", None),
("GET", "/api/audit?event=login&startTime=2026-01-01T00:00:00Z&endTime=2026-01-31T23:59:59Z&success=false", None),
("GET", "/api/audit/summary", None),
("GET", "/api/audit/export", None),
("GET", "/api/audit/6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45/verify", None),
),
"MULTI_TENANT": (
("GET", "/api/orgs/production-datacenter/members", None),
("GET", "/api/orgs/production-datacenter/shares/incoming", None),
),
}
def recipes(name: str) -> list[str]:
requests = []
for block in re.findall(r"```bash\n(.*?)```", (DOCS / f"{name}.md").read_text(), re.DOTALL):
# The audit read examples share a fence but are independent operations.
for step in re.split(r"\n(?=# )", block):
if "curl " in step:
requests.append(step)
return requests
class AdminDocsTest(unittest.TestCase):
def test_shell_recipes_never_expose_credentials_or_override_tls(self):
for name in GUIDES:
with self.subTest(guide=name):
steps = recipes(name)
self.assertEqual(len(steps), len(EXPECTED[name]))
for step in steps:
self.assertNotRegex(step, r"(?:\b\w*TOKEN=|Authorization:|X-API-Token:|Bearer\s|--cookie\b)")
self.assertNotRegex(step, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)")
self.assertIn('curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header"', step)
if re.search(r"--request (POST|PUT)", step):
self.assertIn("--data-binary @-", step)
self.assertIn("<<'JSON'", step)
def test_guides_explain_authority_and_sensitive_output(self):
for name in GUIDES:
guide = (DOCS / f"{name}.md").read_text()
with self.subTest(guide=name):
self.assertIn("API.md#-authentication", guide)
self.assertIn("HTTPS", guide)
self.assertIn("certificate verification", guide)
rbac = (DOCS / "RBAC.md").read_text()
for boundary in ("full-access (`*`)", "bound to an authorised administrator", "cannot be modified or deleted", "self-escalation", "complete list", "URL-encode"):
self.assertIn(boundary, rbac)
audit = (DOCS / "AUDIT_LOGGING.md").read_text()
for boundary in ("audit:read", "audit_logging", "private directory", "redacted error", "does not reuse filters"):
self.assertIn(boundary, audit)
org = (DOCS / "MULTI_TENANT.md").read_text()
for boundary in ("session-based user authentication", "403 session_required", "CSRF", "settings:read", "organization binding", "pending invitation", "accept the share", "accessRole"):
self.assertIn(boundary, org)
self.assertNotRegex(org, r"\| `PATCH` \|")
def test_shipped_copies_are_identical(self):
for name in (*GUIDES, "API"):
with self.subTest(guide=name):
self.assertEqual((DOCS / f"{name}.md").read_bytes(),
(ROOT / "frontend-modern/public/docs" / f"{name}.md").read_bytes())
def test_all_recipes_send_exact_methods_paths_and_bodies_with_each_header(self):
with recording_server() as (port, requests):
for name, operations in EXPECTED.items():
steps = recipes(name)
self.assertEqual(len(steps), len(operations))
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
for step, (method, path, body) in zip(steps, operations):
with self.subTest(guide=name, method=method, path=path, header=key), tempfile.TemporaryDirectory(prefix="admin docs ") as temporary:
before = len(requests)
result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, step)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(len(requests), before + 1, "a copied step sends exactly one request")
sent_path, headers, sent_method, sent_body = requests[-1]
self.assertEqual((sent_method, sent_path), (method, path))
self.assertEqual(headers[key], value)
self.assertNotIn("X-Curlrc-Injected", headers)
self.assertNotIn("Authorization" if key == "X-API-Token" else "X-API-Token", headers)
if body is None:
self.assertEqual(sent_body, b"")
else:
self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(json.loads(sent_body), body)
def test_every_recipe_surfaces_auth_and_license_errors_without_next_steps(self):
for status in (401, 402, 403):
with recording_server(status) as (port, requests):
for name in GUIDES:
for step in recipes(name):
with self.subTest(status=status, guide=name), tempfile.TemporaryDirectory() as temporary:
before = len(requests)
result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, step)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertEqual(len(requests), before + 1)
self.assertNotIn(b"Saved private export", result.stdout)
# curl preserves an error body, in stdout or the requested file.
exports = list(Path(temporary).glob("*/audit-export.json"))
output = exports[0].read_bytes() if exports else result.stdout
self.assertIn(b'{"fixture":true}', output)
def test_audit_export_creates_a_private_new_directory_and_file(self):
step = next(step for step in recipes("AUDIT_LOGGING") if "/api/audit/export" in step)
with recording_server() as (port, _), tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
for _ in range(2):
result = exercise_curl(self, home, f"X-API-Token: {TEST_TOKEN}", port, step)
self.assertEqual(result.returncode, 0, result.stderr.decode())
output = Path(result.stdout.decode().strip().removeprefix("Saved private export to "))
self.assertEqual(output.parent.parent, home)
self.assertEqual(stat.S_IMODE(output.parent.stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
self.assertEqual(json.loads(output.read_bytes()), {"fixture": True})
self.assertEqual(len(list(home.glob("*/audit-export.json"))), 2)
if __name__ == "__main__":
unittest.main()
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Exercise documented agent setup without installing a service or real token.
TLS listeners are guest-local: run with pulse-worker-source-proof. Windows
checks cover the documented source contract, not native ACL/service execution.
"""
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import tempfile
import unittest
from test_pbs_docs import certificate, fixture_environment, server
ROOT = Path(__file__).resolve().parents[2]
NAMES = ("UNIFIED_AGENT.md", "TEMPERATURE_MONITORING.md")
TOKEN = "synthetic-agent-docs-secret"
CLEANUP_PATH = "/rcourtman/Pulse/main/scripts/uninstall-sensor-proxy.sh"
def blocks(name, language="bash"):
text = re.sub(r"^ {1,3}", "", (ROOT / "docs" / name).read_text(), flags=re.MULTILINE)
return re.findall(r"```" + language + r"\n(.*?)```", text, re.DOTALL)
def recipe(name, needle):
found = [block for block in blocks(name) if needle in block]
if len(found) != 1:
raise AssertionError(f"expected one {name} recipe containing {needle!r}")
return found[0]
class AgentDocsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory()
cls.cert, cls.key = certificate(Path(cls.temporary.name), "agent-docs")
cls.other_cert, _ = certificate(Path(cls.temporary.name), "untrusted")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_mirrors_and_no_secret_or_unchecked_bootstrap_commands(self):
for name in NAMES:
with self.subTest(name=name):
doc = (ROOT / "docs" / name).read_bytes()
self.assertEqual(doc, (ROOT / "frontend-modern/public/docs" / name).read_bytes())
shell = "\n".join(blocks(name))
self.assertNotRegex(shell, r"--token(?:\s|=)|PULSE_TOKEN=|curl[^\n]*http://")
self.assertNotRegex(shell, r"curl[^`]*\|\s*(?:sudo\s+)?bash")
self.assertIn("Stop if the download fails", doc.decode())
windows = "\n".join(blocks(NAMES[0], "powershell"))
self.assertNotRegex(windows, r"(?i)\biex\b|\birm\b|Invoke-Expression|http://|\$env:PULSE_TOKEN\s*=")
def test_changed_unix_recipes_parse(self):
count = 0
for name in NAMES:
for block in blocks(name):
if any(word in block for word in ("agent-install.sh", "agent-token", "sensor-proxy-uninstall.sh")):
with self.subTest(name=name, command=block):
subprocess.run(["bash", "-n", "-c", block], check=True, capture_output=True)
count += 1
self.assertEqual(count, 18)
def test_private_file_preparation_preserves_existing_token(self):
command = recipe(NAMES[0], 'vi "$HOME/.config/pulse/agent-token"')
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tools = home / "tools"
tools.mkdir()
editor = tools / "vi"
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n')
editor.chmod(0o700)
env = fixture_environment(home)
env["PATH"] = f"{tools}:{os.environ['PATH']}"
token = home / ".config/pulse/agent-token"
for existing in (False, True):
if existing:
token.write_text(TOKEN + "\n")
token.chmod(0o644)
token.parent.chmod(0o755)
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(stat.S_IMODE(token.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(token.parent.stat().st_mode), 0o700)
self.assertEqual(token.read_text(), TOKEN + "\n" if existing else "")
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
def test_windows_file_handoff_matches_installer_contract(self):
windows = "\n".join(blocks(NAMES[0], "powershell"))
for expected in ("-ErrorAction Stop", "/inheritance:r", "*${userSid}:(OI)(CI)F",
"*S-1-5-18:(OI)(CI)F", "$LASTEXITCODE -ne 0",
"-FilePath notepad.exe", "-Wait", "-OutFile $installerFile",
"Remove-Item Env:PULSE_TOKEN", "-TokenFile $tokenFile"):
self.assertIn(expected, windows)
source = (ROOT / "scripts/install.ps1").read_text()
self.assertIn('[string]$TokenFile = $env:PULSE_TOKEN_FILE,', source)
self.assertIn('if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile))', source)
self.assertIn('(Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()', source)
def download(self, home, command, port, ca=None, hostname="localhost"):
private = home / ".config/pulse"
private.mkdir(parents=True, exist_ok=True, mode=0o700)
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
"Path(os.environ['CURL_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
env = fixture_environment(home)
env.update(PATH=f"{tools}:{os.environ['PATH']}", REAL_CURL=shutil.which("curl"),
CURL_RECEIPT=str(home / "curl-argv.json"))
command = command.replace("https://pulse.example.com", f"https://{hostname}:{port}")
command = command.replace("https://raw.githubusercontent.com", f"https://{hostname}:{port}")
if ca is not None:
command = command.replace("curl ", f'curl --cacert "{ca}" ', 1)
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=20)
argv = json.loads((home / "curl-argv.json").read_text())
self.assertNotIn(TOKEN, " ".join(argv))
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
for argument in ("--connect-timeout", "--max-time", "--fail", "--output"):
self.assertIn(argument, argv)
self.assertNotIn("--insecure", argv)
self.assertNotIn("-k", argv)
return result
def test_downloads_require_verified_tls_and_success(self):
for name, path, needle, output in (
(NAMES[0], "/install.sh", '--output "$HOME/.config/pulse/agent-install.sh"', "agent-install.sh"),
(NAMES[1], CLEANUP_PATH, '--output "$HOME/.config/pulse/sensor-proxy-uninstall.sh"', "sensor-proxy-uninstall.sh"),
):
for status, ca, hostname, expected in (
(200, self.cert, "localhost", 0),
(403, self.cert, "localhost", 22),
(200, None, "localhost", 60),
(200, self.other_cert, "localhost", 60),
(200, self.cert, "127.0.0.1", 60),
):
with self.subTest(name=name, status=status, expected=expected):
with tempfile.TemporaryDirectory() as temporary:
with server(self.cert, self.key, status, b"# harmless installer fixture\n", path) as (port, requests):
home = Path(temporary)
result = self.download(home, recipe(name, needle), port, ca, hostname)
self.assertEqual(result.returncode, expected, result.stderr.decode())
if expected == 0:
self.assertEqual((home / ".config/pulse" / output).read_bytes(), b"# harmless installer fixture\n")
if expected == 60:
self.assertEqual(requests, [])
for requested_path, headers in requests:
self.assertEqual(requested_path, path)
self.assertNotIn("Authorization", headers)
def test_every_profile_passes_only_private_file_not_token_arguments(self):
profiles = []
for name in NAMES:
profiles.extend(block for block in blocks(name) if 'bash "$HOME/.config/pulse/agent-install.sh"' in block and "--token-file" in block)
self.assertEqual(len(profiles), 11)
fixture = '''#!/bin/bash
python3 - "$@" <<'PY'
import json, os, stat, sys
from pathlib import Path
args = sys.argv[1:]
token = Path(args[args.index('--token-file') + 1])
assert args.count('--token-file') == 1
assert token.read_text().strip() == os.environ['EXPECTED_TOKEN']
assert stat.S_IMODE(token.stat().st_mode) == 0o600
assert os.environ['EXPECTED_TOKEN'] not in ' '.join(args)
assert '--token' not in args
Path(os.environ['INSTALL_RECEIPT']).write_text(json.dumps(args))
PY
'''
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
private = home / ".config/pulse"
private.mkdir(parents=True, mode=0o700)
(private / "agent-install.sh").write_text(fixture)
token = private / "agent-token"
token.write_text(TOKEN + "\n")
token.chmod(0o600)
env = fixture_environment(home)
env.update(EXPECTED_TOKEN=TOKEN, INSTALL_RECEIPT=str(home / "installer-argv.json"))
for command in profiles:
with self.subTest(command=command):
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr.decode())
args = json.loads((home / "installer-argv.json").read_text())
self.assertEqual(args[:4], ["--url", "https://pulse.example.com", "--token-file", str(token)])
self.assertNotIn(TOKEN.encode(), result.stdout + result.stderr)
expected_profiles = [[], ["--enable-proxmox", "--proxmox-type", "pve"],
["--enable-proxmox", "--proxmox-type", "pbs"],
["--enable-docker"], ["--enable-docker=false"],
["--enable-kubernetes"], ["--enable-host=false", "--enable-docker"],
["--disable-auto-update"], ["--enable-proxmox"]]
actual_profiles = [re.findall(r"--(?:enable-[a-z]+(?:=false)?|disable-auto-update|proxmox-type\s+(?:pve|pbs))", p) for p in profiles]
for expected in expected_profiles:
# Compare the actual argv tokens, retaining the type's value.
self.assertIn(" ".join(expected), [" ".join(a) for a in actual_profiles])
source = (ROOT / "scripts/install.sh").read_text()
self.assertIn('--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;', source)
self.assertIn('read_collector_token_file_safely "$TOKEN_FILE_PATH" true', source)
def test_retarget_uninstall_and_cleanup_use_saved_script_without_new_token(self):
expected = (["--retarget", "--url", "https://pulse.example.com"], ["--uninstall"],
["--uninstall", "--purge", "--local-only"],
["--uninstall", "--purge", "--remove-proxmox-access", "--local-only"])
commands = [recipe(NAMES[0], "--retarget --url"), recipe(NAMES[0], 'agent-install.sh" --uninstall'),
recipe(NAMES[1], "--uninstall --purge --local-only"),
recipe(NAMES[1], "--uninstall --purge --remove-proxmox-access")]
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
private = home / ".config/pulse"
private.mkdir(parents=True, mode=0o700)
fixture = '#!/bin/bash\npython3 - "$@" <<\'PY\'\nimport json, os, sys\nfrom pathlib import Path\nPath(os.environ["INSTALL_RECEIPT"]).write_text(json.dumps(sys.argv[1:]))\nPY\n'
for name in ("agent-install.sh", "sensor-proxy-uninstall.sh"):
(private / name).write_text(fixture)
env = fixture_environment(home)
env["INSTALL_RECEIPT"] = str(home / "argv.json")
for command, args in zip(commands, expected):
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(json.loads((home / "argv.json").read_text()), args)
if __name__ == "__main__":
unittest.main()
+141 -30
View File
@@ -37,17 +37,53 @@ def commands() -> tuple[str, str]:
return preparation, request
def recipe(path: str) -> str:
matches = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
if path in block]
if len(matches) != 1:
raise AssertionError(f"expected one executable recipe for {path}")
return matches[0]
# Independently expected HTTP operations: extracting the shell from the guide
# ensures the fixture tests what readers copy, not a second hand-written client.
FLEET_ACTION_REQUESTS = (
("GET", "/api/connections", None),
("GET", "/api/agent/resource-capabilities/vm%3A42", None),
("POST", "/api/actions/plan", {
"requestId": "manual-recovery-123", "resourceId": "vm:42",
"capabilityName": "restart", "params": {"mode": "graceful"},
"reason": "Recover after confirmed outage",
}),
("POST", "/api/actions/act_.../decision", {
"outcome": "approved", "reason": "Inside maintenance window", "planHash": "sha256:...",
}),
("POST", "/api/actions/act_.../execute", {
"reason": "Execute approved recovery", "planHash": "sha256:...",
}),
("GET", "/api/audit/actions?resourceId=vm%3A42&limit=10", None),
("GET", "/api/audit/actions/act_.../events", None),
)
@contextmanager
def recording_server(status: int = 200):
requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
requests.append((self.path, dict(self.headers)))
def record_request(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
requests.append((self.path, dict(self.headers), self.command, body))
self.send_response(status)
self.end_headers()
self.wfile.write(b'{"fixture":true}\n')
do_GET = record_request
do_POST = record_request
do_PUT = record_request
do_PATCH = record_request
do_DELETE = record_request
def log_message(self, *_args):
pass
@@ -62,6 +98,48 @@ def recording_server(status: int = 200):
thread.join(timeout=5)
def exercise_curl(case: unittest.TestCase, home: Path, header_text: str, port: int, request: str):
"""Execute the exact copied recipe; capture argv and hostile curl defaults."""
header = home / ".config/pulse/api-header"
header.parent.mkdir(parents=True, exist_ok=True)
header.write_text(header_text + "\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
case.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"from pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
trace = home / "curl-trace.txt"
# An existing curl configuration must not turn safe argv into a trace
# containing the header file's credential, or inject another header.
(home / ".curlrc").write_text(
f'header = "X-Curlrc-Injected: yes"\nverbose\ntrace-ascii = "{trace}"\n'
)
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
CURL_HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"), TMPDIR=str(home),
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
for key in list(env):
if key.lower().endswith("_proxy"):
del env[key]
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
argv = json.loads(receipt.read_text())
case.assertEqual(argv[0], "--disable", "curl defaults must be disabled by the first option")
case.assertNotIn(TEST_TOKEN, " ".join(argv))
case.assertIn("@" + str(header), argv)
case.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
case.assertFalse(trace.exists(), "local curl configuration must not create a credential trace")
return result
class APIAuthDocsTest(unittest.TestCase):
def test_credentials_are_not_documented_as_command_arguments(self):
section = auth_section()
@@ -73,6 +151,30 @@ class APIAuthDocsTest(unittest.TestCase):
self.assertIn("monitoring:read", section)
self.assertIn("Do not paste tokens", section)
def test_all_shell_recipes_keep_credentials_out_of_arguments_and_defaults(self):
bash = "\n".join(re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL))
self.assertNotRegex(bash, r"(?:\b\w*TOKEN=|X-API-Token:|Authorization:|Bearer\s)")
self.assertNotRegex(bash, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)")
requests = [block for block in re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL)
if block.startswith("curl ")]
self.assertEqual(len(requests), 8)
for request in requests:
self.assertTrue(request.startswith("curl --disable --fail-with-body "))
self.assertIn('--header "@$HOME/.config/pulse/api-header"', request)
if "--request POST" in request:
self.assertIn("--data-binary @-", request)
self.assertIn("<<'JSON'", request)
def test_action_guidance_preserves_scope_and_review_boundaries(self):
guide = DOC.read_text()
for scope in ("monitoring:read", "settings:read", "actions:plan", "actions:approve",
"actions:execute", "audit:read"):
self.assertIn(scope, guide)
for boundary in ("steps separately", "bound to an authorised user", "licensed audit logging",
"separation of duties", "step-up", "losing the connection",
"terminal result", "planHash", "URL-encode"):
self.assertIn(boundary, guide)
def test_shipped_copy_is_identical(self):
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/API.md").read_bytes())
@@ -97,34 +199,8 @@ class APIAuthDocsTest(unittest.TestCase):
self.assertEqual(stat.S_IMODE(header.stat().st_mode), 0o600)
self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "")
def run_documented_request(self, home: Path, header_text: str, port: int):
_, request = commands()
header = home / ".config/pulse/api-header"
header.parent.mkdir(parents=True, exist_ok=True)
header.write_text(header_text + "\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
self.assertIsNotNone(real_curl, "curl is required to exercise the documented command")
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"from pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}",
REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
request = request.replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}")
result = subprocess.run(["bash", "-eu", "-c", request], env=env, capture_output=True, timeout=10)
argv = json.loads(receipt.read_text())
self.assertNotIn(TEST_TOKEN, " ".join(argv))
self.assertIn("@" + str(header), argv)
self.assertNotIn(b"synthetic-doc-test-token", result.stdout + result.stderr)
return result
def run_documented_request(self, home: Path, header_text: str, port: int, request=None):
return exercise_curl(self, home, header_text, port, request if request is not None else commands()[1])
def test_header_file_sends_each_supported_header_without_exposing_argv(self):
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):
@@ -134,6 +210,41 @@ class APIAuthDocsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(requests[-1][0], "/api/state/summary")
self.assertEqual(requests[-1][1][key], value)
self.assertNotIn("X-Curlrc-Injected", requests[-1][1])
def test_fleet_action_recipes_send_expected_methods_paths_and_json(self):
with tempfile.TemporaryDirectory() as temporary, recording_server() as (port, requests):
for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)):
for method, path, body in FLEET_ACTION_REQUESTS:
with self.subTest(header=key, method=method, path=path):
before = len(requests)
result = self.run_documented_request(Path(temporary), f"{key}: {value}", port,
recipe(path))
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(len(requests), before + 1, "each step sends only its own request")
sent_path, headers, sent_method, sent_body = requests[-1]
self.assertEqual((sent_method, sent_path), (method, path))
self.assertEqual(headers[key], value)
other_key = "Authorization" if key == "X-API-Token" else "X-API-Token"
self.assertNotIn(other_key, headers)
self.assertNotIn("X-Curlrc-Injected", headers)
if body is None:
self.assertEqual(sent_body, b"")
else:
self.assertEqual(headers["Content-Type"], "application/json")
self.assertEqual(json.loads(sent_body), body)
def test_each_fleet_action_recipe_surfaces_auth_errors_without_following_steps(self):
for status in (401, 403):
with tempfile.TemporaryDirectory() as temporary, recording_server(status) as (port, requests):
for _method, path, _body in FLEET_ACTION_REQUESTS:
with self.subTest(status=status, path=path):
before = len(requests)
result = self.run_documented_request(Path(temporary), "X-API-Token: " + TEST_TOKEN,
port, recipe(path))
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertIn(b'{"fixture":true}', result.stdout)
self.assertEqual(len(requests), before + 1)
def test_http_auth_errors_fail_instead_of_looking_successful(self):
for status in (401, 403):
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Execute the PBS guide's recipes with synthetic tokens and guest-local TLS.
No real PBS/Pulse instance, installer, service or credential is used. Run the
loopback checks with pulse-worker-source-proof, not on the worker host.
"""
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
from pathlib import Path
import re
import shutil
import ssl
import stat
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/PBS.md"
PBS_TOKEN = "synthetic-pbs-secret"
AGENT_TOKEN = "synthetic-agent-secret"
AUTH_HEADER = f"PBSAPIToken=pulse-monitor@pbs!pulse-token:{PBS_TOKEN}"
def blocks():
# Nested list-item fences have two leading spaces, which are not shell
# input when copied from the rendered guide.
text = re.sub(r"^ ", "", DOC.read_text(), flags=re.MULTILINE)
return re.findall(r"```bash\n(.*?)```", text, re.DOTALL)
def recipe(needle):
matches = [block for block in blocks() if needle in block]
if len(matches) != 1:
raise AssertionError(f"expected one recipe containing {needle!r}")
return matches[0]
def fixture_environment(home):
env = dict(os.environ, HOME=str(home))
# Keep all requests guest-local even if the proof runtime has proxy vars.
for key in list(env):
if key.lower().endswith("_proxy"):
del env[key]
return env
def certificate(directory, name):
certificate = directory / f"{name}.pem"
key = directory / f"{name}.key"
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key), "-out", str(certificate), "-days", "1",
"-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost",
], check=True, capture_output=True)
key.chmod(0o600)
return certificate, key
@contextmanager
def server(cert, key, status=200, installer=None, installer_path="/install.sh"):
requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
requests.append((self.path, dict(self.headers)))
self.send_response(status)
self.end_headers()
body = installer if self.path == installer_path else b'{"data":[]}\n'
self.wfile.write(body or b"fixture error\n")
def log_message(self, *_args):
pass
http = HTTPServer(("127.0.0.1", 0), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(str(cert), str(key))
http.socket = context.wrap_socket(http.socket, server_side=True)
thread = threading.Thread(target=http.serve_forever, daemon=True)
thread.start()
try:
yield http.server_address[1], requests
finally:
http.shutdown()
http.server_close()
thread.join(timeout=5)
class PBSDocsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory()
cls.cert, cls.key = certificate(Path(cls.temporary.name), "server")
cls.other_cert, _ = certificate(Path(cls.temporary.name), "other")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_shipped_copy_and_safe_scope(self):
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/PBS.md").read_bytes())
text = DOC.read_text()
shell = "\n".join(blocks())
self.assertNotRegex(shell, r"Authorization:|PBSAPIToken=|PULSE_SETUP_TOKEN=|--token(?:\s|=)|--insecure|curl\s+-[^\s]*k")
self.assertNotIn("/api2/json/version", shell)
self.assertNotIn("disable SSL verification", text)
for phrase in ("both the user and the token", "Manual Token Setup",
"not the **PBS** token", "independently verified",
"An agent is not", "empty list is not proof",
"Do not delete backups", "Do not clear History"):
self.assertIn(phrase, text)
def test_every_documented_shell_recipe_parses(self):
for command in blocks():
with self.subTest(command=command):
subprocess.run(["bash", "-n", "-c", command], check=True, capture_output=True)
def test_new_setup_grants_audit_to_user_and_token(self):
command = recipe("proxmox-backup-manager user create")
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tool = home / "proxmox-backup-manager"
tool.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"with open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n"
)
tool.chmod(0o700)
env = fixture_environment(home)
env.update(PATH=f"{home}:{os.environ['PATH']}", CALLS=str(home / "calls.jsonl"))
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
calls = [json.loads(line) for line in (home / "calls.jsonl").read_text().splitlines()]
self.assertEqual(calls, [
["user", "create", "pulse-monitor@pbs", "--comment", "Pulse monitoring"],
["user", "generate-token", "pulse-monitor@pbs", "pulse-token"],
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs"],
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs!pulse-token"],
])
# Same permission shapes as the owning implementation, not a claim of
# running proxmox-backup-manager on a real appliance.
source = (ROOT / "internal/api/configapi/setup_script_render.go").read_text()
self.assertIn("proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs", source)
self.assertIn('proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"', source)
def test_private_preparation_preserves_existing_content(self):
for name in ("pbs-agent-token", "pbs-header"):
command = recipe(f'vi "$HOME/.config/pulse/{name}"')
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tools = home / "tools"
tools.mkdir()
editor = tools / "vi"
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n')
editor.chmod(0o700)
env = fixture_environment(home)
env["PATH"] = f"{tools}:{os.environ['PATH']}"
path = home / ".config/pulse" / name
for existing in (False, True):
if existing:
path.write_text("synthetic-existing-content\n")
path.chmod(0o644)
path.parent.chmod(0o755)
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700)
self.assertEqual(path.read_text(), "synthetic-existing-content\n" if existing else "")
def run_curl(self, home, command, port, ca=None, hostname="localhost"):
private = home / ".config/pulse"
private.mkdir(parents=True, exist_ok=True, mode=0o700)
header = private / "pbs-header"
header.write_text(f"Authorization: {AUTH_HEADER}\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
self.assertIsNotNone(real_curl)
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
env = fixture_environment(home)
env.update(PATH=f"{tools}:{os.environ['PATH']}", REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
command = command.replace("https://pbs.example.com:8007", f"https://{hostname}:{port}")
command = command.replace("https://pulse.example.com", f"https://{hostname}:{port}")
if ca is not None:
shutil.copyfile(ca, private / "pbs-ca.pem")
# This is the guide's optional, independently verified CA-file form.
command = command.replace("curl ", 'curl --cacert "$HOME/.config/pulse/pbs-ca.pem" ', 1)
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=20)
argv = json.loads(receipt.read_text())
for secret in (PBS_TOKEN, AGENT_TOKEN):
self.assertNotIn(secret, " ".join(argv))
self.assertNotIn(secret.encode(), result.stdout + result.stderr)
self.assertNotIn("--insecure", argv)
self.assertNotIn("-k", argv)
return result, argv
def test_datastore_request_transmits_auth_only_in_header(self):
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key) as (port, requests):
result, argv = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertIn("--max-time", argv)
self.assertIn("--connect-timeout", argv)
self.assertIn("@" + str(Path(temporary) / ".config/pulse/pbs-header"), argv)
self.assertEqual(requests, [("/api2/json/admin/datastore", {
**requests[0][1], "Authorization": AUTH_HEADER,
})])
self.assertEqual(result.stdout, b'{"data":[]}\n')
def test_auth_errors_retain_body_and_fail(self):
for status in (401, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary:
with server(self.cert, self.key, status) as (port, requests):
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertEqual(len(requests), 1)
self.assertEqual(result.stdout, b'{"data":[]}\n')
def test_tls_rejects_untrusted_wrong_certificate_and_wrong_hostname(self):
for ca, hostname in ((None, "localhost"), (self.other_cert, "localhost"), (self.cert, "127.0.0.1")):
with self.subTest(ca=ca, hostname=hostname), tempfile.TemporaryDirectory() as temporary:
with server(self.cert, self.key) as (port, requests):
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, ca, hostname)
self.assertEqual(result.returncode, 60, result.stderr.decode())
# Authentication is never sent over an unverified channel.
self.assertEqual(requests, [])
def test_download_and_agent_file_handoff(self):
# The downloaded fixture deliberately cannot install or change a
# service. It verifies only the documented argument/file handoff.
installer = b'''#!/bin/bash
python3 - "$@" <<'PY'
import json, os, stat, sys
from pathlib import Path
args = sys.argv[1:]
path = Path(args[args.index('--token-file') + 1])
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert path.read_text().strip() == os.environ['EXPECTED_AGENT_TOKEN']
assert os.environ['EXPECTED_AGENT_TOKEN'] not in ' '.join(args)
Path(os.environ['AGENT_RECEIPT']).write_text(json.dumps(args))
PY
'''
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, installer=installer) as (port, requests):
home = Path(temporary)
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(requests[0][0], "/install.sh")
self.assertNotIn("Authorization", requests[0][1])
token = home / ".config/pulse/pbs-agent-token"
token.write_text(AGENT_TOKEN + "\n")
token.chmod(0o600)
command = recipe('bash "$HOME/.config/pulse/pbs-agent-install.sh"')
env = fixture_environment(home)
env.update(EXPECTED_AGENT_TOKEN=AGENT_TOKEN, AGENT_RECEIPT=str(home / "agent-argv.json"))
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(json.loads((home / "agent-argv.json").read_text()), [
"--url", "https://pulse.example.com", "--token-file", str(token),
"--enable-proxmox", "--proxmox-type", "pbs", "--enable-docker=false",
])
self.assertNotIn(AGENT_TOKEN.encode(), result.stdout + result.stderr)
actual = (ROOT / "scripts/install.sh").read_text()
self.assertIn('--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;', actual)
self.assertIn('read_collector_token_file_safely "$TOKEN_FILE_PATH" true', actual)
def test_failed_installer_download_is_nonzero(self):
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, 403) as (port, _requests):
home = Path(temporary)
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertFalse((home / ".config/pulse/pbs-agent-install.sh").exists())
if __name__ == "__main__":
unittest.main()