From 61c8e8ca008d631ec684742ae424304cffdc70fb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 9 Jun 2026 17:55:09 +0100 Subject: [PATCH] Make agent command-exec token rejection actionable, not a silent 'Invalid token' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent enrolled for metrics but whose token the server doesn't recognise (or that lacks the agent:exec scope, or is bound to a different agent) was rejected on the command-exec WebSocket with a bare 'Invalid token' and — for the token-not-found case — no server log at all. The agent then retried forever, logging only 'Invalid token', so the operator had no signal that discovery deep-scan was failing or why. (Confirmed live: delly/minipc agents pointed at a backend that didn't recognise their token retried thousands of times; discovery abstained for every guest as a result.) - agentexec/server.go: the registration-rejection message the agent logs verbatim now says 'agent token not authorized for command execution — re-run the agent installer to enroll an agent:exec-scoped token'. - api/agent_exec_token_binding.go: the previously-silent token-not-recognised branch now logs the specific reason with the agent hostname. Contract-neutral: same rejection behaviour, just legible. Regression test: TestHandleWebSocket_RejectionMessageIsActionable. Verified live end-to-end. --- internal/agentexec/server.go | 7 ++++- internal/agentexec/server_coverage_test.go | 33 ++++++++++++++++++++++ internal/api/agent_exec_token_binding.go | 8 ++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/agentexec/server.go b/internal/agentexec/server.go index 62597b4c4..999459038 100644 --- a/internal/agentexec/server.go +++ b/internal/agentexec/server.go @@ -433,7 +433,12 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { // Validate token if !s.validateToken(reg.Token, reg.AgentID, reg.Hostname) { log.Warn().Str("agent_id", reg.AgentID).Msg("Agent registration rejected: invalid token") - rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "Invalid token"}) + // Actionable message instead of a bare "Invalid token": the agent logs + // this verbatim, and the dominant causes (token not recognised, or not + // bound to this agent) are both fixed by re-enrolling, while a token + // that exists but lacks the scope is named explicitly. Avoids the silent + // retry loop that previously gave operators nothing to act on. + rejectedMsg, err := NewMessage(MsgTypeRegistered, "", RegisteredPayload{Success: false, Message: "agent token not authorized for command execution — re-run the agent installer to enroll an agent:exec-scoped token"}) if err != nil { log.Warn().Err(err).Str("agent_id", reg.AgentID).Msg("Failed to encode rejection message") conn.Close() diff --git a/internal/agentexec/server_coverage_test.go b/internal/agentexec/server_coverage_test.go index e56865f06..de1b12584 100644 --- a/internal/agentexec/server_coverage_test.go +++ b/internal/agentexec/server_coverage_test.go @@ -173,6 +173,39 @@ func TestHandleWebSocket_InvalidTokenRejectionSendFailure(t *testing.T) { waitFor(t, 2*time.Second, func() bool { return !s.IsAgentConnected("a1") }) } +func TestHandleWebSocket_RejectionMessageIsActionable(t *testing.T) { + // The agent logs the rejection message verbatim, so it must tell the operator + // how to fix it (re-enroll / agent:exec scope) rather than the opaque + // "Invalid token" that previously left a stale-enrollment agent retrying + // forever with no actionable signal. + s := NewServer(func(string, string, string) bool { return false }) + ts := newWSServer(t, s) + defer ts.Close() + + conn, _, err := dialAgentExecWebSocket(t, ts.URL) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{ + AgentID: "a1", + Hostname: "host1", + Token: "bad", + })) + + payload := wsReadRegisteredPayload(t, conn) + if payload.Success { + t.Fatalf("expected registration to be rejected") + } + if payload.Message == "Invalid token" { + t.Fatalf("rejection message must not be the bare opaque 'Invalid token'") + } + if !strings.Contains(payload.Message, "re-run the agent installer") { + t.Fatalf("rejection message should point the operator at re-enrollment, got: %q", payload.Message) + } +} + func TestHandleWebSocket_RegistrationAckSendFailure(t *testing.T) { origWriteTextMessage := writeTextMessage t.Cleanup(func() { writeTextMessage = origWriteTextMessage }) diff --git a/internal/api/agent_exec_token_binding.go b/internal/api/agent_exec_token_binding.go index 83e19439e..4f16c3f67 100644 --- a/internal/api/agent_exec_token_binding.go +++ b/internal/api/agent_exec_token_binding.go @@ -25,6 +25,14 @@ func (r *Router) validateAgentExecToken(token string, agentID string, hostname s record, ok := r.config.ValidateAPIToken(token) if !ok { config.Mu.Unlock() + // This is the branch a stale-enrollment agent hits: it holds a token + // from a prior install that this server no longer recognises. It was + // previously the only rejection path with no log, which made a looping + // "Invalid token" agent impossible to diagnose without reading source. + log.Warn(). + Str("agent_id", requestedID). + Str("hostname", requestedHost). + Msg("Agent exec token not recognized by this server — re-run the agent installer to re-enroll this agent") return false }