diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
index da74591b8..c056f0130 100644
--- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
+++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx
@@ -1427,6 +1427,15 @@ export const AgentsMachinesTable: Component<{
asTrimmedString(machine.identity?.hostname);
const systemLabel = () => systemLabelFor(machine);
const agentVersion = () => agentVersionFor(machine);
+ const agentStale = () =>
+ !isAgentlessMachine(machine) && Boolean(machine.agent?.stale);
+ const agentStaleTitle = () => {
+ const last = asTrimmedString(machine.agent?.lastReportAt);
+ const when = last
+ ? ` Last report ${formatPlatformTableRelativeTimeValue(last)}.`
+ : '';
+ return `Agent has stopped reporting.${when} Re-run the install command from Settings > Agents to refresh its token.`;
+ };
const indicator = () => getSimpleStatusIndicator(machine.status);
const canRenderMetrics = () => indicator().variant !== 'danger';
const telemetryFallback = () =>
@@ -1589,8 +1598,17 @@ export const AgentsMachinesTable: Component<{
-
+
{agentVersion()}
+
+ (stale)
+
diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx
index 617f9d356..8a0a29994 100644
--- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx
+++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx
@@ -299,6 +299,31 @@ describe('AgentsMachinesTable', () => {
).toBeGreaterThanOrEqual(2);
});
+ it('flags a non-reporting agent version as stale even when the row stays online', () => {
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('(stale)')).toBeInTheDocument();
+ expect(screen.getByTitle(/Agent has stopped reporting/)).toBeInTheDocument();
+ });
+
it('renders multi-disk machine usage as vertical mini-bars', () => {
const { container } = render(() => (
v6 upgrade that did
+ // not carry the token across). Retrying with the same token loops
+ // forever, so drop the report instead of buffering it and surface an
+ // actionable error the operator can act on. Throttle it so a
+ // permanently rejected token does not flood the log.
+ a.logAuthFailure(statusErr)
+ return nil
+ }
a.reportBuffer.Push(report)
event := a.logger.Warn().
@@ -567,6 +586,10 @@ func (a *Agent) process(ctx context.Context) error {
return nil
}
+ // A successful report means the token is accepted again; reset the auth
+ // failure throttle so a later rejection is reported promptly.
+ a.lastAuthFailureLog = time.Time{}
+
// If successful, try to flush buffer
a.flushBuffer(ctx)
@@ -577,6 +600,31 @@ func (a *Agent) process(ctx context.Context) error {
return nil
}
+// logAuthFailure emits an actionable, throttled error when the server rejects
+// the agent's API token with 401. It is only called from the single-threaded
+// report loop, so lastAuthFailureLog needs no additional synchronisation.
+func (a *Agent) logAuthFailure(statusErr *reportHTTPStatusError) {
+ if time.Since(a.lastAuthFailureLog) < authFailureLogInterval {
+ return
+ }
+ a.lastAuthFailureLog = time.Now()
+
+ endpoint := agentReportEndpoint
+ statusCode := http.StatusUnauthorized
+ if statusErr != nil {
+ if statusErr.Endpoint != "" {
+ endpoint = statusErr.Endpoint
+ }
+ statusCode = statusErr.StatusCode
+ }
+
+ a.logger.Error().
+ Str("endpoint", endpoint).
+ Int("status_code", statusCode).
+ Str("pulse_url", a.trimmedPulseURL).
+ Msg("Pulse rejected this agent's API token (401 Unauthorized). The token is no longer valid on the server, which usually means Pulse was restored/reinstalled or upgraded (for example v5 -> v6) without carrying the token across. Re-run the agent install command from the Pulse UI (Settings > Agents) to mint a fresh token. Reports are dropped until the token is replaced.")
+}
+
func (a *Agent) flushBuffer(ctx context.Context) {
if a.reportBuffer.IsEmpty() {
return
@@ -593,6 +641,18 @@ func (a *Agent) flushBuffer(ctx context.Context) {
if err := a.sendReport(ctx, report); err != nil {
var statusErr *reportHTTPStatusError
+ if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized {
+ // The token is rejected; buffered reports will never be
+ // accepted with it. Drop them so the buffer cannot grow
+ // unbounded across restarts, and surface the actionable error.
+ a.logAuthFailure(statusErr)
+ for {
+ if _, ok := a.reportBuffer.Pop(); !ok {
+ break
+ }
+ }
+ return
+ }
event := a.logger.Warn().
Err(err).
Str("endpoint", agentReportEndpoint).
diff --git a/internal/hostagent/send_report_test.go b/internal/hostagent/send_report_test.go
index 583f3ef64..97c77ad64 100644
--- a/internal/hostagent/send_report_test.go
+++ b/internal/hostagent/send_report_test.go
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"testing"
+ "time"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
@@ -239,6 +240,107 @@ func TestAgentProcess_ForbiddenResponseDoesNotBuffer(t *testing.T) {
}
}
+func TestAgentProcess_UnauthorizedResponseDoesNotBuffer(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ var logBuf bytes.Buffer
+ agent := &Agent{
+ cfg: Config{APIToken: "stale-token"},
+ logger: zerolog.New(&logBuf),
+ httpClient: server.Client(),
+ trimmedPulseURL: server.URL,
+ reportBuffer: utils.New[agentshost.Report](8),
+ collector: &mockCollector{},
+ }
+
+ if err := agent.process(context.Background()); err != nil {
+ t.Fatalf("process: %v", err)
+ }
+ // A rejected token loops forever; the report must be dropped, not buffered.
+ if !agent.reportBuffer.IsEmpty() {
+ t.Fatalf("buffer should stay empty for unauthorized response, len=%d", agent.reportBuffer.Len())
+ }
+ if !strings.Contains(logBuf.String(), "rejected this agent's API token") {
+ t.Fatalf("expected 401 log to be actionable, got %q", logBuf.String())
+ }
+}
+
+func TestAgentProcess_UnauthorizedLogThrottled(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ var logBuf bytes.Buffer
+ agent := &Agent{
+ cfg: Config{APIToken: "stale-token"},
+ logger: zerolog.New(&logBuf),
+ httpClient: server.Client(),
+ trimmedPulseURL: server.URL,
+ reportBuffer: utils.New[agentshost.Report](8),
+ collector: &mockCollector{},
+ }
+
+ for i := 0; i < 3; i++ {
+ if err := agent.process(context.Background()); err != nil {
+ t.Fatalf("process %d: %v", i, err)
+ }
+ }
+ if got := strings.Count(logBuf.String(), "rejected this agent's API token"); got != 1 {
+ t.Fatalf("expected actionable 401 log exactly once (throttled), got %d in %q", got, logBuf.String())
+ }
+
+ // Resetting the throttle (which a successful report does) re-emits the error.
+ agent.lastAuthFailureLog = time.Time{}
+ if err := agent.process(context.Background()); err != nil {
+ t.Fatalf("process after reset: %v", err)
+ }
+ if got := strings.Count(logBuf.String(), "rejected this agent's API token"); got != 2 {
+ t.Fatalf("expected actionable 401 log to re-emit after throttle reset, got %d", got)
+ }
+}
+
+func TestAgentFlushBuffer_UnauthorizedDropsBuffer(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ var logBuf bytes.Buffer
+ buffer := utils.New[agentshost.Report](8)
+ buffer.Push(agentshost.Report{Agent: agentshost.AgentInfo{ID: "a1"}})
+ buffer.Push(agentshost.Report{Agent: agentshost.AgentInfo{ID: "a2"}})
+
+ agent := &Agent{
+ cfg: Config{APIToken: "stale-token"},
+ logger: zerolog.New(&logBuf),
+ httpClient: server.Client(),
+ trimmedPulseURL: server.URL,
+ reportBuffer: buffer,
+ collector: &mockCollector{},
+ }
+
+ agent.flushBuffer(context.Background())
+
+ // Buffered reports will never be accepted with a rejected token; drop them
+ // so the buffer cannot grow unbounded across restarts.
+ if !agent.reportBuffer.IsEmpty() {
+ t.Fatalf("buffer should be drained after unauthorized flush, len=%d", agent.reportBuffer.Len())
+ }
+ if !strings.Contains(logBuf.String(), "rejected this agent's API token") {
+ t.Fatalf("expected flush 401 log to be actionable, got %q", logBuf.String())
+ }
+}
+
func TestAgentProcess_SuccessLogsUnifiedAgentReport(t *testing.T) {
t.Parallel()
diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go
index 2340410f8..aefec4fd6 100644
--- a/internal/unifiedresources/adapters.go
+++ b/internal/unifiedresources/adapters.go
@@ -165,6 +165,20 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) {
LinkedVMID: host.LinkedVMID,
LinkedContainerID: host.LinkedContainerID,
}
+
+ // Surface agent staleness so a row that stays online via another source
+ // (e.g. a Proxmox node still reachable over the PVE API) does not present a
+ // dead agent's version as if it were current. The staleness evaluator marks
+ // a host "offline" once it stops reporting; carry that onto the agent
+ // payload along with the agent's own last report time.
+ if !host.LastSeen.IsZero() {
+ lastReport := host.LastSeen
+ agent.LastReportAt = &lastReport
+ }
+ if strings.EqualFold(strings.TrimSpace(host.Status), "offline") {
+ agent.Stale = true
+ }
+
storageAssessments := make([]storagehealth.Assessment, 0, len(host.RAID)+1)
// Populate sensors
diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go
index 04f1511e6..3e1659cb2 100644
--- a/internal/unifiedresources/adapters_test.go
+++ b/internal/unifiedresources/adapters_test.go
@@ -579,6 +579,54 @@ func TestResourceFromHostProjectsAgentHostProfile(t *testing.T) {
}
}
+func TestResourceFromHostMarksOfflineAgentStale(t *testing.T) {
+ lastReport := time.Now().Add(-30 * time.Minute).UTC()
+ host := models.Host{
+ ID: "omv-host",
+ Hostname: "omv",
+ Platform: "linux",
+ Status: "offline", // set by the staleness evaluator when reports stop
+ AgentVersion: "6.0.2",
+ LastSeen: lastReport,
+ }
+
+ resource, _ := resourceFromHost(host)
+ if resource.Agent == nil {
+ t.Fatal("expected agent payload")
+ }
+ if !resource.Agent.Stale {
+ t.Fatalf("expected offline agent to be marked stale, got %+v", resource.Agent)
+ }
+ if resource.Agent.AgentVersion != "6.0.2" {
+ t.Fatalf("agent version = %q, want the last reported 6.0.2 retained", resource.Agent.AgentVersion)
+ }
+ if resource.Agent.LastReportAt == nil || !resource.Agent.LastReportAt.Equal(lastReport) {
+ t.Fatalf("expected agent LastReportAt = %v, got %v", lastReport, resource.Agent.LastReportAt)
+ }
+}
+
+func TestResourceFromHostOnlineAgentNotStale(t *testing.T) {
+ host := models.Host{
+ ID: "live-host",
+ Hostname: "live",
+ Platform: "linux",
+ Status: "online",
+ AgentVersion: "6.0.5",
+ LastSeen: time.Now().UTC(),
+ }
+
+ resource, _ := resourceFromHost(host)
+ if resource.Agent == nil {
+ t.Fatal("expected agent payload")
+ }
+ if resource.Agent.Stale {
+ t.Fatalf("online agent should not be marked stale, got %+v", resource.Agent)
+ }
+ if resource.Agent.LastReportAt == nil {
+ t.Fatalf("expected LastReportAt to be populated for a reporting agent")
+ }
+}
+
func TestResourceFromHostProjectsPressureOnlyThermalState(t *testing.T) {
warningLevel := 1
host := models.Host{
diff --git a/internal/unifiedresources/presentation_coalesce_test.go b/internal/unifiedresources/presentation_coalesce_test.go
index ee331e14d..8a0b48116 100644
--- a/internal/unifiedresources/presentation_coalesce_test.go
+++ b/internal/unifiedresources/presentation_coalesce_test.go
@@ -58,6 +58,55 @@ func TestCoalescePresentationHostResourcesMergesSplitRuntimeAndPlatformHost(t *t
}
}
+func TestCoalescePresentationHostResourcesSurfacesStaleAgentOnLiveNode(t *testing.T) {
+ // A Proxmox node whose Pulse Agent has stopped reporting (401) stays online
+ // via the PVE API poll, but the dead agent must remain flagged stale so the
+ // UI does not present its last version as if it were current (issue #1515).
+ now := time.Date(2026, 7, 8, 10, 30, 0, 0, time.UTC)
+ resources := []Resource{
+ {
+ ID: "agent-runtime-pve1",
+ Type: ResourceTypeAgent,
+ Name: "pve1",
+ Status: StatusOffline,
+ LastSeen: now.Add(-30 * time.Minute),
+ Sources: []DataSource{SourceAgent},
+ Identity: ResourceIdentity{Hostnames: []string{"pve1"}},
+ Agent: &AgentData{
+ AgentID: "agent-machine-pve1",
+ Hostname: "pve1",
+ AgentVersion: "6.0.2",
+ Stale: true,
+ },
+ },
+ {
+ ID: "proxmox-node-pve1",
+ Type: ResourceTypeAgent,
+ Name: "pve1",
+ Status: StatusOnline,
+ LastSeen: now,
+ Sources: []DataSource{SourceProxmox},
+ Identity: ResourceIdentity{Hostnames: []string{"pve1"}},
+ Proxmox: &ProxmoxData{NodeName: "pve1", ClusterName: "homelab"},
+ },
+ }
+
+ coalesced := CoalescePresentationHostResources(resources)
+ if len(coalesced) != 1 {
+ t.Fatalf("expected split host resources to coalesce into 1 resource, got %d", len(coalesced))
+ }
+ resource := coalesced[0]
+ if resource.Status != StatusOnline {
+ t.Fatalf("node should stay online via the PVE source, got %q", resource.Status)
+ }
+ if resource.Agent == nil || !resource.Agent.Stale {
+ t.Fatalf("expected the dead agent to remain flagged stale after merge, got %+v", resource.Agent)
+ }
+ if resource.Agent.AgentVersion != "6.0.2" {
+ t.Fatalf("expected stale agent version retained, got %q", resource.Agent.AgentVersion)
+ }
+}
+
func TestCoalescePresentationHostResourcesRedirectsProxmoxChildrenToAgentBackedParent(t *testing.T) {
now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC)
proxmoxParentID := "agent-proxmox-delly"
diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go
index 63a3bf12c..7366e9d4a 100644
--- a/internal/unifiedresources/types.go
+++ b/internal/unifiedresources/types.go
@@ -774,8 +774,19 @@ type AgentMemoryMeta struct {
// AgentData contains host agent-specific data.
type AgentData struct {
- AgentID string `json:"agentId,omitempty"`
- AgentVersion string `json:"agentVersion,omitempty"`
+ AgentID string `json:"agentId,omitempty"`
+ AgentVersion string `json:"agentVersion,omitempty"`
+ // Stale is set when the agent has stopped reporting (its host was marked
+ // offline by the staleness evaluator) even though the row itself may stay
+ // online via another source such as the Proxmox API poll. It lets the UI
+ // present the agent and its version as not-reporting instead of a
+ // healthy-looking stale value.
+ Stale bool `json:"stale,omitempty"`
+ // LastReportAt is the agent's own last successful report time. On a
+ // multi-source row (for example a Proxmox node also polled over the PVE
+ // API) this differs from the row's LastSeen, which reflects the freshest
+ // source rather than the agent.
+ LastReportAt *time.Time `json:"lastReportAt,omitempty"`
Hostname string `json:"hostname,omitempty"`
MachineID string `json:"machineId,omitempty"`
TokenID string `json:"tokenId,omitempty"`
diff --git a/scripts/install.sh b/scripts/install.sh
index 56b7ed506..736dba27d 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -347,10 +347,27 @@ restore_selinux_contexts() {
# After starting the agent service, poll its readiness endpoint to verify it
# actually started. The agent exposes /readyz on the configured health address
# once modules are initialized. The default is 127.0.0.1:9191.
+# warn_agent_token_rejected surfaces the actionable recovery path when the
+# server rejects the agent's token. Keeps the message in one place so both
+# health-check paths report it identically.
+warn_agent_token_rejected() {
+ log_warn "Pulse rejected this agent's API token (HTTP 401/403). The saved token is no longer valid on the server, which usually means Pulse was restored/reinstalled or upgraded (for example v5 -> v6) without carrying the token across."
+ log_warn "Re-run the full agent install command from the Pulse UI (Settings > Agents) to mint a fresh token. The agent will keep reporting 401 until the token is replaced."
+}
+
+# verify_agent_server_registration returns:
+# 0 - the server confirmed this agent's registration
+# 1 - registration not confirmed yet (transient: agent not reported, network)
+# 2 - the server rejected the token (HTTP 401/403 - actionable, permanent)
verify_agent_server_registration() {
local lookup_hostname="${HOSTNAME_OVERRIDE}"
- local lookup_resp=""
- local lookup_args=(-fsSL --connect-timeout 5 --max-time 10)
+ local lookup_out=""
+ local lookup_status=""
+ local lookup_body=""
+ # No -f: we need the response body AND the HTTP status even on 4xx so a
+ # rejected token (401/403) can be told apart from "not reported yet". The
+ # -w format appends "\n" after the body.
+ local lookup_args=(-sSL --connect-timeout 5 --max-time 10 -w $'\n%{http_code}')
if [[ -z "$PULSE_URL" ]]; then
return 1
@@ -366,8 +383,18 @@ verify_agent_server_registration() {
if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi
if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi
- lookup_resp=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true)
- if echo "$lookup_resp" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_resp" | grep -q '"id"[[:space:]]*:'; then
+ lookup_out=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true)
+ lookup_status="${lookup_out##*$'\n'}"
+ lookup_body="${lookup_out%$'\n'*}"
+
+ # A rejected token is a definitive, actionable failure distinct from "the
+ # agent has not reported yet"; signal it so the caller can tell the operator
+ # the token needs replacing instead of leaving a silent 401 loop behind.
+ case "$lookup_status" in
+ 401|403) return 2 ;;
+ esac
+
+ if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then
return 0
fi
return 1
@@ -429,8 +456,12 @@ verify_agent_started() {
if [[ -z "$health_url" ]]; then
while [ $iteration -lt $max_iterations ]; do
if agent_process_running; then
- if verify_agent_server_registration; then
+ verify_agent_server_registration
+ local reg_rc=$?
+ if [[ $reg_rc -eq 0 ]]; then
log_info "Agent process is running and registered with Pulse."
+ elif [[ $reg_rc -eq 2 ]]; then
+ warn_agent_token_rejected
else
log_warn "Agent process is running, but server registration was not confirmed yet."
fi
@@ -451,8 +482,12 @@ verify_agent_started() {
while [ $iteration -lt $max_iterations ]; do
# Check the readiness endpoint first — this is the definitive signal
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then
- if verify_agent_server_registration; then
+ verify_agent_server_registration
+ local reg_rc=$?
+ if [[ $reg_rc -eq 0 ]]; then
log_info "Agent is running, healthy, and registered with Pulse."
+ elif [[ $reg_rc -eq 2 ]]; then
+ warn_agent_token_rejected
else
log_warn "Agent local health is ready, but server registration was not confirmed yet."
fi
diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go
index fc9cf553e..b460c4424 100644
--- a/scripts/installtests/install_sh_test.go
+++ b/scripts/installtests/install_sh_test.go
@@ -3517,3 +3517,86 @@ func TestSetupAutoUpdatesPreservesRCChannelWhenUpdatingExistingConfig(t *testing
t.Fatalf("system.json lost rc channel:\n%s", content)
}
}
+
+// TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken verifies that
+// the post-install health check tells a rejected token (401/403) apart from a
+// transient "agent has not reported yet" state, so the installer can surface an
+// actionable error instead of leaving a silent 401 loop behind (issue #1515).
+func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T) {
+ urlEncode := extractInstallShellFunction(t, "url_encode")
+ verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration")
+
+ cases := []struct {
+ name string
+ status int
+ body string
+ wantRC string
+ }{
+ {"rejected token 401", http.StatusUnauthorized, `{"error":"Authentication required"}`, "rc=2"},
+ {"rejected token 403", http.StatusForbidden, `{"error":"missing_scope"}`, "rc=2"},
+ {"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv"}}`, "rc=0"},
+ {"not reported yet", http.StatusNotFound, `{"error":"agent_not_found"}`, "rc=1"},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasPrefix(r.URL.Path, "/api/agents/agent/lookup") {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ w.WriteHeader(tc.status)
+ _, _ = w.Write([]byte(tc.body))
+ }))
+ defer server.Close()
+
+ script := `
+ PULSE_URL="` + server.URL + `"
+ PULSE_TOKEN="stale-token"
+ HOSTNAME_OVERRIDE="omv"
+ INSECURE="false"
+ CURL_CA_BUNDLE=""
+` + urlEncode + `
+` + verifyFn + `
+ verify_agent_server_registration
+ echo "rc=$?"
+ `
+ out, err := exec.Command("bash", "-c", script).CombinedOutput()
+ if err != nil {
+ t.Fatalf("bash: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), tc.wantRC) {
+ t.Fatalf("case %q: want %s, got:\n%s", tc.name, tc.wantRC, out)
+ }
+ })
+ }
+}
+
+// TestInstallSHWarnAgentTokenRejectedIsActionable pins the actionable recovery
+// copy the installer prints when the server rejects the agent's token.
+func TestInstallSHWarnAgentTokenRejectedIsActionable(t *testing.T) {
+ logWarn := extractInstallShellFunction(t, "log_warn")
+ warnFn := extractInstallShellFunction(t, "warn_agent_token_rejected")
+
+ script := `
+ NON_INTERACTIVE="false"
+ redact_token() { printf '%s' "$1"; }
+` + logWarn + `
+` + warnFn + `
+ warn_agent_token_rejected
+ `
+ out, err := exec.Command("bash", "-c", script).CombinedOutput()
+ if err != nil {
+ t.Fatalf("bash: %v\n%s", err, out)
+ }
+ for _, needle := range []string{
+ "rejected this agent's API token",
+ "Re-run the full agent install command",
+ "mint a fresh token",
+ } {
+ if !strings.Contains(string(out), needle) {
+ t.Fatalf("warn_agent_token_rejected missing %q:\n%s", needle, out)
+ }
+ }
+}