fix(mail): validate recipient exists before sending (#886)
Add validateRecipient() to check that mail recipients correspond to existing agents before sending. This prevents mail from being stored with invalid assignees that won't match inbox queries. The validation queries agent beads and checks if any match the recipient identity. The only special case is "overseer" which is the human operator and doesn't have an agent bead. Tests create a temporary isolated beads database with test agents to validate both success and failure cases. Tests are skipped if bd CLI is not available (e.g., in CI). Fixes gt-0y8qa Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -566,11 +566,39 @@ func (r *Router) sendToGroup(msg *Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRecipient checks that the recipient identity corresponds to an existing agent.
|
||||
// Returns an error if the recipient is invalid or doesn't exist.
|
||||
func (r *Router) validateRecipient(identity string) error {
|
||||
// Overseer is the human operator, not an agent bead
|
||||
if identity == "overseer" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query all agents and check if any match this identity
|
||||
agents, err := r.queryAgents("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query agents: %w", err)
|
||||
}
|
||||
|
||||
for _, agent := range agents {
|
||||
if agentBeadToAddress(agent) == identity {
|
||||
return nil // Found matching agent
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no agent found")
|
||||
}
|
||||
|
||||
// sendToSingle sends a message to a single recipient.
|
||||
func (r *Router) sendToSingle(msg *Message) error {
|
||||
// Convert addresses to beads identities
|
||||
toIdentity := AddressToIdentity(msg.To)
|
||||
|
||||
// Validate recipient exists
|
||||
if err := r.validateRecipient(toIdentity); err != nil {
|
||||
return fmt.Errorf("invalid recipient %q: %w", msg.To, err)
|
||||
}
|
||||
|
||||
// Build labels for from/thread/reply-to/cc
|
||||
var labels []string
|
||||
labels = append(labels, "from:"+msg.From)
|
||||
|
||||
@@ -2,6 +2,7 @@ package mail
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -813,3 +814,107 @@ func TestExpandAnnounceNoTownRoot(t *testing.T) {
|
||||
t.Errorf("expandAnnounce error = %v, want containing 'no town root'", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Recipient Validation Tests ============
|
||||
|
||||
func TestValidateRecipient(t *testing.T) {
|
||||
// Skip if bd CLI is not available (e.g., in CI environment)
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd CLI not available, skipping test")
|
||||
}
|
||||
|
||||
// Create isolated beads environment for testing
|
||||
tmpDir := t.TempDir()
|
||||
townRoot := tmpDir
|
||||
|
||||
// Create .beads directory and initialize
|
||||
beadsDir := filepath.Join(townRoot, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("creating beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Initialize beads database with "gt" prefix (matches agent bead IDs)
|
||||
cmd := exec.Command("bd", "init", "gt")
|
||||
cmd.Dir = townRoot
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Set issue prefix to "gt" (matches agent bead ID pattern)
|
||||
cmd = exec.Command("bd", "config", "set", "issue_prefix", "gt")
|
||||
cmd.Dir = townRoot
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd config set issue_prefix failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Register custom types (agent, message, etc.) - required before creating agents
|
||||
cmd = exec.Command("bd", "config", "set", "types.custom", "agent,role,rig,convoy,slot,queue,event,message,molecule,gate,merge-request")
|
||||
cmd.Dir = townRoot
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd config set types.custom failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
// Create test agent beads
|
||||
createAgent := func(id, title string) {
|
||||
cmd := exec.Command("bd", "create", title, "--type=agent", "--id="+id, "--force")
|
||||
cmd.Dir = townRoot
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("creating agent %s: %v\n%s", id, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Create agents that match expected bead ID patterns
|
||||
createAgent("gt-mayor", "Mayor agent")
|
||||
createAgent("gt-deacon", "Deacon agent")
|
||||
createAgent("gt-testrig-witness", "Test witness")
|
||||
createAgent("gt-testrig-crew-alice", "Test crew alice")
|
||||
createAgent("gt-testrig-polecat-bob", "Test polecat bob")
|
||||
|
||||
r := NewRouterWithTownRoot(townRoot, townRoot)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
identity string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
// Overseer is always valid (human operator, no agent bead)
|
||||
{"overseer", "overseer", false, ""},
|
||||
|
||||
// Town-level agents (validated against beads)
|
||||
{"mayor", "mayor/", false, ""},
|
||||
{"deacon", "deacon/", false, ""},
|
||||
|
||||
// Rig-level agents (validated against beads)
|
||||
{"witness", "testrig/witness", false, ""},
|
||||
{"crew member", "testrig/alice", false, ""},
|
||||
{"polecat", "testrig/bob", false, ""},
|
||||
|
||||
// Invalid addresses - should fail
|
||||
{"bare name", "ruby", true, "no agent found"},
|
||||
{"nonexistent rig agent", "testrig/nonexistent", true, "no agent found"},
|
||||
{"wrong rig", "wrongrig/alice", true, "no agent found"},
|
||||
{"misrouted town agent", "testrig/mayor", true, "no agent found"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := r.validateRecipient(tt.identity)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("validateRecipient(%q) expected error, got nil", tt.identity)
|
||||
} else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("validateRecipient(%q) error = %v, want containing %q", tt.identity, err, tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("validateRecipient(%q) unexpected error: %v", tt.identity, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user