From 63267f57e1575947abb26caadd0ea959387d0274 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:23:25 +0100 Subject: [PATCH 1/6] Restore Windows installer test compilation The Unix agent-ID recovery test imported syscall.Mkfifo from a generic test file. Go must compile that file before its runtime skip can run, so Windows CI could no longer build the installer test package. Keep the security regression on supported Unix targets while restoring the Windows delivery signal. Change-source: pulse-maintainer (cherry picked from commit c0ca94ee9ff92757a4eab2b67570f99bc49c8bb6) (cherry picked from commit d2cc6b488420c92e651c73c172f69201a2272156) --- .../agent_id_recovery_unix_test.go | 64 +++++++++++++++++++ .../agent_state_dir_lifecycle_test.go | 54 ---------------- 2 files changed, 64 insertions(+), 54 deletions(-) create mode 100644 scripts/installtests/agent_id_recovery_unix_test.go diff --git a/scripts/installtests/agent_id_recovery_unix_test.go b/scripts/installtests/agent_id_recovery_unix_test.go new file mode 100644 index 000000000..b2b55ef13 --- /dev/null +++ b/scripts/installtests/agent_id_recovery_unix_test.go @@ -0,0 +1,64 @@ +//go:build linux || darwin || freebsd + +package installtests + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) { + binaryPath := buildLifecycleAgent(t) + root := t.TempDir() + validPath := filepath.Join(root, "valid-agent-id") + oversizedPath := filepath.Join(root, "oversized-agent-id") + symlinkPath := filepath.Join(root, "symlink-agent-id") + fifoPath := filepath.Join(root, "fifo-agent-id") + if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(validPath, symlinkPath); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(fifoPath, 0600); err != nil { + t.Fatal(err) + } + + harness := func(path string) ([]byte, error) { + script := ` + set -euo pipefail + COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `" + INSTALL_DIR="` + root + `" + BINARY_NAME="pulse-agent" + LEAST_PRIVILEGE_USER="pulse-agent-test-missing" +` + extractLifecycleTrustShellFunctions(t) + ` +` + extractInstallShellFunction(t, "collector_lifecycle_binary") + ` +` + extractInstallShellFunction(t, "read_agent_id_file_safely") + ` + read_agent_id_file_safely "` + path + `" + ` + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput() + } + if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" { + t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out) + } + for _, path := range []string{symlinkPath, fifoPath, oversizedPath} { + started := time.Now() + if out, err := harness(path); err == nil { + t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out) + } + if elapsed := time.Since(started); elapsed >= 2*time.Second { + t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed) + } + } +} diff --git a/scripts/installtests/agent_state_dir_lifecycle_test.go b/scripts/installtests/agent_state_dir_lifecycle_test.go index 696f83988..43af6b7db 100644 --- a/scripts/installtests/agent_state_dir_lifecycle_test.go +++ b/scripts/installtests/agent_state_dir_lifecycle_test.go @@ -5,7 +5,6 @@ package installtests import ( "bytes" "compress/gzip" - "context" "encoding/json" "io" "net/http" @@ -21,59 +20,6 @@ import ( "time" ) -func TestInstallSHAgentIDRecoveryRejectsSymlinkFIFOAndOversizedState(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix descriptor-bound identity recovery") - } - binaryPath := buildLifecycleAgent(t) - root := t.TempDir() - validPath := filepath.Join(root, "valid-agent-id") - oversizedPath := filepath.Join(root, "oversized-agent-id") - symlinkPath := filepath.Join(root, "symlink-agent-id") - fifoPath := filepath.Join(root, "fifo-agent-id") - if err := os.WriteFile(validPath, []byte("agent-safe-123\n"), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(oversizedPath, []byte(strings.Repeat("a", 5000)), 0600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(validPath, symlinkPath); err != nil { - t.Fatal(err) - } - if err := syscall.Mkfifo(fifoPath, 0600); err != nil { - t.Fatal(err) - } - - harness := func(path string) ([]byte, error) { - script := ` - set -euo pipefail - COLLECTOR_LIFECYCLE_BINARY_PATH="` + binaryPath + `" - INSTALL_DIR="` + root + `" - BINARY_NAME="pulse-agent" - LEAST_PRIVILEGE_USER="pulse-agent-test-missing" -` + extractLifecycleTrustShellFunctions(t) + ` -` + extractInstallShellFunction(t, "collector_lifecycle_binary") + ` -` + extractInstallShellFunction(t, "read_agent_id_file_safely") + ` - read_agent_id_file_safely "` + path + `" - ` - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - return exec.CommandContext(ctx, "bash", "-c", script).CombinedOutput() - } - if out, err := harness(validPath); err != nil || strings.TrimSpace(string(out)) != "agent-safe-123" { - t.Fatalf("valid descriptor-bound agent ID recovery failed: %v\n%s", err, out) - } - for _, path := range []string{symlinkPath, fifoPath, oversizedPath} { - started := time.Now() - if out, err := harness(path); err == nil { - t.Fatalf("unsafe agent ID path %s was accepted:\n%s", path, out) - } - if elapsed := time.Since(started); elapsed >= 2*time.Second { - t.Fatalf("unsafe agent ID path %s blocked for %s", path, elapsed) - } - } -} - type agentLifecycleControlPlane struct { mu sync.Mutex online bool From f503b134425783af4b6102a9b79bae9379757754 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:09:36 +0100 Subject: [PATCH 2/6] Bind release dispatches to the admitted commit A workflow dispatch by branch can resolve after that branch moves, allowing an unreviewed tip to enter the release pipeline. Require every publishing dispatch to name its expected source SHA and make the workflow reject a different source or workflow commit before checkout. Change-source: pulse-maintainer (cherry picked from commit a461fc9c0acb09bc1fd48228a8c32c5303ad4bc8) --- .github/workflows/create-release.yml | 20 +++++++++++++++++++ .../subsystems/deployment-installability.md | 7 +++++++ .../installtests/build_release_assets_test.go | 4 ++++ .../release_promotion_policy_test.py | 4 ++++ scripts/trigger-release.sh | 3 +++ scripts/trigger-stable-patch.sh | 3 +++ 6 files changed, 41 insertions(+) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f5bd3fda5..4bead5286 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -9,6 +9,10 @@ on: description: 'Version number (e.g., 4.30.0)' required: true type: string + expected_source_sha: + description: 'Exact 40-character commit SHA admitted for this release' + required: true + type: string release_notes: description: 'Release notes (markdown)' required: true @@ -108,6 +112,22 @@ jobs: visual_capture_count: ${{ steps.visual_plan.outputs.capture_count }} visual_comparison_tag: ${{ steps.visual_plan.outputs.comparison_tag }} steps: + - name: Verify admitted source commit + env: + EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }} + run: | + set -euo pipefail + if [[ ! "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected_source_sha must be an exact 40-character commit SHA" + exit 1 + fi + if [[ "${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}" || \ + "${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}" ]]; then + echo "::error::Release dispatch expected ${EXPECTED_SOURCE_SHA}, but GitHub resolved source ${GITHUB_SHA} and workflow ${GITHUB_WORKFLOW_SHA}." + exit 1 + fi + echo "[OK] Release dispatch is bound to ${EXPECTED_SOURCE_SHA}" + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f62068c0e..4a88c32e5 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2131,6 +2131,13 @@ artifact-selection behaviour. trigger, promotion resolver, rendered release body, current upgrade guide, or current release packet that routes systemd/LXC rollback through the Unified Agent installer, and must retain explicit Docker image guidance. +17. Bind every publishing release dispatch to the exact commit admitted by the + caller. `.github/workflows/create-release.yml` must require a full + 40-character `expected_source_sha` and, before checkout, reject the run + unless both `GITHUB_SHA` and `GITHUB_WORKFLOW_SHA` equal that commit. + `scripts/trigger-release.sh` and `scripts/trigger-stable-patch.sh` must send + the exact remote candidate SHA they already verified; branch ancestry or a + later branch tip is not equivalent release admission. ## Current State diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 23bc2e22c..1844891f2 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -933,6 +933,10 @@ func TestCreateReleaseUploadsPowerShellInstaller(t *testing.T) { convergenceWorkflow := string(convergenceContent) required := []string{ `historical_asset_backfill_only:`, + `expected_source_sha:`, + `EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}`, + `"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"`, + `"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"`, `description: 'Repair an already-published release packet in place without rebuilding binaries'`, `SYFT_VERSION="1.42.4"`, `SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"`, diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 62a810496..697c3e86d 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1689,6 +1689,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("build_rollback_section", renderer) self.assertIn("promotion metadata out of customer notes", renderer) self.assertIn("historical_asset_backfill_only:", content) + self.assertIn("expected_source_sha:", content) + self.assertIn('EXPECTED_SOURCE_SHA: ${{ inputs.expected_source_sha }}', content) + self.assertIn('"${GITHUB_SHA}" != "${EXPECTED_SOURCE_SHA}"', content) + self.assertIn('"${GITHUB_WORKFLOW_SHA}" != "${EXPECTED_SOURCE_SHA}"', content) self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content) self.assertIn("draft: true", content) self.assertIn("activate_release:", content) diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 0d6acd7f4..79a901e79 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -120,6 +120,7 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ --require version \ + --require expected_source_sha \ --require release_notes \ --require release_screenshot_plan \ --require promoted_from_tag \ @@ -372,6 +373,7 @@ echo "Triggering release workflow..." if [ -n "$NOTES_FILE" ]; then jq -n \ --arg version "$VERSION" \ + --arg expected_source_sha "$LOCAL" \ --rawfile release_notes "$NOTES_FILE" \ --rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \ --arg rollback_version "$ROLLBACK_VERSION" \ @@ -387,6 +389,7 @@ if [ -n "$NOTES_FILE" ]; then --arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \ '{ version: $version, + expected_source_sha: $expected_source_sha, release_notes: $release_notes, release_screenshot_plan: $release_screenshot_plan, rollback_version: $rollback_version, diff --git a/scripts/trigger-stable-patch.sh b/scripts/trigger-stable-patch.sh index c4f759894..efe146cd8 100755 --- a/scripts/trigger-stable-patch.sh +++ b/scripts/trigger-stable-patch.sh @@ -203,6 +203,7 @@ else --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ --require version \ + --require expected_source_sha \ --require release_notes \ --require release_screenshot_plan \ --require promoted_from_tag \ @@ -219,6 +220,7 @@ else jq -n \ --arg version "$VERSION" \ + --arg expected_source_sha "$LOCAL_SHA" \ --rawfile release_notes "$NOTES_FILE" \ --rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \ --arg promoted_from_tag "" \ @@ -234,6 +236,7 @@ else --arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \ '{ version: $version, + expected_source_sha: $expected_source_sha, release_notes: $release_notes, release_screenshot_plan: $release_screenshot_plan, promoted_from_tag: $promoted_from_tag, From 5017c599f0f3b22ff401f9832d490ae94fe07fc7 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:03:29 +0100 Subject: [PATCH 3/6] Bind rootful attestation to recovery test The pre-batch upstream rootful source-closure manifest was created before the local Unix recovery test. Include that compiled installer input so merged qualification evidence remains bound to the complete harness. Change-source: pulse-maintainer (cherry picked from commit f5ad4e343e6acbefcf94e5f36c0eebdcc429015c) --- .../secure_runtime_rootful_source_manifest_v1.json | 1 + 1 file changed, 1 insertion(+) 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 b5a76c0e7..6e1fc5408 100644 --- a/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json +++ b/scripts/release_control/secure_runtime_rootful_source_manifest_v1.json @@ -11,6 +11,7 @@ "pkg/agents/docker/report_limits.go", "scripts/install.sh", "scripts/release_ldflags.sh", + "scripts/installtests/agent_id_recovery_unix_test.go", "scripts/installtests/agent_state_dir_lifecycle_test.go", "scripts/installtests/backfill_release_assets_test.go", "scripts/installtests/build_release_assets_test.go", From bdf84e8adc23598536a1e33476b907dd15e24f34 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:44:13 +0100 Subject: [PATCH 4/6] Stabilize agent module retry lifecycle tests The Docker and Kubernetes retry tests used a whole-runtime timeout as both their synchronization point and shutdown trigger. Under suite load, startup could consume that deadline before the second module initialization, producing a false failure unrelated to retry behavior. Wait for the successful retry explicitly, then cancel and verify clean shutdown so the tests measure the lifecycle contract deterministically. Change-source: pulse-maintainer --- cmd/pulse-agent/main_test.go | 54 +++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index b2612d18a..f3889eaa7 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -2454,13 +2454,17 @@ func TestRun_DockerRetry(t *testing.T) { origDocker := newDockerAgent defer func() { newDockerAgent = origDocker }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + retrySucceeded := make(chan struct{}) + // First call fails, second succeeds - calls := 0 + var calls atomic.Int32 newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { - calls++ - if calls == 1 { + if calls.Add(1) == 1 { return nil, errors.New("not available yet") } + close(retrySucceeded) return &mockRunnableCloser{mockRunnable: mockRunnable{started: make(chan struct{})}}, nil } @@ -2469,8 +2473,6 @@ func TestRun_DockerRetry(t *testing.T) { retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() @@ -2479,17 +2481,26 @@ func TestRun_DockerRetry(t *testing.T) { errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-docker=true", "-enable-host=false"}, func(s string) string { return "" }) }() + select { + case <-retrySucceeded: + cancel() + case err := <-errCh: + t.Fatalf("run returned before Docker retry succeeded: %v", err) + case <-ctx.Done(): + t.Fatalf("Docker retry did not succeed: %v", ctx.Err()) + } + select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for run") + t.Fatal("timeout waiting for run to stop") } - if calls < 2 { - t.Errorf("expected at least 2 calls to newDockerAgent, got %d", calls) + if got := calls.Load(); got != 2 { + t.Errorf("newDockerAgent calls = %d, want 2", got) } } @@ -2547,13 +2558,17 @@ func TestRun_KubeRetry(t *testing.T) { origKube := newKubeAgent defer func() { newKubeAgent = origKube }() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + retrySucceeded := make(chan struct{}) + // First call fails, second succeeds - calls := 0 + var calls atomic.Int32 newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { - calls++ - if calls == 1 { + if calls.Add(1) == 1 { return nil, errors.New("not available yet") } + close(retrySucceeded) return &mockRunnable{started: make(chan struct{})}, nil } @@ -2562,8 +2577,6 @@ func TestRun_KubeRetry(t *testing.T) { retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() server := httptest.NewServer(http.NotFoundHandler()) defer server.Close() @@ -2573,17 +2586,26 @@ func TestRun_KubeRetry(t *testing.T) { errCh <- run(ctx, []string{"-token", "T", "-url", server.URL, "-enable-kubernetes=true", "-enable-host=false", "-enable-docker=false"}, func(s string) string { return "" }) }() + select { + case <-retrySucceeded: + cancel() + case err := <-errCh: + t.Fatalf("run returned before Kubernetes retry succeeded: %v", err) + case <-ctx.Done(): + t.Fatalf("Kubernetes retry did not succeed: %v", ctx.Err()) + } + select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for run") + t.Fatal("timeout waiting for run to stop") } - if calls < 2 { - t.Errorf("expected at least 2 calls to newKubeAgent, got %d", calls) + if got := calls.Load(); got != 2 { + t.Errorf("newKubeAgent calls = %d, want 2", got) } } From 9bda0b30db80fdbeb300a552fb4f086d0caf3699 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:54:21 +0100 Subject: [PATCH 5/6] Fix shell and settings accessibility regressions Keyboard and screen-reader users could not reliably bypass the shell or identify two General Settings toggles, while the RC badge failed the maintained contrast contract. Restoring explicit focus transfer, accessible control names, and compliant badge contrast keeps core navigation and settings usable without changing product scope. Change-source: pulse-maintainer Contract-Neutral: Accessibility regression fix only; no public contract or subsystem boundary changes. --- frontend-modern/browser-verification.json | 64 +++++++------------ frontend-modern/src/AppLayout.tsx | 6 +- .../src/__tests__/AppLayout.test.tsx | 11 +++- .../Settings/GeneralSettingsPanel.tsx | 2 + ...GeneralSettingsPanel.localization.test.tsx | 8 +++ .../83-product-trust-accessibility.spec.ts | 13 +++- 6 files changed, 60 insertions(+), 44 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 0ab28a8fd..dfb21230c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,23 @@ { "version": 1, - "base_sha": "2f8a4ec629b75c7ecefd38a9abc4b1511bc9a891", - "verified_at": "2026-09-01T19:54:19Z", + "base_sha": "44e274e5b386e5e990e9bf09b85b6258043487ba", + "verified_at": "2026-09-02T02:59:02Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/App.tsx", "frontend-modern/src/AppLayout.tsx", - "frontend-modern/src/components/shared/MobileNavBar.tsx", - "frontend-modern/src/components/shared/mobileNavBarModel.ts", - "frontend-modern/src/features/home/HomePageSurface.tsx", - "frontend-modern/src/features/home/homePageModel.ts", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/routing/navigation.ts", - "frontend-modern/src/routing/resourceLinks.ts", - "frontend-modern/src/routing/routePreload.ts", - "frontend-modern/src/utils/assistantPageContext.ts" + "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx" ], "content_sha256": { - "frontend-modern/src/App.tsx": "d5473f7838148edeaaf5564f01c166d97b6086232dcba70f6296b5c58e563a4b", - "frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373", - "frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5", - "frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04", - "frontend-modern/src/features/home/HomePageSurface.tsx": "deleted", - "frontend-modern/src/features/home/homePageModel.ts": "deleted", - "frontend-modern/src/i18n/messages.de.ts": "602246d3d4ce11a1a8a914027d950a30ba1f2f25850c385836013853957c2d0c", - "frontend-modern/src/i18n/messages.es.ts": "ed5a749603efad29293cd6964215f6edc21a4ce14bca99a48429ddd120770263", - "frontend-modern/src/i18n/messages.ts": "43a757e00eaa7879c400895c9c59a930e31513dbf03e0ae6d60f7e72a6ad9962", - "frontend-modern/src/routing/navigation.ts": "8ae1ad012e60ef345ffb3d18bb66f5d8af056758109655a8ec362ebf4c7b9556", - "frontend-modern/src/routing/resourceLinks.ts": "dee9a426de785e23390ba49c9067f55c8f923cecfce1247ff18f9b1004e0cc90", - "frontend-modern/src/routing/routePreload.ts": "ee79d423db0afcf8d76d1d39da59a13cb2c98e908e516a016cbb425982b25873", - "frontend-modern/src/utils/assistantPageContext.ts": "bfca19b4e183777ee1535073d48cce92ce31980f4105bab337f33a28538a6a35" + "frontend-modern/src/AppLayout.tsx": "be386bcaa58656a9397fed71a9d70147e2540ff6cb4aff53261d9c03e6ca3834", + "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "f1e1c0630cc841ae2820b15130a887aeef0ec7838cb93f652213ebb8fb278199" }, "routes": [ - "/", - "/home", - "/proxmox/overview" + "/actions", + "/alerts/overview", + "/settings/infrastructure", + "/settings/system-general", + "/patrol", + "/" ], "viewports": [ { @@ -44,21 +25,22 @@ "height": 720 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "signed-in desktop shell with primary tabs Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines and no Home tab", - "/home renders the Page Not Found surface with 'No route matched /home' and a Go to workspace button", - "signed-in narrow shell on /proxmox/overview with the bottom navigation bar showing Proxmox, Alerts, Patrol, Actions, More and no Home entry", - "More navigation sheet open at narrow width listing Settings only", - "no horizontal page overflow at 375px; no console errors at either width" + "authenticated empty Actions surface at phone width with the RC Preview badge visible, no horizontal overflow, and the skip link focused", + "authenticated General Settings surface with Full-width mode off and Outbound usage telemetry on, each exposed as a named pressed-state button", + "authenticated Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion enabled and no automatically detectable WCAG A/AA violations", + "Add infrastructure dialog open with its accessible description, close control focused, and underlying shell retained", + "logged-out welcome surface with reduced motion enabled and no automatically detectable WCAG A/AA violations" ], "interactions": [ - "navigated to / and /home at desktop width and read the rendered nav and main headings", - "pressed Go to workspace on the /home not-found page and landed on /proxmox/overview", - "resized to 375x812, loaded /proxmox/overview, opened the More navigation sheet, closed it with Escape", - "confirmed the default landing route and nav order are unchanged from the parent revision apart from the removed Home entry" + "tabbed from the document start to Skip to main content at phone width, activated it with Enter, and confirmed focus moved to the main landmark", + "inspected the rendered 390x844 Actions screenshot for badge contrast, placement, clipping, scrolling, and bottom-navigation coherence", + "navigated the authenticated desktop routes and scanned their rendered states for WCAG A/AA violations and unexpected reduced-motion effects", + "opened Add infrastructure, verified initial close-control focus and dialog description, dismissed it with Escape, and confirmed focus returned to Add infrastructure", + "opened the logged-out entry surface and verified its heading, disabled welcome/form motion, and accessibility scan" ] } diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 2fae9e46d..3d4ea0def 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -258,6 +258,7 @@ export function AppLayout(props: AppLayoutProps) { const [skipLinkFocused, setSkipLinkFocused] = createSignal(false); const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0); let headerEl: HTMLDivElement | undefined; + let mainContentEl: HTMLElement | undefined; let assistantLauncherEl: HTMLButtonElement | undefined; let restoreAssistantLauncherFocus = false; let headerHideTimeout: ReturnType | undefined; @@ -730,6 +731,7 @@ export function AppLayout(props: AppLayoutProps) { jump past the chrome straight into the page content. */} mainContentEl?.focus()} onFocus={() => setSkipLinkFocused(true)} onBlur={() => setSkipLinkFocused(false)} class={ @@ -814,7 +816,7 @@ export function AppLayout(props: AppLayoutProps) { - + Preview @@ -980,7 +982,9 @@ export function AppLayout(props: AppLayoutProps) {
diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 821a963de..d097bb98a 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -174,7 +174,16 @@ describe('AppLayout navigation icons', () => { expect(container.querySelector('.pulse-shell')).toHaveClass('pb-safe-or-14'); expect(container.querySelector('.pulse-shell')).not.toHaveClass('pb-safe-or-16'); expect(container.querySelector('.header')).toHaveClass('mb-1', 'sm:mb-3'); - expect(container.querySelector('main')).toHaveClass('mb-1', 'sm:mb-2'); + const main = container.querySelector('main'); + expect(main).toHaveClass('mb-1', 'sm:mb-2'); + expect(main).toHaveAttribute('id', 'main'); + expect(main).toHaveAttribute('tabindex', '-1'); + const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); + expect(skipLink).toHaveAttribute('href', '#main'); + fireEvent.click(skipLink); + expect(main).toHaveFocus(); + expect(screen.getByText('Preview')).toHaveClass('bg-orange-700', 'text-white'); + expect(screen.getByText('Preview')).not.toHaveClass('bg-orange-500'); expect(container.querySelector('footer')).toHaveClass('pulse-footer', 'px-2', 'sm:px-4'); const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' }); diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 0f3b9ad5f..d6f00707e 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -223,6 +223,7 @@ export const GeneralSettingsPanel: Component = (props layoutStore.toggle()} />
@@ -276,6 +277,7 @@ export const GeneralSettingsPanel: Component = (props props.handleTelemetryEnabledChange(!props.telemetryEnabled())} /> diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx index d37365766..3cd87ddc7 100644 --- a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -86,6 +86,14 @@ describe('GeneralSettingsPanel localization', () => { ).toBeInTheDocument(); expect(screen.getByText('Usage data and privacy')).toBeInTheDocument(); expect(screen.getByText('Outbound usage telemetry')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Full-width mode' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + expect(screen.getByRole('button', { name: 'Outbound usage telemetry' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); expect(screen.getByRole('button', { name: 'Preview payload' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reset ID' })).toBeInTheDocument(); expect(screen.getByText('Monitoring cadence')).toBeInTheDocument(); diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts index 553474bb9..48b74f8d7 100644 --- a/tests/integration/tests/83-product-trust-accessibility.spec.ts +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -137,8 +137,19 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o document.documentElement.clientWidth, ); expect(overflow).toBeFalsy(); + + const skipLink = page.getByRole("link", { name: "Skip to main content" }); + await page.evaluate(() => { + document.body.tabIndex = -1; + document.body.focus(); + document.body.removeAttribute("tabindex"); + }); await page.keyboard.press("Tab"); - await expect(page.locator(":focus")).toBeVisible(); + await expect(skipLink).toBeFocused(); + await expect(skipLink).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page.locator("#main")).toBeFocused(); + await testInfo.attach("actions-phone-width", { body: await page.screenshot(), contentType: "image/png", From d680c339d9ff49ccbd030db98a8da8501d8b3d72 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 04:40:19 +0100 Subject: [PATCH 6/6] Require accessible names for shared dialogs Make the shared Dialog prop contract require exactly one accessible-name strategy so new modal call sites cannot silently omit screen-reader context. Record the canonical primitive contract, preserve existing runtime behavior, and cover both valid strategies plus invalid unnamed and ambiguous props. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 4 ++ frontend-modern/browser-verification.json | 37 ++++++++-------- .../src/components/shared/Dialog.tsx | 16 +++++-- .../shared/__tests__/Dialog.test.tsx | 42 ++++++++++++++----- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8988419bd..a7ded1465 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -41,6 +41,10 @@ overlay's accessible heading. A reusable panel may suppress its standalone title when the owning overlay supplies the canonical title, while preserving that title in inline and desktop contexts; the overlay remains responsible for one visible heading, its accessible label, dismissal, and focus return. +The shared `Dialog` component requires exactly one accessible-name strategy at +its component boundary: consumers provide either `ariaLabelledBy` for a visible +heading or `ariaLabel` when no visible label is available. Unnamed dialogs and +consumers that provide both strategies must fail the frontend type boundary. The alert schedule's initial-delivery selector composes `SettingsPanel` and `FormSelect`, uses the shared alert-configuration presentation vocabulary, and exposes the same email, webhook, Apprise, and all-destination labels used by diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index dfb21230c..2d7ac6f24 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,20 +1,16 @@ { "version": 1, - "base_sha": "44e274e5b386e5e990e9bf09b85b6258043487ba", - "verified_at": "2026-09-02T02:59:02Z", + "base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab", + "verified_at": "2026-09-02T03:39:02Z", "result": "passed", - "changed_paths": [ - "frontend-modern/src/AppLayout.tsx", - "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx" - ], + "changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"], "content_sha256": { - "frontend-modern/src/AppLayout.tsx": "be386bcaa58656a9397fed71a9d70147e2540ff6cb4aff53261d9c03e6ca3834", - "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "f1e1c0630cc841ae2820b15130a887aeef0ec7838cb93f652213ebb8fb278199" + "frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735" }, "routes": [ + "/settings/infrastructure", "/actions", "/alerts/overview", - "/settings/infrastructure", "/settings/system-general", "/patrol", "/" @@ -27,20 +23,23 @@ { "width": 390, "height": 844 + }, + { + "width": 393, + "height": 851 } ], "states": [ - "authenticated empty Actions surface at phone width with the RC Preview badge visible, no horizontal overflow, and the skip link focused", - "authenticated General Settings surface with Full-width mode off and Outbound usage telemetry on, each exposed as a named pressed-state button", - "authenticated Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion enabled and no automatically detectable WCAG A/AA violations", - "Add infrastructure dialog open with its accessible description, close control focused, and underlying shell retained", - "logged-out welcome surface with reduced motion enabled and no automatically detectable WCAG A/AA violations" + "Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow", + "Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored", + "authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations", + "logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations" ], "interactions": [ - "tabbed from the document start to Skip to main content at phone width, activated it with Enter, and confirmed focus moved to the main landmark", - "inspected the rendered 390x844 Actions screenshot for badge contrast, placement, clipping, scrolling, and bottom-navigation coherence", - "navigated the authenticated desktop routes and scanned their rendered states for WCAG A/AA violations and unexpected reduced-motion effects", - "opened Add infrastructure, verified initial close-control focus and dialog description, dismissed it with Escape, and confirmed focus returned to Add infrastructure", - "opened the logged-out entry surface and verified its heading, disabled welcome/form motion, and accessibility scan" + "opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description", + "inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout", + "verified the dialog bounds stay inside both viewports and the document has no horizontal overflow", + "dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure", + "scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects" ] } diff --git a/frontend-modern/src/components/shared/Dialog.tsx b/frontend-modern/src/components/shared/Dialog.tsx index f00014d07..a6bd2989f 100644 --- a/frontend-modern/src/components/shared/Dialog.tsx +++ b/frontend-modern/src/components/shared/Dialog.tsx @@ -9,19 +9,29 @@ import { } from './dialogModel'; import { useDialogState } from './useDialogState'; -interface DialogProps { +interface DialogBaseProps { isOpen: boolean; onClose: () => void; children: JSX.Element; panelClass?: string; layout?: DialogLayout; closeOnBackdrop?: boolean; - ariaLabel?: string; - ariaLabelledBy?: string; ariaDescribedBy?: string; returnFocus?: () => HTMLElement | null | undefined; } +type DialogProps = DialogBaseProps & + ( + | { + ariaLabel: string; + ariaLabelledBy?: never; + } + | { + ariaLabel?: never; + ariaLabelledBy: string; + } + ); + export const Dialog: Component = (props) => { const state = useDialogState(props); diff --git a/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx b/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx index 1d86f3a64..3f172a1d8 100644 --- a/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Dialog.test.tsx @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { createSignal, Show } from 'solid-js'; +import type { ComponentProps, JSX } from 'solid-js'; import { Dialog } from '@/components/shared/Dialog'; import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; import dialogSource from '@/components/shared/Dialog.tsx?raw'; @@ -53,10 +54,30 @@ describe('Dialog', () => { expect(dialogModelSource).toContain('FOCUSABLE_SELECTOR'); }); + it('requires exactly one accessible-name strategy at the component boundary', () => { + type UnnamedDialogProps = { + isOpen: boolean; + onClose: () => void; + children: JSX.Element; + }; + type DialogComponentProps = ComponentProps; + + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabel: string } + >().toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabelledBy: string } + >().toMatchTypeOf(); + expectTypeOf< + UnnamedDialogProps & { ariaLabel: string; ariaLabelledBy: string } + >().not.toMatchTypeOf(); + }); + it('renders as a modal dialog and closes on backdrop click', () => { const onClose = vi.fn(); render(() => ( - +
@@ -76,7 +97,7 @@ describe('Dialog', () => { it('closes on Escape and locks body scroll while open', () => { const onClose = vi.fn(); const { unmount } = render(() => ( - +
Body
)); @@ -96,7 +117,7 @@ describe('Dialog', () => { expect(dialogStackHasBlockingDialog()).toBe(false); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog">
Body
)); @@ -112,7 +133,7 @@ describe('Dialog', () => { document.body.appendChild(background); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -133,7 +154,7 @@ describe('Dialog', () => { document.body.appendChild(background); const { unmount } = render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -178,7 +199,7 @@ describe('Dialog', () => { it('makes body-level surfaces added while a dialog is open inert', async () => { render(() => ( - undefined}> + undefined} ariaLabel="Test dialog"> )); @@ -194,7 +215,7 @@ describe('Dialog', () => { it('keeps keyboard focus trapped in the dialog', async () => { const onClose = vi.fn(); render(() => ( - +
@@ -294,7 +315,7 @@ describe('Dialog', () => { it('honors an explicitly requested initial focus target', async () => { render(() => ( - undefined}> + undefined} ariaLabel="Test dialog">