diff --git a/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-blocked-2026-05-01.md b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-blocked-2026-05-01.md new file mode 100644 index 000000000..70afdd775 --- /dev/null +++ b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-blocked-2026-05-01.md @@ -0,0 +1,71 @@ +# Known RC Issue Closure For GA Blocked Record + +- Date: `2026-05-01` +- Gate: `known-rc-issue-closure-for-ga` +- Result: `blocked` + +## Context + +The RC3 maintenance audit compared the active v5 maintenance line against +`pulse/v6-release` and reviewed recently updated GitHub issues, comments, and +discussion signals before cutting another v6 release candidate. + +## Fixed In The Current RC3 Patch + +1. v5 `#1444`: alert cooldown set to disabled no longer means "notify every + metric tick." The v6 alert cooldown gate now sends the first notification for + an alert occurrence and suppresses subsequent re-notifications while + cooldown remains disabled. +2. v5 `#1440`: unsaved SMTP test-send settings no longer mutate or inherit the + shared production email manager when the SMTP/auth transport differs. This + prevents relay-mode tests from leaking stale saved authentication. +3. v5 DOMPurify security bump: the v6 frontend lockfile now resolves + `dompurify` to `3.4.1`. +4. `#1451` bootstrap-token confusion: the root installer no longer prints the + encrypted `.bootstrap_token` file contents as the user-facing token. It uses + the canonical `pulse bootstrap-token` command with the install data directory + instead. + +Proof run during the audit: + +- `go test ./internal/alerts ./internal/notifications` +- `go test ./scripts/installtests` +- `bash -n install.sh` +- `npm --prefix frontend-modern ls dompurify --package-lock-only` + +## v5 Fixes Already Carried By v6 + +The v5 maintenance audit found that several recent v5 fixes already have v6 +equivalents on `pulse/v6-release`, including QNAP update/boot continuity, +unified host/docker row merging, mdstat operation gating for RAID rebuild +alerts, stable agent identity files, linked guest filesystem display, +unreachable-guest snapshot carry-forward, update progress modal closability, +Ollama `keep_alive=30s`, and SSE EOF tool-call finalization. + +## Remaining RC3 Triage Candidates + +1. `#1441` affects `6.0.0-rc.2`: the reporter's screenshot shows the Proxmox + settings row reporting `Online` while the cluster popover reports Proxmox + offline for the same nodes. This is a v6 RC2 user-visible trust issue and + needs a root status-model fix, invalidation proof, or explicit supersession + before this gate can be called clear again. +2. `#1452` affects `5.1.28`: screenshots show graph tooltip content covering + the hovered chart point and surrounding graph detail. v6 shares the history + chart tooltip model, so RC3 should either fix the shared tooltip placement or + prove the v6 surface is not affected with browser evidence. +3. `#1448` discussion: PBS alert thresholds were reported as firing despite the + threshold being off. The alert cooldown fix may reduce notification spam, but + this still needs threshold-specific triage if reproducible on v6. +4. `#1435` release sequencing: the stable LXC installer branch now filters + prereleases correctly, and GitHub latest points at `v5.1.28`, but the + affected release asset is not repaired for stable users until the next v5 + stable asset is published or backfilled. This is not a v6 code blocker, but + it remains part of the RC3/v5 sequencing checklist. + +## Outcome + +The audit removed several high-confidence v5-to-v6 regressions from the RC3 +candidate, but later RC issue intake is not fully dispositioned. The +`known-rc-issue-closure-for-ga` gate must remain blocked until the remaining +RC3 triage candidates above are fixed, proven invalid, or conservatively +superseded. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 34153adf3..94b908d01 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4340,7 +4340,7 @@ "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "managed-runtime-exercise", - "status": "passed", + "status": "blocked", "verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", "lane_ids": [ "L1", @@ -4358,6 +4358,12 @@ "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-2026-04-21.md", "kind": "file", "evidence_tier": "managed-runtime-exercise" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-blocked-2026-05-01.md", + "kind": "file", + "evidence_tier": "managed-runtime-exercise" } ] }, diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 3e2029f59..2cf7507df 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -305,6 +305,12 @@ reset behavior, quiet-hours day/category toggles, cooldown/grouping/escalation update policy, and the canonical defaults handoff. Future schedule control-flow work should extend that hook instead of rebuilding those mutations inline in the tab shell. +The backend cooldown gate is part of that same schedule contract. A disabled +cooldown (`0` or negative) means "do not send periodic re-notifications for +the same active alert"; it still allows the first notification for a new alert +occurrence, while level-escalation delivery remains owned by the separate +escalation path. Runtime evaluation must not treat disabled cooldown as +"always notify" because the alert loop runs every metric tick. Incident-event filter chip and filter-action styling now routes through `frontend-modern/src/utils/alertIncidentPresentation.ts` for both diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 85c695caf..95d99d224 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -658,6 +658,11 @@ developer/runtime bootstrap. Changes to `package.json`, `package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, and `go.sum` must remain governed with that entrypoint boundary rather than floating as unowned dependency or build-runtime drift. +Security-driven lockfile bumps for packages shipped in the release frontend +are part of the same governed bootstrap input even when the package manifest +range already permits the newer version; the lockfile must identify the +resolved package version and integrity that the release build will actually +consume. When the managed launcher reports runtime status, it must tell operators which browser URL to use and whether the frontend shell, proxied API path, and direct backend health endpoint all agree, instead of leaving `5173` versus @@ -989,6 +994,11 @@ setup-token-authenticated endpoint or depend on scraping a plaintext `.bootstrap_token` file just to call it. The supported operator retrieval path for first-session bootstrap is `pulse bootstrap-token`, and runtime bootstrap token persistence must stay encrypted at rest. +Root installer completion output, LXC post-install guidance, and copied +first-session setup instructions must also route operators through +`pulse bootstrap-token` with the correct runtime data directory instead of +printing or instructing users to `cat` `.bootstrap_token`, because the file is +an encrypted persistence artifact rather than the raw setup token. That same bootstrap artifact contract must now be backend-owned as one canonical install artifact model rather than a handler-local bootstrap struct plus a second response envelope. Shell downloads, setup-script-url responses, diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 2d3cd6304..bac9f8688 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -96,6 +96,14 @@ Email single-alert, grouped, resolved, and HTML send paths must follow that same ownership rule: they may expose different calling surfaces, but they must all route through one canonical enhanced email executor instead of rebuilding separate manager/config setup paths. +That enhanced email executor owns the production-manager reuse boundary as +well as the transport send itself. A test or ad hoc send whose SMTP host, port, +username, password, TLS, STARTTLS, or provider differs from the shared manager +must build an isolated delivery manager and leave the production manager +untouched, so unsaved relay-mode tests cannot inherit stale saved SMTP auth. +When the transport identity matches, the shared manager may still update +From/To and rate-limit presentation state so grouped and resolved sends keep +their persistent limiter continuity. Notification test APIs must follow that same truthfulness rule: test email and webhook paths may keep dedicated top-level entry points, but they must route through canonical error-returning single-delivery executors instead of diff --git a/install.sh b/install.sh index 4c87054e8..091d81443 100755 --- a/install.sh +++ b/install.sh @@ -1866,7 +1866,9 @@ create_lxc_container() { fi echo echo " First-time setup:" - echo " pct exec $CTID -- cat $CONFIG_DIR/.bootstrap_token # Get bootstrap token" + local QUOTED_CONFIG_DIR + printf -v QUOTED_CONFIG_DIR '%q' "$CONFIG_DIR" + echo " pct exec $CTID -- env PULSE_DATA_DIR=$QUOTED_CONFIG_DIR pulse bootstrap-token" echo echo " Common commands:" echo " pct enter $CTID # Enter container" @@ -3786,17 +3788,36 @@ print_completion() { local TOKEN_DATA_DIR="${CONFIG_DIR:-/etc/pulse}" local TOKEN_FILE="$TOKEN_DATA_DIR/.bootstrap_token" if [[ -f "$TOKEN_FILE" ]]; then - BOOTSTRAP_TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null | tr -d '\n') - if [[ -n "$BOOTSTRAP_TOKEN" ]]; then - echo + local -a BOOTSTRAP_TOKEN_COMMAND=() + if [[ -x "$BINARY_LINK_PATH" ]]; then + BOOTSTRAP_TOKEN_COMMAND=("$BINARY_LINK_PATH" "bootstrap-token") + elif [[ -x "$INSTALL_DIR/bin/pulse" ]]; then + BOOTSTRAP_TOKEN_COMMAND=("$INSTALL_DIR/bin/pulse" "bootstrap-token") + elif command -v pulse >/dev/null 2>&1; then + BOOTSTRAP_TOKEN_COMMAND=("$(command -v pulse)" "bootstrap-token") + fi + + echo + if [[ ${#BOOTSTRAP_TOKEN_COMMAND[@]} -gt 0 ]]; then + if ! PULSE_DATA_DIR="$TOKEN_DATA_DIR" "${BOOTSTRAP_TOKEN_COMMAND[@]}"; then + local QUOTED_TOKEN_DATA_DIR + local TOKEN_COMMAND_DISPLAY + printf -v QUOTED_TOKEN_DATA_DIR '%q' "$TOKEN_DATA_DIR" + printf -v TOKEN_COMMAND_DISPLAY '%q ' "${BOOTSTRAP_TOKEN_COMMAND[@]}" + TOKEN_COMMAND_DISPLAY="${TOKEN_COMMAND_DISPLAY% }" + print_warn "Bootstrap token exists but could not be displayed automatically." + print_info "Run: PULSE_DATA_DIR=$QUOTED_TOKEN_DATA_DIR $TOKEN_COMMAND_DISPLAY" + fi + else echo -e "${YELLOW}╔═══════════════════════════════════════════════════════════════════════╗${NC}" echo -e "${YELLOW}║ BOOTSTRAP TOKEN REQUIRED FOR FIRST-TIME SETUP ║${NC}" echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}" - printf "${YELLOW}║${NC} Token: ${GREEN}%-61s${YELLOW}║${NC}\n" "$BOOTSTRAP_TOKEN" - printf "${YELLOW}║${NC} File: %-61s${YELLOW}║${NC}\n" "$TOKEN_FILE" + echo -e "${YELLOW}║${NC} Run this command on the Pulse host to reveal the setup token: ${YELLOW}║${NC}" + printf "${YELLOW}║${NC} PULSE_DATA_DIR=%-47s${YELLOW}║${NC}\n" "$TOKEN_DATA_DIR" + printf "${YELLOW}║${NC} %s/bin/pulse bootstrap-token%-37s${YELLOW}║${NC}\n" "$INSTALL_DIR" "" echo -e "${YELLOW}╠═══════════════════════════════════════════════════════════════════════╣${NC}" - echo -e "${YELLOW}║${NC} Copy this token and paste it into the unlock screen in your browser. ${YELLOW}║${NC}" - echo -e "${YELLOW}║${NC} This token will be automatically deleted after successful setup. ${YELLOW}║${NC}" + echo -e "${YELLOW}║${NC} Paste the token into the unlock screen in your browser. ${YELLOW}║${NC}" + echo -e "${YELLOW}║${NC} This token will be automatically deleted after successful setup. ${YELLOW}║${NC}" echo -e "${YELLOW}╚═══════════════════════════════════════════════════════════════════════╝${NC}" fi fi diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 23f379c14..6c32b7e16 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -2658,20 +2658,28 @@ func (m *Manager) ShouldSuppressResolvedNotification(alert *Alert) bool { return suppressed } -// shouldNotifyAfterCooldown checks if enough time has passed since the last notification -// Returns true if notification should be sent, false if still in cooldown period +// shouldNotifyAfterCooldown decides whether a re-notification can be sent for +// an existing alert based on the configured cooldown. +// +// Cooldown semantics: +// - cooldown > 0: re-notify after that many minutes have passed since the +// last notification. +// - cooldown <= 0: cooldown is disabled, so only the first notification for +// an alert occurrence is allowed. The alert evaluation loop runs every +// metric tick, so treating disabled cooldown as "always allow" causes +// repeated notifications for the same active alert. +// +// Level-escalation re-notifications are handled separately at the call site. +// Returns true if a notification should be sent, false otherwise. func (m *Manager) shouldNotifyAfterCooldown(alert *Alert) bool { - // If cooldown is 0 or negative, always allow notifications - if m.config.Schedule.Cooldown <= 0 { - return true - } - - // If this is the first notification, allow it if alert.LastNotified == nil { return true } - // Check if enough time has passed + if m.config.Schedule.Cooldown <= 0 { + return false + } + cooldownDuration := time.Duration(m.config.Schedule.Cooldown) * time.Minute timeSinceLastNotification := time.Since(*alert.LastNotified) diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 2ed3594c9..0b9de9898 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -4776,8 +4776,7 @@ func TestApplyRelaxedGuestThresholds(t *testing.T) { func TestShouldNotifyAfterCooldown(t *testing.T) { // t.Parallel() - t.Run("cooldown disabled allows notification", func(t *testing.T) { - // t.Parallel() + t.Run("cooldown disabled allows first-time notification", func(t *testing.T) { m := newTestManager(t) m.mu.Lock() m.config.Schedule.Cooldown = 0 @@ -4789,12 +4788,28 @@ func TestShouldNotifyAfterCooldown(t *testing.T) { } if !m.shouldNotifyAfterCooldown(alert) { - t.Error("expected true when cooldown is 0") + t.Error("expected true for first-time alert when cooldown is 0") } }) - t.Run("negative cooldown allows notification", func(t *testing.T) { - // t.Parallel() + t.Run("cooldown disabled suppresses re-notification", func(t *testing.T) { + m := newTestManager(t) + m.mu.Lock() + m.config.Schedule.Cooldown = 0 + m.mu.Unlock() + + now := time.Now() + alert := &Alert{ + ID: "test-alert", + LastNotified: &now, + } + + if m.shouldNotifyAfterCooldown(alert) { + t.Error("expected false: cooldown=0 must not allow re-notification spam (#1444)") + } + }) + + t.Run("negative cooldown suppresses re-notification", func(t *testing.T) { m := newTestManager(t) m.mu.Lock() m.config.Schedule.Cooldown = -5 @@ -4806,8 +4821,8 @@ func TestShouldNotifyAfterCooldown(t *testing.T) { LastNotified: &now, } - if !m.shouldNotifyAfterCooldown(alert) { - t.Error("expected true when cooldown is negative") + if m.shouldNotifyAfterCooldown(alert) { + t.Error("expected false: negative cooldown is treated the same as disabled cooldown") } }) diff --git a/internal/notifications/email_legacy_coverage_test.go b/internal/notifications/email_legacy_coverage_test.go index fc834a625..8e9269a78 100644 --- a/internal/notifications/email_legacy_coverage_test.go +++ b/internal/notifications/email_legacy_coverage_test.go @@ -324,8 +324,10 @@ func TestSendGroupedEmail_UsesFromAsRecipient(t *testing.T) { } config := EmailConfig{ - From: "sender@example.com", - To: nil, + From: "sender@example.com", + To: nil, + SMTPHost: "smtp.example.com", + SMTPPort: 25, } alertList := []*alerts.Alert{ @@ -360,7 +362,7 @@ func TestSendEmail_UsesSharedDeliveryExecutor(t *testing.T) { EmailConfig: EmailConfig{ From: "old@example.com", To: []string{"old-recipient@example.com"}, - SMTPHost: "old.localhost.test", + SMTPHost: "smtp.example.com", SMTPPort: 25, }, }) diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index c4551b48c..4aedc3b7b 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1660,6 +1660,49 @@ func effectiveEmailRecipients(config EmailConfig) []string { return recipients } +func newEmailDeliveryManager(config EmailConfig, recipients []string) *EnhancedEmailManager { + rl := effectiveEmailRateLimit(config.RateLimit) + return NewEnhancedEmailManager(EmailProviderConfig{ + EmailConfig: EmailConfig{ + Provider: config.Provider, + From: config.From, + To: recipients, + SMTPHost: config.SMTPHost, + SMTPPort: config.SMTPPort, + Username: config.Username, + Password: config.Password, + TLS: config.TLS, + StartTLS: config.StartTLS, + RateLimit: config.RateLimit, + }, + Provider: config.Provider, + StartTLS: config.StartTLS, + MaxRetries: 2, + RetryDelay: 3, + RateLimit: rl, + SkipTLSVerify: false, + AuthRequired: config.Username != "" && config.Password != "", + }) +} + +func emailDeliveryTransportMatches(manager *EnhancedEmailManager, config EmailConfig) bool { + if manager == nil { + return false + } + persisted := manager.config.EmailConfig + persistedProvider := manager.config.Provider + if persistedProvider == "" { + persistedProvider = persisted.Provider + } + return persisted.SMTPHost == config.SMTPHost && + persisted.SMTPPort == config.SMTPPort && + persisted.Username == config.Username && + persisted.Password == config.Password && + persisted.TLS == config.TLS && + persisted.StartTLS == config.StartTLS && + persistedProvider == config.Provider +} + func (n *NotificationManager) emailDeliveryManager(config EmailConfig) (*EnhancedEmailManager, []string) { recipients := effectiveEmailRecipients(config) @@ -1668,38 +1711,24 @@ func (n *NotificationManager) emailDeliveryManager(config EmailConfig) (*Enhance n.mu.RUnlock() if manager == nil { - rl := effectiveEmailRateLimit(config.RateLimit) - manager = NewEnhancedEmailManager(EmailProviderConfig{ - EmailConfig: EmailConfig{ - From: config.From, - To: recipients, - SMTPHost: config.SMTPHost, - SMTPPort: config.SMTPPort, - Username: config.Username, - Password: config.Password, - TLS: config.TLS, - StartTLS: config.StartTLS, - }, - Provider: config.Provider, - StartTLS: config.StartTLS, - MaxRetries: 2, - RetryDelay: 3, - RateLimit: rl, - SkipTLSVerify: false, - AuthRequired: config.Username != "" && config.Password != "", - }) - return manager, recipients + return newEmailDeliveryManager(config, recipients), recipients + } + + if !emailDeliveryTransportMatches(manager, config) { + return newEmailDeliveryManager(config, recipients), recipients } manager.config.EmailConfig = EmailConfig{ - From: config.From, - To: recipients, - SMTPHost: config.SMTPHost, - SMTPPort: config.SMTPPort, - Username: config.Username, - Password: config.Password, - TLS: config.TLS, - StartTLS: config.StartTLS, + Provider: config.Provider, + From: config.From, + To: recipients, + SMTPHost: config.SMTPHost, + SMTPPort: config.SMTPPort, + Username: config.Username, + Password: config.Password, + TLS: config.TLS, + StartTLS: config.StartTLS, + RateLimit: config.RateLimit, } manager.config.Provider = config.Provider manager.config.StartTLS = config.StartTLS diff --git a/internal/notifications/notifications_additional_test.go b/internal/notifications/notifications_additional_test.go index 2320ac001..70a9a04cf 100644 --- a/internal/notifications/notifications_additional_test.go +++ b/internal/notifications/notifications_additional_test.go @@ -483,9 +483,11 @@ func TestSendResolvedNotificationsDirectDispatchesEnabledTargets(t *testing.T) { manager.sendResolvedNotificationsDirect( EmailConfig{ - Enabled: true, - From: "new@example.com", - To: []string{"new@example.com"}, + Enabled: true, + From: "new@example.com", + To: []string{"new@example.com"}, + SMTPHost: "invalid.localhost.test", + SMTPPort: 25, }, []WebhookConfig{ {Name: "enabled", URL: server.URL + "/ok", Enabled: true, Service: "ntfy"}, diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index 859c960b8..32e8a2b2e 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -2800,7 +2800,7 @@ func TestSendHTMLEmailWithError_ExistingEmailManager(t *testing.T) { EmailConfig: EmailConfig{ From: "old@example.com", To: []string{"old-recipient@example.com"}, - SMTPHost: "old.localhost.test", + SMTPHost: "invalid.localhost.test", SMTPPort: 25, }, }) @@ -2813,21 +2813,65 @@ func TestSendHTMLEmailWithError_ExistingEmailManager(t *testing.T) { From: "new@example.com", To: []string{"new-recipient@example.com"}, SMTPHost: "invalid.localhost.test", - SMTPPort: 587, + SMTPPort: 25, } - // Will fail but exercises the "update existing manager config" path + // Will fail but exercises the "update existing manager config" path when the + // passed config matches the persisted SMTP target. err := nm.sendHTMLEmailWithError("Test Subject", "

