From a5c8b5d59799c7cf43af0f0fc7e796722c95fe7f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:11:04 +0100 Subject: [PATCH 01/23] Make PBS setup and diagnostics credential-safe Prefer read-only API access, grant Audit to both PBS identities, keep tokens in private files and verify TLS before sending them. Exercise the documented recipes with synthetic tokens, HTTP failures and certificate rejection controls. Contract-Neutral: Documentation and synthetic recipe checks only; no runtime API, installer, PBS identity or permission contract changes. Change-source: pulse-maintainer --- docs/PBS.md | 219 +++++++++++++++------- frontend-modern/public/docs/PBS.md | 219 +++++++++++++++------- scripts/tests/test_pbs_docs.py | 284 +++++++++++++++++++++++++++++ 3 files changed, 582 insertions(+), 140 deletions(-) create mode 100644 scripts/tests/test_pbs_docs.py diff --git a/docs/PBS.md b/docs/PBS.md index 6093098e27..922ed75833 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -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://:7655/install.sh | \ - sudo bash -s -- --url http://:7655 --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://:7655/api/setup-script?type=pbs&host=https://:8007&pulse_url=http://:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_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 `` with the secret + for the PBS token being tested: + + ```text + Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: + ``` + + 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. --- diff --git a/frontend-modern/public/docs/PBS.md b/frontend-modern/public/docs/PBS.md index 6093098e27..922ed75833 100644 --- a/frontend-modern/public/docs/PBS.md +++ b/frontend-modern/public/docs/PBS.md @@ -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://:7655/install.sh | \ - sudo bash -s -- --url http://:7655 --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://:7655/api/setup-script?type=pbs&host=https://:8007&pulse_url=http://:7655" | { if [ "$(id -u)" -eq 0 ]; then PULSE_SETUP_TOKEN="" bash; elif command -v sudo >/dev/null 2>&1; then sudo env PULSE_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 `` with the secret + for the PBS token being tested: + + ```text + Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token: + ``` + + 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. --- diff --git a/scripts/tests/test_pbs_docs.py b/scripts/tests/test_pbs_docs.py new file mode 100644 index 0000000000..03ce29234a --- /dev/null +++ b/scripts/tests/test_pbs_docs.py @@ -0,0 +1,284 @@ +#!/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): + 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 == "/install.sh" 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.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() From 88f52387af1fe6ebd6554acd49320a2e5927c0e8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:24:25 +0100 Subject: [PATCH 02/23] Restore touch activation on shared table rows Mark clickable table rows as native WebKit touch targets without moving their actions out of Solid's delegated event ordering. Preserve keyboard disclosure, embedded controls, dynamic action removal and explicit native handlers; cover the shared table and data grid, and record production PBS drawer browser proof. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 17 +- .../browser-tests/table-row-touch.cjs | 245 ++++++++++++++++ .../browser-tests/table-row-touch.html | 12 + .../browser-tests/table-row-touch.tsx | 91 ++++++ frontend-modern/browser-verification.json | 267 +++++++++++------- .../src/components/shared/Table.tsx | 6 + .../shared/__tests__/PulseDataGrid.test.tsx | 17 ++ .../shared/__tests__/Table.test.tsx | 127 ++++++++- 8 files changed, 682 insertions(+), 100 deletions(-) create mode 100644 frontend-modern/browser-tests/table-row-touch.cjs create mode 100644 frontend-modern/browser-tests/table-row-touch.html create mode 100644 frontend-modern/browser-tests/table-row-touch.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c3bac1b5c9..560a03e88d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -401,9 +401,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 diff --git a/frontend-modern/browser-tests/table-row-touch.cjs b/frontend-modern/browser-tests/table-row-touch.cjs new file mode 100644 index 0000000000..025a5cd043 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/table-row-touch.html b/frontend-modern/browser-tests/table-row-touch.html new file mode 100644 index 0000000000..9fd7276bf5 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Shared table touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/table-row-touch.tsx b/frontend-modern/browser-tests/table-row-touch.tsx new file mode 100644 index 0000000000..ab8e033077 --- /dev/null +++ b/frontend-modern/browser-tests/table-row-touch.tsx @@ -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 ( +
+

Shared table touch verification