test

", "test", config) if err == nil { t.Error("expected error for invalid SMTP host") } - // Verify the manager's config was updated + // Verify the manager's From/To were updated on the shared delivery path. if existingManager.config.EmailConfig.From != "new@example.com" { t.Errorf("expected From to be updated to 'new@example.com', got %q", existingManager.config.EmailConfig.From) } } +func TestSendHTMLEmailWithError_DifferingConfigDoesNotMutateManager(t *testing.T) { + existingManager := NewEnhancedEmailManager(EmailProviderConfig{ + EmailConfig: EmailConfig{ + From: "saved@example.com", + To: []string{"saved-recipient@example.com"}, + SMTPHost: "saved.localhost.test", + SMTPPort: 587, + Username: "saved-user", + Password: "saved-pass", + }, + AuthRequired: true, + }) + + nm := &NotificationManager{ + emailManager: existingManager, + } + + config := EmailConfig{ + From: "test@example.com", + To: []string{"test-recipient@example.com"}, + SMTPHost: "invalid.localhost.test", + SMTPPort: 25, + } + + err := nm.sendHTMLEmailWithError("Test Subject", "

test

", "test", config) + if err == nil { + t.Error("expected error for unreachable SMTP host") + } + + if got := existingManager.config.EmailConfig.SMTPHost; got != "saved.localhost.test" { + t.Errorf("shared manager SMTPHost was mutated: got %q, want %q", got, "saved.localhost.test") + } + if got := existingManager.config.EmailConfig.Username; got != "saved-user" { + t.Errorf("shared manager Username was mutated: got %q, want %q", got, "saved-user") + } + if got := existingManager.config.EmailConfig.From; got != "saved@example.com" { + t.Errorf("shared manager From was mutated: got %q, want %q", got, "saved@example.com") + } + if !existingManager.config.AuthRequired { + t.Error("shared manager AuthRequired was cleared by a test send") + } +} + func TestSendNotificationsDirect_MultipleWebhooks(t *testing.T) { origSpawn := spawnAsync spawnAsync = func(func()) {} diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index e3cfd177b..65d4b7089 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -1954,6 +1954,79 @@ func TestBuildPrintedManagementCommandPreservesRCChannel(t *testing.T) { } } +func TestRootPrintCompletionRevealsBootstrapTokenThroughCLI(t *testing.T) { + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + installDir := filepath.Join(tmpDir, "install") + pulseBin := filepath.Join(tmpDir, "bin", "pulse") + + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + if err := os.MkdirAll(filepath.Dir(pulseBin), 0755); err != nil { + t.Fatalf("mkdir pulse bin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, ".bootstrap_token"), []byte(`{"version":2,"token_ciphertext":"encrypted-token","token_hash":"hash"}`), 0600); err != nil { + t.Fatalf("write encrypted bootstrap token marker: %v", err) + } + if err := os.WriteFile(pulseBin, []byte(`#!/usr/bin/env bash +if [[ "$1" != "bootstrap-token" ]]; then + exit 2 +fi +printf 'Token: raw-bootstrap-token\n' +printf 'Data: %s\n' "$PULSE_DATA_DIR" +`), 0755); err != nil { + t.Fatalf("write fake pulse binary: %v", err) + } + + script := ` + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' + CONFIG_DIR="$TEST_CONFIG_DIR" + INSTALL_DIR="$TEST_INSTALL_DIR" + BINARY_LINK_PATH="$TEST_PULSE_BIN" + UPDATE_HELPER_PATH="/bin/update" + SERVICE_NAME="pulse" + UPDATE_TIMER_UNIT="pulse-update.timer" + hostname() { if [[ "${1:-}" == "-I" ]]; then printf '127.0.0.1\n'; else command hostname "$@"; fi; } + current_frontend_port() { printf '7655\n'; } + print_header() { :; } + print_success() { printf '%s\n' "$1"; } + print_warn() { printf 'WARN: %s\n' "$1"; } + print_info() { printf 'INFO: %s\n' "$1"; } + update_timer_exists() { return 1; } + update_timer_enabled() { return 1; } + build_printed_management_command() { printf '/bin/update\n'; } +` + extractRootInstallShellFunction(t, "print_completion") + ` + print_completion + ` + + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), + "TEST_CONFIG_DIR="+configDir, + "TEST_INSTALL_DIR="+installDir, + "TEST_PULSE_BIN="+pulseBin, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + + got := string(out) + if !strings.Contains(got, "raw-bootstrap-token") { + t.Fatalf("completion output did not include revealed token:\n%s", got) + } + if !strings.Contains(got, "Data: "+configDir) { + t.Fatalf("completion output did not pass PULSE_DATA_DIR to bootstrap-token:\n%s", got) + } + if strings.Contains(got, "encrypted-token") || strings.Contains(got, "token_ciphertext") { + t.Fatalf("completion output leaked encrypted bootstrap file contents:\n%s", got) + } +} + func TestBuildPrintedManagementCommandPreservesForcedVersion(t *testing.T) { script := ` GITHUB_REPO="rcourtman/Pulse" diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 4451d6645..6ce40b199 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -172,6 +172,35 @@ func TestRootInstallScriptAutoRegisterUsesSecureContractShape(t *testing.T) { } } +func TestRootInstallShowsBootstrapTokenCommandInsteadOfEncryptedFile(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatalf("read root install.sh: %v", err) + } + + script := string(content) + required := []string{ + `pulse bootstrap-token`, + `PULSE_DATA_DIR=`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("root install.sh missing bootstrap-token display fragment: %s", needle) + } + } + + forbidden := []string{ + `cat $CONFIG_DIR/.bootstrap_token`, + `cat "$TOKEN_FILE"`, + `Token: ${GREEN}`, + } + for _, needle := range forbidden { + if strings.Contains(script, needle) { + t.Fatalf("root install.sh still exposes encrypted bootstrap file contents: %s", needle) + } + } +} + func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) { rootInstall, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) if err != nil {