+ + + {JSON.stringify({ + expanded: expanded(), + rowActions: rowActions(), + childActions: childActions(), + enabled: enabled(), + gridActions: gridActions(), + })} + + + + + + + Shared resource name + + + + + Native link + + event.stopPropagation()} + /> + + + + Static row + No action + + +
+ + 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: () => ( + + ), + }, + ]} + /> +
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6fd5437339..51ed3966f6 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,124 +1,189 @@ { "version": 1, - "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", - "verified_at": "2026-10-01T01:42:43Z", + "base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73", + "verified_at": "2026-10-01T03:23:21Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + "frontend-modern/src/components/shared/Table.tsx" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + "frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1" }, "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/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)", + "/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)" ], "viewports": [ - { - "width": 1365, - "height": 900 - }, { "width": 390, "height": 844 + }, + { + "width": 1365, + "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" + "WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next", + "History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs", + "delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row", + "shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works", + "PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow" ], "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" + "phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation", + "tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space", + "tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button" ], - "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/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.", "browser_versions": { "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" + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png" ], - "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.", + "notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline.", "prior_verifications": [ + { + "version": 1, + "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", + "verified_at": "2026-10-01T01:42:43Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99" + }, + "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)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "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" + ], + "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" + ], + "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.", + "browser_versions": { + "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." + }, { "version": 1, "base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881", "verified_at": "2026-10-01T00:37:51Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a" }, @@ -192,7 +257,9 @@ "base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f", "verified_at": "2026-09-30T23:28:26Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2" }, @@ -332,7 +399,9 @@ "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", "verified_at": "2026-09-30T20:12:09Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"], + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" + ], "content_sha256": { "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" }, @@ -387,7 +456,9 @@ "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", "verified_at": "2026-09-30T01:40:13Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + ], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" }, @@ -436,7 +507,9 @@ "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", "verified_at": "2026-09-29T23:04:22Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"], + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + ], "content_sha256": { "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b" }, @@ -532,7 +605,9 @@ "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484", "verified_at": "2026-09-29T19:50:45Z", "result": "passed", - "changed_paths": ["frontend-modern/src/pages/Actions.tsx"], + "changed_paths": [ + "frontend-modern/src/pages/Actions.tsx" + ], "content_sha256": { "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce" }, @@ -573,7 +648,9 @@ "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c", "verified_at": "2026-09-29T19:09:52Z", "result": "passed", - "changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"], + "changed_paths": [ + "frontend-modern/src/features/actions/ActionReviewDialog.tsx" + ], "content_sha256": { "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81" }, diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx index 6d2fdbb506..472263d205 100644 --- a/frontend-modern/src/components/shared/Table.tsx +++ b/frontend-modern/src/components/shared/Table.tsx @@ -63,11 +63,17 @@ export function TableBody(props: TableBodyProps) { export type TableRowProps = JSX.HTMLAttributes; +// 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 ( {local.children} diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index 4691fbf0fa..23ac2ff5e9 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -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(() => ( + + 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(); diff --git a/frontend-modern/src/components/shared/__tests__/Table.test.tsx b/frontend-modern/src/components/shared/__tests__/Table.test.tsx index 484cecc996..c81ce1dde0 100644 --- a/frontend-modern/src/components/shared/__tests__/Table.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Table.test.tsx @@ -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,128 @@ 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(() => ( + + + + Tap resource + + +
+ )); + 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(() => ( + + + + Static resource + + +
+ )); + 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(() => ( + + + + + + + + +
+ )); + 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(() => ( + + + + Changing resource + + +
+ )); + 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(() => ( + + + { + event.stopPropagation(); + onNative(); + }} + > + Native action + + + Bound action + + +
+ )); + 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'); From 12c8a7aba39bd8457f541ca0b1cc045893aed5db Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:33:11 +0100 Subject: [PATCH 03/23] Keep native and bound row-handler tests lint-compatible Test caller-native and bound delegated click handlers on separate rows rather than combining JSX event props rejected by the existing duplicate-prop rule. The touch-row runtime and its parent-bound browser receipt are unchanged. Change-source: pulse-maintainer --- frontend-modern/src/components/shared/__tests__/Table.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend-modern/src/components/shared/__tests__/Table.test.tsx b/frontend-modern/src/components/shared/__tests__/Table.test.tsx index c81ce1dde0..a0e7fc3260 100644 --- a/frontend-modern/src/components/shared/__tests__/Table.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Table.test.tsx @@ -111,7 +111,6 @@ describe('TableRow touch activation', () => { { event.stopPropagation(); onNative(); From 2fd114b3ac79fab125d6d63a4730eb010750465a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:23:08 +0100 Subject: [PATCH 04/23] Keep Proxmox bootstrap credentials out of copied commands Separate PVE/PBS setup and telemetry credentials from shell source and download URLs. Use silent root/sudo input, private-file handoff, complete downloads and the agent preflight; preserve scope, TLS defaults, single-line paste and coherent rolling-upgrade metadata. Reveal tokens through the existing dialog and discard late issuance after close. Pin executable shell, history, TLS/registration and browser contracts without using real credentials. Change-source: pulse-maintainer --- docs/API.md | 39 +- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 5 + .../subsystems/deployment-installability.md | 5 + .../internal/subsystems/storage-recovery.md | 5 + .../browser-tests/proxmox-private-input.cjs | 238 ++++++ .../browser-tests/proxmox-private-input.html | 12 + .../browser-tests/proxmox-private-input.tsx | 54 ++ frontend-modern/browser-verification.json | 802 +----------------- frontend-modern/public/docs/API.md | 39 +- .../__tests__/nodes.branchcov0724pm.test.ts | 4 +- .../src/api/__tests__/nodes.test.ts | 99 ++- frontend-modern/src/api/nodes.ts | 17 +- .../Settings/NodeModalSetupGuideSection.tsx | 26 +- .../NodeModalSetupGuideSection.test.tsx | 6 +- .../__tests__/settingsArchitecture.test.ts | 3 +- .../components/Settings/useNodeModalState.ts | 53 +- frontend-modern/src/stores/tokenReveal.ts | 2 +- .../src/utils/__tests__/docsLinks.test.ts | 11 + install.sh | 70 +- internal/api/agent_install_command_shared.go | 65 +- .../api/agent_install_command_shared_test.go | 57 +- .../config_handlers_setup_url_test.go | 32 +- internal/api/configapi/install_command.go | 89 +- .../api/configapi/install_command_test.go | 425 ++++++++-- .../private_bootstrap_pipeline_test.go | 144 ++++ .../api/configapi/setup_script_artifact.go | 36 +- internal/api/configapi/setup_script_render.go | 77 +- internal/api/contract_test.go | 87 +- .../api/hosted_agent_install_command_test.go | 2 +- internal/hostagent/proxmox_setup.go | 15 +- internal/hostagent/proxmox_setup_test.go | 46 + .../installtests/private_bootstrap_test.go | 93 ++ scripts/installtests/root_install_sh_test.go | 20 +- 34 files changed, 1459 insertions(+), 1224 deletions(-) create mode 100644 frontend-modern/browser-tests/proxmox-private-input.cjs create mode 100644 frontend-modern/browser-tests/proxmox-private-input.html create mode 100644 frontend-modern/browser-tests/proxmox-private-input.tsx create mode 100644 internal/api/configapi/private_bootstrap_pipeline_test.go create mode 100644 scripts/installtests/private_bootstrap_test.go diff --git a/docs/API.md b/docs/API.md index 46d3d3cbec..928b6ad586 100644 --- a/docs/API.md +++ b/docs/API.md @@ -525,9 +525,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 +559,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-` 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` diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 69e9c7318f..2c16d51b43 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e1030c0536..9298790eed 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 82ca498eac..9e3e54ca00 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2553,6 +2553,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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index fe2083f3b8..1f6e4df2cb 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/browser-tests/proxmox-private-input.cjs b/frontend-modern/browser-tests/proxmox-private-input.cjs new file mode 100644 index 0000000000..abc346a213 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.cjs @@ -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(); + } +})(); diff --git a/frontend-modern/browser-tests/proxmox-private-input.html b/frontend-modern/browser-tests/proxmox-private-input.html new file mode 100644 index 0000000000..b88f0ce1e9 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.html @@ -0,0 +1,12 @@ + + + + + + Proxmox private input fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/proxmox-private-input.tsx b/frontend-modern/browser-tests/proxmox-private-input.tsx new file mode 100644 index 0000000000..cf4b982d69 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-private-input.tsx @@ -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 ( +
+

Connect a Proxmox server

+ + + + + + {state.quickSetupCommandReady() ? 'ready' : 'empty'} + + +
+ ); +}; +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 6fd5437339..7e82fe7313 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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= PULSE_BROWSER_ARTIFACTS= PULSE_CHROMIUM_PATH= 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" ] } diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 46d3d3cbec..928b6ad586 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -525,9 +525,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 +559,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-` 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` diff --git a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts index 7e9e3fda50..21200d6a7c 100644 --- a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts @@ -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 ...', @@ -152,7 +152,7 @@ describe('NodesAPI — branch coverage (downloadProxmoxSetupScript failure arms) 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 ...', diff --git a/frontend-modern/src/api/__tests__/nodes.test.ts b/frontend-modern/src/api/__tests__/nodes.test.ts index e118544496..5a4602e196 100644 --- a/frontend-modern/src/api/__tests__/nodes.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.test.ts @@ -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' }), + ).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 ...', diff --git a/frontend-modern/src/api/nodes.ts b/frontend-modern/src/api/nodes.ts index ff19cd74bf..62e0b3734f 100644 --- a/frontend-modern/src/api/nodes.ts +++ b/frontend-modern/src/api/nodes.ts @@ -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, }; }; diff --git a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx index 1bdf60fd18..19ff8161d4 100644 --- a/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx +++ b/frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx @@ -241,8 +241,8 @@ export const NodeModalSetupGuideSection: Component

- 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.

@@ -286,7 +286,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

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.

@@ -424,7 +425,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

@@ -728,8 +729,8 @@ export const NodeModalSetupGuideSection: Component

- 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.

@@ -757,7 +758,7 @@ export const NodeModalSetupGuideSection: Component
-

Credentialed command ready

+

Credential-free command ready

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.

@@ -895,7 +897,7 @@ export const NodeModalSetupGuideSection: Component

- Fully automatic: no manual token copying needed. + The monitoring API token is registered automatically.

diff --git a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx index 3f27e1aa90..5acd3c9780 100644 --- a/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/NodeModalSetupGuideSection.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 6619ee7a5b..14e4b7fe38 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1817,7 +1817,8 @@ describe('settings architecture guardrails', () => { expect(nodeModalSetupGuideSectionSource).toContain( 'one-time setup token is intentionally not shown on this page', ); - expect(nodeModalStateSource).toContain('data.commandWithEnv'); + 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'); diff --git a/frontend-modern/src/components/Settings/useNodeModalState.ts b/frontend-modern/src/components/Settings/useNodeModalState.ts index e8b6249adf..b3f76c6635 100644 --- a/frontend-modern/src/components/Settings/useNodeModalState.ts +++ b/frontend-modern/src/components/Settings/useNodeModalState.ts @@ -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 => { + ): Promise => { + 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)); diff --git a/frontend-modern/src/stores/tokenReveal.ts b/frontend-modern/src/stores/tokenReveal.ts index 504e45cbb2..6bbca7b3dd 100644 --- a/frontend-modern/src/stores/tokenReveal.ts +++ b/frontend-modern/src/stores/tokenReveal.ts @@ -3,7 +3,7 @@ import type { APITokenRecord } from '@/api/security'; export interface TokenRevealPayload { token: string; - record: APITokenRecord; + record?: APITokenRecord; source?: string; note?: string; } diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 172e0345b4..d4bac377ae 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -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( diff --git a/install.sh b/install.sh index 85ad224e82..affec613cb 100755 --- a/install.sh +++ b/install.sh @@ -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=" 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 diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go index 43faed3d1f..0a0a5d70f6 100644 --- a/internal/api/agent_install_command_shared.go +++ b/internal/api/agent_install_command_shared.go @@ -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 { diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index 2ee7329ab4..06a15f61c9 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -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,10 @@ 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.NotContains(t, command, "https://pulse.example.com/base/") } func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testing.T) { @@ -195,17 +195,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 +220,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 +246,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 +293,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) diff --git a/internal/api/configapi/config_handlers_setup_url_test.go b/internal/api/configapi/config_handlers_setup_url_test.go index 285a021f2f..e5d5487a14 100644 --- a/internal/api/configapi/config_handlers_setup_url_test.go +++ b/internal/api/configapi/config_handlers_setup_url_test.go @@ -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) diff --git a/internal/api/configapi/install_command.go b/internal/api/configapi/install_command.go index c495efa039..49c48a1831 100644 --- a/internal/api/configapi/install_command.go +++ b/internal/api/configapi/install_command.go @@ -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 &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 { diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go index 2568f7107d..605c9d0a0e 100644 --- a/internal/api/configapi/install_command_test.go +++ b/internal/api/configapi/install_command_test.go @@ -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,299 @@ 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") + } +} diff --git a/internal/api/configapi/private_bootstrap_pipeline_test.go b/internal/api/configapi/private_bootstrap_pipeline_test.go new file mode 100644 index 0000000000..1ecb07cbaf --- /dev/null +++ b/internal/api/configapi/private_bootstrap_pipeline_test.go @@ -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") + } + }) + } +} diff --git a/internal/api/configapi/setup_script_artifact.go b/internal/api/configapi/setup_script_artifact.go index 47b8c4d871..43c1357b4d 100644 --- a/internal/api/configapi/setup_script_artifact.go +++ b/internal/api/configapi/setup_script_artifact.go @@ -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 { diff --git a/internal/api/configapi/setup_script_render.go b/internal/api/configapi/setup_script_render.go index d604f165da..7a66b29d0d 100644 --- a/internal/api/configapi/setup_script_render.go +++ b/internal/api/configapi/setup_script_render.go @@ -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/null || true; fi - IFS= read -r PULSE_SETUP_TOKEN /dev/null 2>&1; then stty echo /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/null || true; fi - IFS= read -r PULSE_SETUP_TOKEN /dev/null 2>&1; then stty echo /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." diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 612a8fc8b5..f0ad503610 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -6907,18 +6907,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 +6937,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 +10243,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 +10265,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 +10287,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 +10303,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 +11199,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) { diff --git a/internal/api/hosted_agent_install_command_test.go b/internal/api/hosted_agent_install_command_test.go index 6b47686bda..e212d923cf 100644 --- a/internal/api/hosted_agent_install_command_test.go +++ b/internal/api/hosted_agent_install_command_test.go @@ -61,7 +61,7 @@ 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")) diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index d470573182..c7a382e28a 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -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) } diff --git a/internal/hostagent/proxmox_setup_test.go b/internal/hostagent/proxmox_setup_test.go index 84fcd2a9e9..214f09e165 100644 --- a/internal/hostagent/proxmox_setup_test.go +++ b/internal/hostagent/proxmox_setup_test.go @@ -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) + } + }) + } + } +} diff --git a/scripts/installtests/private_bootstrap_test.go b/scripts/installtests/private_bootstrap_test.go new file mode 100644 index 0000000000..10d248839b --- /dev/null +++ b/scripts/installtests/private_bootstrap_test.go @@ -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") + } + }) + } + } +} diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 1845d6e61a..39696cf6a7 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -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=" 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) } From 022b55083ded98ad707e5e4e6c2a6ad7bb20a64f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:28:54 +0100 Subject: [PATCH 05/23] Keep authored release notes compatible with safety checks Bind stable notes to version and operator disclosures rather than obsolete prose. Support bounded grouped customer sections for v6.4.6 and later, align generation and rendering, and preserve published history and all qualification gates. Change-source: pulse-maintainer --- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 7 ++ .../subsystems/deployment-installability.md | 14 ++- docs/releases/RELEASE_NOTES_TEMPLATE.md | 24 +++- scripts/generate-release-notes.sh | 41 ++++++ .../installtests/build_release_assets_test.go | 18 +-- .../release_notes_contract_test.go | 110 ++++++++++++++++ .../testdata/release-notes-v6.4.5-authored.md | 81 ++++++++++++ .../release_control/render_release_body.py | 59 ++++++--- .../render_release_body_test.py | 119 ++++++++++++++++++ 9 files changed, 442 insertions(+), 31 deletions(-) create mode 100644 scripts/installtests/release_notes_contract_test.go create mode 100644 scripts/installtests/testdata/release-notes-v6.4.5-authored.md diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 37839089d1..31dd057486 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1021,9 +1021,16 @@ 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 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 `. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 82ca498eac..5be7fa9970 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1922,9 +1922,19 @@ 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. 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. diff --git a/docs/releases/RELEASE_NOTES_TEMPLATE.md b/docs/releases/RELEASE_NOTES_TEMPLATE.md index c2b24234c1..53470c263b 100644 --- a/docs/releases/RELEASE_NOTES_TEMPLATE.md +++ b/docs/releases/RELEASE_NOTES_TEMPLATE.md @@ -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 diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index a5ea317a47..844fea3f64 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -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 < 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) + } + } +} diff --git a/scripts/installtests/testdata/release-notes-v6.4.5-authored.md b/scripts/installtests/testdata/release-notes-v6.4.5-authored.md new file mode 100644 index 0000000000..0b850aedef --- /dev/null +++ b/scripts/installtests/testdata/release-notes-v6.4.5-authored.md @@ -0,0 +1,81 @@ +# 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. + diff --git a/scripts/release_control/render_release_body.py b/scripts/release_control/render_release_body.py index 40e63e72c1..074aeb1597 100644 --- a/scripts/release_control/render_release_body.py +++ b/scripts/release_control/render_release_body.py @@ -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): diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index 23b092e25a..0b1b93d0a0 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -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" From d5c187613a68e6e891ae9a5b6dbfd6154cea1844 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:41:44 +0100 Subject: [PATCH 06/23] Keep agent setup tokens out of commands and unverified downloads Use existing Unix and Windows token-file installers, verified HTTPS downloads and inspection before execution across setup, retarget, profile and cleanup examples. Exercise private files, all profile arguments, rejected TLS and failed downloads with synthetic guest-local fixtures. Contract-Neutral: Documentation and executable documentation tests only; installer behaviour, privileges and runtime contracts are unchanged. Change-source: pulse-maintainer --- docs/TEMPERATURE_MONITORING.md | 39 ++- docs/UNIFIED_AGENT.md | 209 +++++++++++----- .../public/docs/TEMPERATURE_MONITORING.md | 39 ++- frontend-modern/public/docs/UNIFIED_AGENT.md | 209 +++++++++++----- scripts/tests/test_agent_docs.py | 235 ++++++++++++++++++ scripts/tests/test_pbs_docs.py | 4 +- 6 files changed, 593 insertions(+), 142 deletions(-) create mode 100644 scripts/tests/test_agent_docs.py diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index 4f727e2e04..bc1063438a 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index ff7435ca9d..4ef1f23ae1 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -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://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +### Private-file installation: Linux, macOS and NAS -### macOS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://: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://:7655" -$env:PULSE_TOKEN="" -irm http://: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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://: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 +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. diff --git a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md index 4f727e2e04..bc1063438a 100644 --- a/frontend-modern/public/docs/TEMPERATURE_MONITORING.md +++ b/frontend-modern/public/docs/TEMPERATURE_MONITORING.md @@ -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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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. diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index ff7435ca9d..4ef1f23ae1 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -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://:7655/install.sh | \ - bash -s -- --url http://:7655 --token -``` +### Private-file installation: Linux, macOS and NAS -### macOS -```bash -curl -fsSL http://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://: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://:7655" -$env:PULSE_TOKEN="" -irm http://: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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://:7655/install.sh | \ - bash -s -- --url http://:7655 --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://: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 +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. diff --git a/scripts/tests/test_agent_docs.py b/scripts/tests/test_agent_docs.py new file mode 100644 index 0000000000..dedb46cfa2 --- /dev/null +++ b/scripts/tests/test_agent_docs.py @@ -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() diff --git a/scripts/tests/test_pbs_docs.py b/scripts/tests/test_pbs_docs.py index 03ce29234a..fc19a7c2fd 100644 --- a/scripts/tests/test_pbs_docs.py +++ b/scripts/tests/test_pbs_docs.py @@ -63,7 +63,7 @@ def certificate(directory, name): @contextmanager -def server(cert, key, status=200, installer=None): +def server(cert, key, status=200, installer=None, installer_path="/install.sh"): requests = [] class Handler(BaseHTTPRequestHandler): @@ -71,7 +71,7 @@ def server(cert, key, status=200, installer=None): requests.append((self.path, dict(self.headers))) self.send_response(status) self.end_headers() - body = installer if self.path == "/install.sh" else b'{"data":[]}\n' + 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): From 521acef97a612dfe5ac834649903ada6f371c714 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:42:34 +0100 Subject: [PATCH 07/23] Reject mixed bootstrap metadata and align credential-safe fixtures The root parser's legacy no-token alias accepted a new private-file command beside a legacy download URL. Reject every mixed alias, as the existing coherent-transport contract requires. Preserve backup-permission URL bindings. Align older secret-in-command and shared type fixtures, and exercise the actual wrong server installer parser. Contract-Neutral: Enforce the already-declared coherent bootstrap transports and correct verification fixtures; no API, scope or documented behaviour delta. Change-source: pulse-maintainer --- .../__tests__/nodes.branchcov0724pm.test.ts | 1 + .../src/api/__tests__/nodes.test.ts | 2 +- .../src/stores/__tests__/tokenReveal.test.ts | 14 +++++++++++- install.sh | 2 +- ...config_handlers_cluster_additional_test.go | 8 +++---- .../config_handlers_setup_script_test.go | 5 +++-- .../hosted_agent_install_token_test.go | 8 +++++-- .../api/configapi/install_command_test.go | 22 +++++++++++++++++++ scripts/installtests/root_install_sh_test.go | 2 +- 9 files changed, 52 insertions(+), 12 deletions(-) diff --git a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts index 21200d6a7c..98b333ed27 100644 --- a/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.branchcov0724pm.test.ts @@ -149,6 +149,7 @@ 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', diff --git a/frontend-modern/src/api/__tests__/nodes.test.ts b/frontend-modern/src/api/__tests__/nodes.test.ts index 5a4602e196..bd7eadcb10 100644 --- a/frontend-modern/src/api/__tests__/nodes.test.ts +++ b/frontend-modern/src/api/__tests__/nodes.test.ts @@ -407,7 +407,7 @@ describe('NodesAPI', () => { [field]: `unsafe-${secret}`, }); await expect( - NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example' }), + NodesAPI.getProxmoxSetupCommand({ type: 'pve', host: 'pve.example', backupPerms: false }), ).rejects.toThrow('Setup credentials must be entered separately from commands and URLs'); }, ); diff --git a/frontend-modern/src/stores/__tests__/tokenReveal.test.ts b/frontend-modern/src/stores/__tests__/tokenReveal.test.ts index 95f541110f..999705484c 100644 --- a/frontend-modern/src/stores/__tests__/tokenReveal.test.ts +++ b/frontend-modern/src/stores/__tests__/tokenReveal.test.ts @@ -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({ diff --git a/install.sh b/install.sh index affec613cb..d345f3c7ce 100755 --- a/install.sh +++ b/install.sh @@ -2349,7 +2349,7 @@ for _field_name, _value, _requires_token in command_fields: 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=" in _value and setup_token in _value) if _requires_token else ("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)) if not valid: if _field_name == "command": setup_command = "" diff --git a/internal/api/configapi/config_handlers_cluster_additional_test.go b/internal/api/configapi/config_handlers_cluster_additional_test.go index 3bf2ab49b1..ab0d4cf5bb 100644 --- a/internal/api/configapi/config_handlers_cluster_additional_test.go +++ b/internal/api/configapi/config_handlers_cluster_additional_test.go @@ -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) } } diff --git a/internal/api/configapi/config_handlers_setup_script_test.go b/internal/api/configapi/config_handlers_setup_script_test.go index 16a06dd083..53bd2a12de 100644 --- a/internal/api/configapi/config_handlers_setup_script_test.go +++ b/internal/api/configapi/config_handlers_setup_script_test.go @@ -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`, diff --git a/internal/api/configapi/hosted_agent_install_token_test.go b/internal/api/configapi/hosted_agent_install_token_test.go index b1b1c4bb73..a8d2f8dab4 100644 --- a/internal/api/configapi/hosted_agent_install_token_test.go +++ b/internal/api/configapi/hosted_agent_install_token_test.go @@ -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") } }) } diff --git a/internal/api/configapi/install_command_test.go b/internal/api/configapi/install_command_test.go index 605c9d0a0e..b35d089dd9 100644 --- a/internal/api/configapi/install_command_test.go +++ b/internal/api/configapi/install_command_test.go @@ -372,3 +372,25 @@ func TestPrivateBootstrapInteractiveHistoryContainsCommandButNotToken(t *testing 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") + } + } +} diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 39696cf6a7..a19044b490 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -598,7 +598,7 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { `'if [ "$(id -u)" -eq 0 ]; then' not in _value`, `'elif command -v sudo >/dev/null 2>&1; then' not 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=" in _value and setup_token in _value) if _requires_token else ("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" ]]`, From 083c73103ce2e42f6cb762441e28b7159b892641 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:51:09 +0100 Subject: [PATCH 08/23] Align routed and hosted Proxmox private-input contracts Keep fresh token issuance, scope, persistence, normalized product and origin checks while requiring the minted credential outside copied shell text. Dynamic executable coverage already pins decoded quoted arguments. No runtime or frontend content changes. Change-source: pulse-maintainer --- internal/api/contract_test.go | 10 +++++----- internal/api/hosted_agent_install_command_test.go | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f0ad503610..5625301458 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -23275,8 +23275,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)) { @@ -24090,7 +24090,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 { @@ -25075,8 +25075,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) } } } diff --git a/internal/api/hosted_agent_install_command_test.go b/internal/api/hosted_agent_install_command_test.go index e212d923cf..ce3c24ce33 100644 --- a/internal/api/hosted_agent_install_command_test.go +++ b/internal/api/hosted_agent_install_command_test.go @@ -63,7 +63,7 @@ func TestHostedTenantAgentInstallCommand_GeneratesOrgBoundTokenAndCommand(t *tes require.Contains(t, resp.Command, "https://cloud.example.com/install.sh") 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) From 4efa52921579affbbdfee695ed4f1ff838d632bd Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 05:51:28 +0100 Subject: [PATCH 09/23] Close release-note qualification input and fixture gaps Bind the new compiled notes helper and authored fixture in the rootful source manifest. Repair the visual rollback fixture's missing version and align the internal control-plane page with the active release-reliability target, without changing source routing or published packets. Change-source: pulse-maintainer --- .../release-control/internal/CONTROL_PLANE.md | 20 ++++++++++--------- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 1 + .../subsystems/deployment-installability.md | 4 ++++ .../release_note_visuals_test.py | 1 + ...ure_runtime_rootful_attestation_v1_test.py | 2 ++ ...re_runtime_rootful_source_manifest_v1.json | 2 ++ 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/release-control/internal/CONTROL_PLANE.md b/docs/release-control/internal/CONTROL_PLANE.md index 129ba0d394..167bb7c07f 100644 --- a/docs/release-control/internal/CONTROL_PLANE.md +++ b/docs/release-control/internal/CONTROL_PLANE.md @@ -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` diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 31dd057486..502c99bf61 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1023,6 +1023,7 @@ Companion drill: - 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` `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: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 5be7fa9970..3d3d419353 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1935,6 +1935,10 @@ artifact-selection behaviour. 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. diff --git a/scripts/release_control/release_note_visuals_test.py b/scripts/release_control/release_note_visuals_test.py index cb169a99ae..f3f93abf15 100644 --- a/scripts/release_control/release_note_visuals_test.py +++ b/scripts/release_control/release_note_visuals_test.py @@ -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", }, diff --git a/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py b/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py index beb40af503..2d058d1e2e 100644 --- a/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py +++ b/scripts/release_control/secure_runtime_rootful_attestation_v1_test.py @@ -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", diff --git a/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json b/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json index 45fee927ca..758b48ce07 100644 --- a/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json +++ b/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json @@ -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", From cc2fbb6204c63fdbc00ccef0b386e8bee0530b8d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:05:21 +0100 Subject: [PATCH 10/23] Pin separate token reveal in settings architecture checks Replace the last pre-repair credentialed-command and hidden-token copy assertions with the implemented separate-input contract. All other architecture and runtime assertions remain; browser-verified frontend content is unchanged. Change-source: pulse-maintainer --- .../Settings/__tests__/settingsArchitecture.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 14e4b7fe38..a6c84839ac 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1813,10 +1813,8 @@ 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(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'); From 4af4f9119dd54edcfd5d3b261743a27445a9e857 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:06:47 +0100 Subject: [PATCH 11/23] Keep credentials outside hosted setup downloads in contract tests Retain configured-origin and proxy-spoofing coverage while requiring separate credentials, identical tokenless script URLs and private-input renderers. This aligns remaining routed setup assertions with the credential-safe response; no runtime change. Change-source: pulse-maintainer --- internal/api/contract_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 5625301458..abc064b298 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -23326,9 +23326,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?") { @@ -24122,8 +24123,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") } }) } From ec372845541fd0a79800b9ccadd7f4972be5e836 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:22:45 +0100 Subject: [PATCH 12/23] Show sparse drawer History without inventing a trend Render lone stored observations at their actual time instead of blank collecting panels. Label live legend fallbacks as current and describe empty windows without claiming collection is active. Preserve source/range isolation and failed-refresh recovery. Add sparse-observation regressions and scoped browser evidence, and align both canonical contracts with the shared renderer's verification boundary. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 21 +- .../subsystems/performance-and-scalability.md | 16 +- .../v6/internal/subsystems/registry.json | 19 ++ .../browser-tests/history-window.cjs | 71 +++++- .../browser-tests/pbs-history-refresh.cjs | 5 +- frontend-modern/browser-verification.json | 156 ++++++++++--- .../ResourceDetailDrawer.history.test.tsx | 2 +- .../components/Workloads/GuestDrawer.test.tsx | 4 +- .../Workloads/GuestDrawerHistory.tsx | 98 ++++++-- .../GuestDrawerHistory.refresh.test.tsx | 4 +- .../GuestDrawerHistory.sparse.test.tsx | 211 ++++++++++++++++++ .../GuestDrawerHistory.window.test.tsx | 2 +- 12 files changed, 532 insertions(+), 77 deletions(-) create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 560a03e88d..2eec02ad85 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 @@ -708,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. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ed0ab8c5ec..50dd1df948 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index feeb999fb2..c4689a77c4 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5223,6 +5223,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", @@ -5929,6 +5930,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 diff --git a/frontend-modern/browser-tests/history-window.cjs b/frontend-modern/browser-tests/history-window.cjs index ea0a9bce7e..c04342a8e1 100644 --- a/frontend-modern/browser-tests/history-window.cjs +++ b/frontend-modern/browser-tests/history-window.cjs @@ -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(); diff --git a/frontend-modern/browser-tests/pbs-history-refresh.cjs b/frontend-modern/browser-tests/pbs-history-refresh.cjs index 2eb3133e97..9a61945e5b 100644 --- a/frontend-modern/browser-tests/pbs-history-refresh.cjs +++ b/frontend-modern/browser-tests/pbs-history-refresh.cjs @@ -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'); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 51ed3966f6..8bfdbcd2d4 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,17 +1,17 @@ { "version": 1, - "base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73", - "verified_at": "2026-10-01T03:23:21Z", + "base_sha": "36418af66beac94204276f7dc73de2c41e297fb1", + "verified_at": "2026-10-01T05:22:37Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/Table.tsx" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "65538187de57957691eb00af08a9f86b916a6300f2d12065a1985fb2170ef7bf" }, "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)", - "/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)" + "/browser-tests/pbs-identity-boundary.html (production PBS table and full ResourceDetailDrawer/History; synthetic bounded API/Agent/PVE resources)", + "/browser-tests/pbs-history-refresh.html (production shared History renderer with deliberately distinct current readings)" ], "viewports": [ { @@ -24,43 +24,131 @@ } ], "states": [ - "WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next", - "History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs", - "delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row", - "shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works", - "PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow" + "All three synthetic PBS drawers retain vm-one, vm-two and agent-three history identity and one dated window", + "A lone stored CPU, network, disk-write and thermal series each displays one point at its actual time, with valid measured zeros and no manufactured path", + "Single-time panels visibly date their observation and explain that it is not a trend; empty successful reads report no stored history in the selected range", + "Live CPU/memory legend fallbacks carry an explicit current label and create no stored geometry", + "Failed same-source refresh retains lone points and the warning; uncached range replacement clears old geometry and captions while loading; locked ranges make no reads", + "Light/dark phone and desktop rendering: no page errors or document horizontal overflow" ], "interactions": [ - "phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation", - "tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space", - "tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button" + "Native disclosure and History tabs for each of three mapped drawers; keyboard/touch stored-time inspection remains dated and makes no extra reads", + "Refresh, failed refresh, retry, sparse response, successful empty response, and 24h/1h/7d/locked-14d range changes", + "Current-only direct renderer read confirms explicitly labelled live readings and empty plots" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.", + "command": "Four executions from assigned workspace root: pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=webkit --phone --theme=light; --engine=webkit --phone --theme=dark; --engine=firefox --theme=dark; --engine=chromium --phone --theme=light. Playwright 1.56.1 matches integration lock; Vite 6.4.3.", "browser_versions": { - "chromium": "141.0.7390.37", + "webkit": "26.0", "firefox": "142.0.1", - "webkit": "26.0" + "chromium": "141.0.7390.37" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/sparse-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/current-empty-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/empty-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/current-empty-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/sparse-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/empty-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/two-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/three-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/current-empty-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/one-hour-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/one-24h-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/sparse-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/retained-window-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/empty-dark.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/progress.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/sparse-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/current-empty-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/one-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/two-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/one-hour-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/empty-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/three-24h-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/retained-window-light.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/progress.json" ], - "notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline.", + "notes": "All 32 final screenshots inspected after the copy-style correction to Single observation. No trend yet. All four fresh passes match the runtime SHA256 above. Baseline WebKit phone returned exit 1: four valid lone series had zero points and four Collecting history claims. Earlier HMR/source-hash diagnostics and a batch wrapper exit 143 are retained outside this final proof; the final single Chromium execution exits 0. Offline renderer fixtures do not establish installed PBS/VirtualBox/provider acceptance, physical devices, screen-reader speech or Tailscale Serve update proof. No API, polling, cache or license boundary changes.", "prior_verifications": [ + { + "version": 1, + "base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73", + "verified_at": "2026-10-01T03:23:21Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/shared/Table.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1" + }, + "routes": [ + "/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)", + "/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)" + ], + "viewports": [ + { + "width": 390, + "height": 844 + }, + { + "width": 1365, + "height": 900 + } + ], + "states": [ + "WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next", + "History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs", + "delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row", + "shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works", + "PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow" + ], + "interactions": [ + "phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation", + "tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space", + "tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.", + "browser_versions": { + "chromium": "141.0.7390.37", + "firefox": "142.0.1", + "webkit": "26.0" + }, + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png" + ], + "notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline." + }, { "version": 1, "base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9", diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index dd722dc9db..a7c1fd727b 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -161,7 +161,7 @@ const baseResource = (overrides: Partial): 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'); }); diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 8b44aefcb1..adb3254ccb 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -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(); }); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 6cf02dac9d..427b7ec2f6 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -274,9 +274,13 @@ const GuestDrawerHistoryGroupChart: Component 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 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
{(item) => ( - + {item.label} {item.valueLabel} + + current + )} @@ -421,26 +436,48 @@ const GuestDrawerHistoryGroupChart: Component {(rangeBounds) => ( - - {(item) => ( - - )} - + <> + + {(item) => ( + + )} + + + {(item) => ( + + )} 0}> @@ -471,9 +508,9 @@ const GuestDrawerHistoryGroupChart: Component - +
- Collecting history + No stored history in this range
@@ -515,6 +552,19 @@ const GuestDrawerHistoryGroupChart: Component

{chartDescription()}

+ + {(observation) => ( +

+ Single observation. No trend yet. + +

+ )} +
1}>