package tools import ( "context" "encoding/json" "fmt" "maps" "math" "os" "path/filepath" "strings" "testing" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) // outsidePath returns an absolute path that is guaranteed to be outside the // given workspace and temp directories on any OS. On Windows bare "/etc/..." // is relative (no drive letter), so we prepend the volume name of the workspace // to ensure filepath.IsAbs returns true. func outsidePath(workspace, segments string) string { vol := filepath.VolumeName(workspace) return filepath.Join(vol+string(filepath.Separator), segments) } func TestResolveMediaPath(t *testing.T) { tmpDir := os.TempDir() // Create a temp workspace with a test file for workspace-relative tests. workspace := t.TempDir() docsDir := filepath.Join(workspace, "docs") if err := os.MkdirAll(docsDir, 0o755); err != nil { t.Fatal(err) } testFile := filepath.Join(docsDir, "report.pdf") if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil { t.Fatal(err) } // Normalize paths to canonical form (resolves macOS /var/folders → /private/var/folders symlink). // The resolvePath function uses filepath.EvalSymlinks, so test expectations must too. testFileCanonical, _ := filepath.EvalSymlinks(testFile) workspaceCanonical, _ := filepath.EvalSymlinks(workspace) t.Run("restricted", func(t *testing.T) { tool := NewMessageTool(workspaceCanonical, true) ctx := context.Background() tests := []struct { name string input string want string wantOK bool }{ // Unscoped temp files are not tenant/run-owned. {"unscoped temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), "", false}, {"unscoped nested temp", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), "", false}, // Workspace files allowed {"workspace absolute", "MEDIA:" + testFileCanonical, testFileCanonical, true}, {"workspace relative", "MEDIA:docs/report.pdf", testFileCanonical, true}, // Not a MEDIA: message {"no prefix", filepath.Join(tmpDir, "test.png"), "", false}, {"empty after prefix", "MEDIA:", "", false}, {"dot path", "MEDIA:.", "", false}, {"empty string", "", "", false}, {"just MEDIA", "MEDIA", "", false}, // Outside workspace + outside /tmp/ → blocked {"outside workspace", "MEDIA:" + outsidePath(workspaceCanonical, "etc/passwd"), "", false}, {"traversal attack", "MEDIA:" + filepath.Join(workspaceCanonical, "..", "etc", "passwd"), "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := tool.resolveMediaPath(ctx, tt.input) if ok != tt.wantOK { t.Errorf("resolveMediaPath(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) } if ok && got != tt.want { t.Errorf("resolveMediaPath(%q) = %q, want %q", tt.input, got, tt.want) } }) } }) // effectiveRestrict() always returns true (multi-tenant security hardening), // so even tools created with restrict=false behave as restricted. t.Run("unrestricted_tool_still_restricted", func(t *testing.T) { tool := NewMessageTool(workspaceCanonical, false) ctx := context.Background() tests := []struct { name string input string wantOK bool }{ // Outside workspace → blocked (effectiveRestrict overrides to true) {"absolute outside workspace", "MEDIA:" + outsidePath(workspaceCanonical, "etc/hostname"), false}, // Workspace-relative → allowed {"workspace relative", "MEDIA:docs/report.pdf", true}, // /tmp/ is not an implicit cross-tenant read root. {"temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, ok := tool.resolveMediaPath(ctx, tt.input) if ok != tt.wantOK { t.Errorf("resolveMediaPath(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) } }) } }) t.Run("context workspace override", func(t *testing.T) { // Tool has no workspace, but context provides one. tool := NewMessageTool("", true) ctx := WithToolWorkspace(context.Background(), workspaceCanonical) got, ok := tool.resolveMediaPath(ctx, "MEDIA:docs/report.pdf") if !ok { t.Fatal("expected ok=true for workspace-relative path with context workspace") } if got != testFileCanonical { t.Errorf("got %q, want %q", got, testFileCanonical) } }) } func TestMessageMediaRejectsNonRegularFiles(t *testing.T) { workspace := t.TempDir() tool := NewMessageTool(workspace, true) tool.SetMessageBus(bus.New()) if result := tool.sendMedia(context.Background(), "telegram", "chat-1", workspace); !result.IsError { t.Fatal("sendMedia allowed a directory") } original := filepath.Join(workspace, "original.png") if err := os.WriteFile(original, []byte("image"), 0o644); err != nil { t.Fatal(err) } hardlink := filepath.Join(workspace, "hardlink.png") if err := os.Link(original, hardlink); err != nil { t.Skipf("hardlinks not supported: %v", err) } if result := tool.sendMedia(context.Background(), "telegram", "chat-1", hardlink); !result.IsError { t.Fatal("sendMedia allowed a hardlinked file") } } func TestIsInTempDir(t *testing.T) { tmpDir := os.TempDir() tests := []struct { name string path string want bool }{ {"in tmp", filepath.Join(tmpDir, "test.png"), true}, {"nested in tmp", filepath.Join(tmpDir, "sub", "file.txt"), true}, {"tmp itself", tmpDir, false}, // only files inside, not the dir itself {"outside tmp", outsidePath(tmpDir, "etc/passwd"), false}, {"relative path", "relative/path.txt", false}, {"traversal", filepath.Join(tmpDir, "..", "etc", "passwd"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isInTempDir(tt.path); got != tt.want { t.Errorf("isInTempDir(%q) = %v, want %v", tt.path, got, tt.want) } }) } } func TestExtractEmbeddedMedia(t *testing.T) { workspace := t.TempDir() workspaceCanonical, _ := filepath.EvalSymlinks(workspace) // Create test files in workspace. docsDir := filepath.Join(workspaceCanonical, "docs") os.MkdirAll(docsDir, 0o755) reportFile := filepath.Join(docsDir, "report.docx") os.WriteFile(reportFile, []byte("test"), 0o644) reportCanonical, _ := filepath.EvalSymlinks(reportFile) tool := NewMessageTool(workspaceCanonical, true) ctx := context.Background() t.Run("no MEDIA: in message", func(t *testing.T) { msg := "Hello, here is your report!" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != msg { t.Errorf("expected unchanged message, got %q", cleaned) } if len(media) != 0 { t.Errorf("expected no media, got %d", len(media)) } }) t.Run("embedded MEDIA: in multi-line message", func(t *testing.T) { msg := "Here is the file:\nMEDIA:" + reportCanonical + "\nPlease download!" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "Here is the file:\nPlease download!" { t.Errorf("unexpected cleaned text: %q", cleaned) } if len(media) != 1 { t.Fatalf("expected 1 media, got %d", len(media)) } if media[0].URL != reportCanonical { t.Errorf("media URL = %q, want %q", media[0].URL, reportCanonical) } wantMime := "application/vnd.openxmlformats-officedocument.wordprocessingml.document" if media[0].ContentType != wantMime { t.Errorf("content type = %q, want %q", media[0].ContentType, wantMime) } }) t.Run("MEDIA: mid-sentence keeps surrounding text", func(t *testing.T) { msg := "Here is your report MEDIA:" + reportCanonical + " please review" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "Here is your report please review" { t.Errorf("surrounding text lost: %q", cleaned) } if len(media) != 1 { t.Fatalf("expected 1 media, got %d", len(media)) } }) t.Run("multiple MEDIA: on same line", func(t *testing.T) { img := filepath.Join(workspaceCanonical, "photo.png") if err := os.WriteFile(img, []byte("image"), 0o644); err != nil { t.Fatal(err) } msg := "MEDIA:" + reportCanonical + " MEDIA:" + img cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "" { t.Errorf("expected empty cleaned text, got %q", cleaned) } if len(media) != 2 { t.Fatalf("expected 2 media from same line, got %d", len(media)) } }) t.Run("non-regular MEDIA path is stripped without attachment", func(t *testing.T) { msg := "Directory:\nMEDIA:" + docsDir + "\nDone" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "Directory:\nDone" { t.Fatalf("unexpected cleaned text: %q", cleaned) } if len(media) != 0 { t.Fatalf("non-regular path produced attachments: %+v", media) } }) t.Run("MEDIA: path outside workspace is stripped but no attachment", func(t *testing.T) { msg := "File:\nMEDIA:" + outsidePath(workspaceCanonical, "etc/passwd") + "\nDone" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "File:\nDone" { t.Errorf("MEDIA: line not stripped: %q", cleaned) } if len(media) != 0 { t.Errorf("expected no media for outside-workspace path, got %d", len(media)) } }) t.Run("message with only MEDIA: lines", func(t *testing.T) { msg := "MEDIA:" + reportCanonical cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "" { t.Errorf("expected empty cleaned text, got %q", cleaned) } if len(media) != 1 { t.Fatalf("expected 1 media, got %d", len(media)) } }) t.Run("audio_as_voice tag stripped", func(t *testing.T) { voice := filepath.Join(workspaceCanonical, "voice.ogg") if err := os.WriteFile(voice, []byte("audio"), 0o644); err != nil { t.Fatal(err) } msg := "[[audio_as_voice]]\nMEDIA:" + voice + "\nExtra text" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "Extra text" { t.Errorf("unexpected cleaned text: %q", cleaned) } if len(media) != 1 { t.Fatalf("expected 1 media, got %d", len(media)) } }) t.Run("multiple MEDIA: paths", func(t *testing.T) { img := filepath.Join(workspaceCanonical, "photo-2.png") if err := os.WriteFile(img, []byte("image"), 0o644); err != nil { t.Fatal(err) } msg := "Files:\nMEDIA:" + reportCanonical + "\nMEDIA:" + img + "\nEnjoy!" cleaned, media := tool.extractEmbeddedMedia(ctx, msg) if cleaned != "Files:\nEnjoy!" { t.Errorf("unexpected cleaned text: %q", cleaned) } if len(media) != 2 { t.Fatalf("expected 2 media, got %d", len(media)) } }) } func TestMimeFromPath(t *testing.T) { tests := []struct { path string want string }{ {"/tmp/file.png", "image/png"}, {"/tmp/file.jpg", "image/jpeg"}, {"/tmp/file.mp4", "video/mp4"}, {"/tmp/file.ogg", "audio/ogg"}, {"/tmp/file.pdf", "application/pdf"}, {"/tmp/file.doc", "application/msword"}, {"/tmp/file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, {"/tmp/file.xls", "application/vnd.ms-excel"}, {"/tmp/file.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, {"/tmp/file.unknown", "application/octet-stream"}, } for _, tt := range tests { t.Run(filepath.Base(tt.path), func(t *testing.T) { if got := mimeFromPath(tt.path); got != tt.want { t.Errorf("mimeFromPath(%q) = %q, want %q", tt.path, got, tt.want) } }) } } func TestValidateChannelTenant(t *testing.T) { tenantA := uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") tenantB := uuid.MustParse("019d1135-7087-7aa9-a2c7-cdaf7af851b1") tool := NewMessageTool("", true) t.Run("no checker configured allows all", func(t *testing.T) { ctx := store.WithTenantID(context.Background(), tenantA) if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil { t.Errorf("expected nil, got error: %s", err.ForLLM) } }) // Wire a mock checker. channels := map[string]uuid.UUID{ "telegram": tenantA, "tenant-b-tg": tenantB, } tool.SetChannelTenantChecker(func(name string) (uuid.UUID, bool) { tid, ok := channels[name] return tid, ok }) t.Run("same tenant allows", func(t *testing.T) { ctx := store.WithTenantID(context.Background(), tenantA) if err := tool.validateChannelTenant(ctx, "telegram", "123"); err != nil { t.Errorf("expected nil for same tenant, got: %s", err.ForLLM) } }) t.Run("cross tenant blocks", func(t *testing.T) { ctx := store.WithTenantID(context.Background(), tenantA) err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456") if err == nil { t.Fatal("expected error for cross-tenant send, got nil") } if !err.IsError { t.Error("expected IsError=true") } }) t.Run("channel not found blocks", func(t *testing.T) { ctx := store.WithTenantID(context.Background(), tenantA) err := tool.validateChannelTenant(ctx, "nonexistent", "789") if err == nil { t.Fatal("expected error for missing channel, got nil") } }) t.Run("nil channel tenant allows (legacy)", func(t *testing.T) { channels["legacy-ch"] = uuid.Nil ctx := store.WithTenantID(context.Background(), tenantA) if err := tool.validateChannelTenant(ctx, "legacy-ch", "123"); err != nil { t.Errorf("expected nil for legacy channel, got: %s", err.ForLLM) } }) t.Run("nil context tenant allows (master/system)", func(t *testing.T) { ctx := context.Background() // no tenant in context if err := tool.validateChannelTenant(ctx, "tenant-b-tg", "456"); err != nil { t.Errorf("expected nil for master context, got: %s", err.ForLLM) } }) } func TestSelfSendGuard(t *testing.T) { workspace := t.TempDir() workspaceCanonical, _ := filepath.EvalSymlinks(workspace) // Create a test file for MEDIA: resolution. testFile := filepath.Join(workspaceCanonical, "report.csv") os.WriteFile(testFile, []byte("data"), 0o644) tool := NewMessageTool(workspaceCanonical, true) // Wire message bus so MEDIA sends can proceed past self-send guard. tool.SetMessageBus(bus.New()) // Build context with self-send channel/chatID. mkCtx := func() context.Context { ctx := context.Background() ctx = WithToolChannel(ctx, "telegram") ctx = WithToolChatID(ctx, "chat-42") return ctx } t.Run("text self-send blocked", func(t *testing.T) { result := tool.Execute(mkCtx(), map[string]any{ "action": "send", "channel": "telegram", "target": "chat-42", "message": "Hello, this is a text message", }) if !result.IsError { t.Fatal("expected text self-send to be blocked") } }) t.Run("text to different chat allowed", func(t *testing.T) { // Cross-target guard also kicks in on unbound sessions — supply // forward=true so this test isolates the self-send guard. result := tool.Execute(mkCtx(), map[string]any{ "action": "send", "channel": "telegram", "target": "chat-99", "message": "Hello, other chat", "forward": true, "forward_reason": "test cross-chat", }) if result.IsError { t.Fatalf("expected cross-chat send to succeed, got: %s", result.ForLLM) } }) t.Run("MEDIA self-send allowed when not delivered", func(t *testing.T) { // No delivered media tracker — MEDIA self-send should be allowed. result := tool.Execute(mkCtx(), map[string]any{ "action": "send", "channel": "telegram", "target": "chat-42", "message": "MEDIA:" + testFile, }) if result.IsError { t.Fatalf("expected MEDIA self-send to be allowed, got: %s", result.ForLLM) } }) t.Run("MEDIA self-send blocked when already delivered", func(t *testing.T) { ctx := mkCtx() dm := NewDeliveredMedia() dm.Mark(testFile) ctx = WithDeliveredMedia(ctx, dm) result := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "chat-42", "message": "MEDIA:" + testFile, }) if !result.IsError { t.Fatal("expected MEDIA self-send to be blocked when file already delivered") } }) t.Run("MEDIA self-send allowed for undelivered file with tracker", func(t *testing.T) { ctx := mkCtx() dm := NewDeliveredMedia() dm.Mark("/some/other/file.pdf") // different file marked ctx = WithDeliveredMedia(ctx, dm) result := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "chat-42", "message": "MEDIA:" + testFile, }) if result.IsError { t.Fatalf("expected MEDIA self-send for undelivered file to be allowed, got: %s", result.ForLLM) } }) t.Run("embedded MEDIA in text self-send blocked", func(t *testing.T) { ctx := mkCtx() dm := NewDeliveredMedia() dm.Mark(testFile) ctx = WithDeliveredMedia(ctx, dm) result := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "chat-42", "message": "Here is the file\nMEDIA:" + testFile, }) // Contains MEDIA: pattern → passes text guard → but file is delivered → blocked if !result.IsError { t.Fatal("expected embedded MEDIA self-send to be blocked when file already delivered") } }) } func TestMessageToolNumericTargetUsesSendPath(t *testing.T) { // JSON tool args use float64 for integers; target must not be ignored (was only .(string)). var gotChat string tool := NewMessageTool("", true) tool.SetChannelSender(func(_ context.Context, ch, chatID, content string) error { if ch != "telegram" { t.Errorf("channel = %q", ch) } gotChat = chatID return nil }) ctx := context.Background() r := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": float64(-1001847298537), "message": "hello", }) if r.IsError { t.Fatalf("unexpected error: %s", r.ForLLM) } if gotChat != "-1001847298537" { t.Errorf("sender saw chatID %q, want -1001847298537", gotChat) } } func TestArgString(t *testing.T) { tests := []struct { name string args map[string]any key string want string }{ {"string value", map[string]any{"k": "hello"}, "k", "hello"}, {"string with spaces", map[string]any{"k": " hi "}, "k", "hi"}, {"empty string", map[string]any{"k": ""}, "k", ""}, {"missing key", map[string]any{}, "k", ""}, {"nil value", map[string]any{"k": nil}, "k", ""}, {"float64 integer", map[string]any{"k": float64(-1001847298537)}, "k", "-1001847298537"}, {"float64 positive", map[string]any{"k": float64(42)}, "k", "42"}, {"float64 zero", map[string]any{"k": float64(0)}, "k", "0"}, {"float64 NaN", map[string]any{"k": math.NaN()}, "k", ""}, {"int", map[string]any{"k": 123}, "k", "123"}, {"int64", map[string]any{"k": int64(-999)}, "k", "-999"}, {"json.Number", map[string]any{"k": json.Number("7654321")}, "k", "7654321"}, {"bool fallback", map[string]any{"k": true}, "k", "true"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := argString(tt.args, tt.key) if got != tt.want { t.Errorf("argString(%v, %q) = %q, want %q", tt.args, tt.key, got, tt.want) } }) } } func TestDeliveredMedia(t *testing.T) { dm := NewDeliveredMedia() if dm.IsDelivered("/tmp/test.csv") { t.Fatal("expected empty tracker to report false") } dm.Mark("/tmp/test.csv") if !dm.IsDelivered("/tmp/test.csv") { t.Fatal("expected marked path to be delivered") } if dm.IsDelivered("/tmp/other.csv") { t.Fatal("expected unmarked path to report false") } } func TestMessageToolCrossTargetGuard(t *testing.T) { type scenario struct { name string sessionKey string ctxChannel string ctxChatID string peerKind string args map[string]any wantErr bool wantOutbound int // number of outbound messages published wantTargets []string } const ( agentDM = "agent:a:telegram:direct:U1" agentGroup = "agent:a:telegram:group:-100G" agentCron = "agent:a:cron:job-1" agentHB = "agent:a:heartbeat" agentSub = "agent:a:subagent:child-1" agentTeam = "agent:a:team:T1:U1" ) baseArgs := func(extra map[string]any) map[string]any { m := map[string]any{"action": "send", "message": "hello"} maps.Copy(m, extra) return m } scenarios := []scenario{ { name: "1_dm_same_target_pass", sessionKey: agentDM, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "U1"}), wantOutbound: 1, wantTargets: []string{"U1"}, }, { name: "2_dm_cross_target_blocked", sessionKey: agentDM, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-1003787954683"}), wantErr: true, }, { name: "3_dm_cross_target_forward_pass", sessionKey: agentDM, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100G", "forward": true, "forward_reason": "user asked to forward"}), wantOutbound: 2, wantTargets: []string{"-100G", "U1"}, }, { name: "4_group_same_target_pass", sessionKey: agentGroup, ctxChannel: "telegram", ctxChatID: "-100G", peerKind: "group", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100G"}), wantOutbound: 1, wantTargets: []string{"-100G"}, }, { name: "5_group_cross_target_blocked", sessionKey: agentGroup, ctxChannel: "telegram", ctxChatID: "-100G", peerKind: "group", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100G2"}), wantErr: true, }, { name: "6_cron_free", sessionKey: agentCron, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100X"}), wantOutbound: 1, wantTargets: []string{"-100X"}, }, { name: "7_heartbeat_free", sessionKey: agentHB, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100X"}), wantOutbound: 1, wantTargets: []string{"-100X"}, }, { name: "7b_subagent_free", sessionKey: agentSub, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100X"}), wantOutbound: 1, wantTargets: []string{"-100X"}, }, { name: "7c_team_free", sessionKey: agentTeam, ctxChannel: "telegram", ctxChatID: "U1", peerKind: "direct", args: baseArgs(map[string]any{"channel": "telegram", "target": "-100X"}), wantOutbound: 1, wantTargets: []string{"-100X"}, }, } // For same-target DM/group scenarios, text self-sends are blocked by the // pre-existing self-send guard (different from our new cross-target guard). // Use MEDIA: to bypass that — our guard doesn't care about message kind. sharedTmp := t.TempDir() mediaPath := filepath.Join(sharedTmp, "note.png") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } mediaMsg := "MEDIA:" + mediaPath // Apply MEDIA bypass to same-target scenarios. for i := range scenarios { if scenarios[i].name == "1_dm_same_target_pass" || scenarios[i].name == "4_group_same_target_pass" { scenarios[i].args["message"] = mediaMsg } } for _, sc := range scenarios { t.Run(sc.name, func(t *testing.T) { tool := NewMessageTool(sharedTmp, false) mb := bus.New() tool.SetMessageBus(mb) ctx := context.Background() ctx = WithToolSessionKey(ctx, sc.sessionKey) ctx = WithToolChannel(ctx, sc.ctxChannel) ctx = WithToolChatID(ctx, sc.ctxChatID) ctx = WithToolPeerKind(ctx, sc.peerKind) res := tool.Execute(ctx, sc.args) if sc.wantErr { if res == nil || !res.IsError { t.Fatalf("expected error result, got: %+v", res) } // Guard must not publish when blocked. got := drainBusNow(mb) if len(got) != 0 { t.Fatalf("expected 0 outbound on block, got %d: %+v", len(got), got) } return } if res != nil && res.IsError { t.Fatalf("unexpected error: %s", res.ForLLM) } got := drainBusNow(mb) if len(got) != sc.wantOutbound { t.Fatalf("outbound count: got %d want %d (%+v)", len(got), sc.wantOutbound, got) } for i, want := range sc.wantTargets { if got[i].ChatID != want { t.Errorf("outbound[%d].ChatID = %q, want %q", i, got[i].ChatID, want) } } }) } } // drainBusNow reads all buffered outbound messages using a short timeout per // read. Pre-cancelled ctx can't be used: select{msg,ctx.Done} picks randomly // when both are ready, so buffered messages would be lost ~50% of the time. func drainBusNow(mb *bus.MessageBus) []bus.OutboundMessage { var out []bus.OutboundMessage for { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) msg, ok := mb.SubscribeOutbound(ctx) cancel() if !ok { return out } out = append(out, msg) } } // Test 10 — replay of production trace 019d9fcf-b433-7550-a9a6-62efa140128d: // DM session, agent tried to send to a group topic ID. Guard must block. func TestMessageToolCrossTargetGuard_TraceReplay(t *testing.T) { tool := NewMessageTool(t.TempDir(), false) mb := bus.New() tool.SetMessageBus(mb) ctx := context.Background() ctx = WithToolSessionKey(ctx, "agent:a:telegram:direct:U1") ctx = WithToolChannel(ctx, "telegram") ctx = WithToolChatID(ctx, "U1") ctx = WithToolPeerKind(ctx, "direct") res := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "-1003787954683:topic:1", "message": "here is the image", }) if res == nil || !res.IsError { t.Fatalf("trace replay: expected ErrorResult, got: %+v", res) } if got := drainBusNow(mb); len(got) != 0 { t.Fatalf("trace replay: expected 0 outbound, got %d", len(got)) } } // Sender-only deployment (no msgBus): notice falls back through t.sender so // the origin chat still gets the audit breadcrumb. Verifies P1 fix. func TestMessageToolCrossTargetGuard_NoticeFallbackSender(t *testing.T) { tool := NewMessageTool(t.TempDir(), false) type sent struct{ channel, target, message string } var calls []sent tool.SetChannelSender(func(_ context.Context, ch, tgt, msg string) error { calls = append(calls, sent{ch, tgt, msg}) return nil }) ctx := context.Background() ctx = WithToolSessionKey(ctx, "agent:a:telegram:direct:U1") ctx = WithToolChannel(ctx, "telegram") ctx = WithToolChatID(ctx, "U1") ctx = WithToolPeerKind(ctx, "direct") res := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "-100G", "forward": true, "forward_reason": "user asked forward", "message": "hello group", }) if res == nil || res.IsError { t.Fatalf("expected success, got: %+v", res) } if len(calls) != 2 { t.Fatalf("expected 2 sender calls (forward + notice), got %d: %+v", len(calls), calls) } if calls[0].target != "-100G" { t.Errorf("forward target: got %q want -100G", calls[0].target) } if calls[1].target != "U1" { t.Errorf("notice target: got %q want U1 (origin)", calls[1].target) } if !strings.Contains(calls[1].message, "user asked forward") { t.Errorf("notice missing reason: %q", calls[1].message) } } // Notice must NOT post when the forward itself fails. Verifies P2 fix. func TestMessageToolCrossTargetGuard_NoNoticeOnSendFailure(t *testing.T) { tool := NewMessageTool(t.TempDir(), false) var calls int tool.SetChannelSender(func(_ context.Context, _, _, _ string) error { calls++ return fmt.Errorf("boom") }) ctx := context.Background() ctx = WithToolSessionKey(ctx, "agent:a:telegram:direct:U1") ctx = WithToolChannel(ctx, "telegram") ctx = WithToolChatID(ctx, "U1") ctx = WithToolPeerKind(ctx, "direct") res := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "telegram", "target": "-100G", "forward": true, "forward_reason": "r", "message": "hi", }) if res == nil || !res.IsError { t.Fatalf("expected ErrorResult on send failure, got: %+v", res) } if calls != 1 { t.Errorf("expected only 1 sender call (forward, no notice), got %d", calls) } } // A cross-target group forward must tag the outbound message with the origin // channel/chat (in addition to group_id) so dispatchOutbound can notify the // origin chat if the downstream send actually fails — otherwise a bad target // (e.g. a display name instead of a real chat ID) fails silently. func TestMessageToolForward_TagsOriginMetadataOnGroupSend(t *testing.T) { tool := NewMessageTool(t.TempDir(), false) mb := bus.New() tool.SetMessageBus(mb) ctx := context.Background() ctx = WithToolSessionKey(ctx, "agent:a:zalo:group:747300108647389888") ctx = WithToolChannel(ctx, "bunny-zalo-personal") ctx = WithToolChatID(ctx, "747300108647389888") ctx = WithToolPeerKind(ctx, "group") res := tool.Execute(ctx, map[string]any{ "action": "send", "channel": "bunny-zalo-personal", "target": "Ban Điều Hành", "forward": true, "forward_reason": "forward to Ban Điều Hành", "message": "Anh Tài ơi, xem giúp comment khách hàng nhé.", }) if res != nil && res.IsError { t.Fatalf("unexpected error: %s", res.ForLLM) } got := drainBusNow(mb) if len(got) == 0 { t.Fatal("expected at least one outbound message") } forwardMsg := got[0] if forwardMsg.ChatID != "Ban Điều Hành" { t.Fatalf("forward target = %q, want %q", forwardMsg.ChatID, "Ban Điều Hành") } if forwardMsg.Metadata[bus.MetaForwardOriginChannel] != "bunny-zalo-personal" { t.Errorf("MetaForwardOriginChannel = %q, want %q", forwardMsg.Metadata[bus.MetaForwardOriginChannel], "bunny-zalo-personal") } if forwardMsg.Metadata[bus.MetaForwardOriginChatID] != "747300108647389888" { t.Errorf("MetaForwardOriginChatID = %q, want %q", forwardMsg.Metadata[bus.MetaForwardOriginChatID], "747300108647389888") } if forwardMsg.Metadata["group_id"] != "Ban Điều Hành" { t.Errorf("group_id metadata = %q, want %q (must survive alongside forward tracking)", forwardMsg.Metadata["group_id"], "Ban Điều Hành") } } func TestMessageTargetEnforced(t *testing.T) { cases := []struct { key string want bool }{ {"", true}, {"agent:a:telegram:direct:U1", true}, {"agent:a:telegram:group:-100G", true}, {"agent:a:ws:direct:conv-1", true}, {"agent:a:main", true}, {"agent:a:cron:job-1", false}, {"agent:a:heartbeat", false}, {"agent:a:heartbeat:12345", false}, {"agent:a:subagent:child", false}, {"agent:a:team:T1:U1", false}, } for _, tc := range cases { if got := MessageTargetEnforced(tc.key); got != tc.want { t.Errorf("MessageTargetEnforced(%q) = %v, want %v", tc.key, got, tc.want) } } } func TestMessageToolEditAction(t *testing.T) { var gotChannel, gotChat, gotContent string var gotMsgID int tool := NewMessageTool("", true) tool.SetChannelEditor(func(_ context.Context, ch, chatID string, messageID int, content string) error { gotChannel, gotChat, gotMsgID, gotContent = ch, chatID, messageID, content return nil }) r := tool.Execute(context.Background(), map[string]any{ "action": "edit", "channel": "telegram", "target": float64(-1003995384344), "message_id": float64(42), "message": "Status\nItem: ✅\nAlice: ✅", }) if r.IsError { t.Fatalf("unexpected error: %s", r.ForLLM) } if gotChannel != "telegram" || gotChat != "-1003995384344" || gotMsgID != 42 { t.Errorf("editor got channel=%q chat=%q msgID=%d, want telegram/-1003995384344/42", gotChannel, gotChat, gotMsgID) } if gotContent == "" || !strings.Contains(gotContent, "Alice: ✅") { t.Errorf("editor content = %q, want new status text", gotContent) } } func TestMessageToolEditRequiresMessageID(t *testing.T) { tool := NewMessageTool("", true) tool.SetChannelEditor(func(_ context.Context, _, _ string, _ int, _ string) error { return nil }) r := tool.Execute(context.Background(), map[string]any{ "action": "edit", "channel": "telegram", "target": "123", "message": "x", }) if !r.IsError { t.Error("edit without message_id must error") } } func TestMessageToolEditPrefersContextChannel(t *testing.T) { var gotChannel, gotChat string tool := NewMessageTool("", true) tool.SetChannelEditor(func(_ context.Context, ch, chatID string, _ int, _ string) error { gotChannel, gotChat = ch, chatID return nil }) // Context has the real channel instance + chat; LLM wrongly passes platform name + null target. ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-1003995384344") r := tool.Execute(ctx, map[string]any{ "action": "edit", "channel": "telegram", // wrong — must be ignored in favor of ctx "target": nil, "message_id": float64(42), "message": "Status\nAlice: ✅\nItem: ✅", }) if r.IsError { t.Fatalf("unexpected error: %s", r.ForLLM) } if gotChannel != "mychan" || gotChat != "-1003995384344" { t.Errorf("editor got channel=%q chat=%q, want mychan/-1003995384344 (context wins)", gotChannel, gotChat) } } func TestMessageToolTopicSend(t *testing.T) { var gotChannel, gotChat, gotTopic string tool := NewMessageTool("", true) tool.SetTopicResolver(func(_ context.Context, ch, chatID, name string) (string, bool) { gotChannel, gotChat, gotTopic = ch, chatID, name if name == "Announcements" { return "77", true } return "", false }) mb := bus.New() tool.SetMessageBus(mb) ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") r := tool.Execute(ctx, map[string]any{ "action": "send", "topic": "Announcements", "message": "invoice marked paid, but Alice has not transferred yet", }) if r.IsError { t.Fatalf("unexpected error: %s", r.ForLLM) } if gotChannel != "mychan" || gotChat != "-100500" || gotTopic != "Announcements" { t.Errorf("resolver got %q/%q/%q, want mychan/-100500/Announcements", gotChannel, gotChat, gotTopic) } out, ok := mb.SubscribeOutbound(ctx) if !ok { t.Fatal("expected an outbound message") } if out.Metadata["message_thread_id"] != "77" { t.Errorf("outbound thread id = %q, want 77", out.Metadata["message_thread_id"]) } } func TestMessageToolTopicNotFound(t *testing.T) { tool := NewMessageTool("", true) tool.SetTopicResolver(func(_ context.Context, _, _, _ string) (string, bool) { return "", false }) tool.SetMessageBus(bus.New()) ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") r := tool.Execute(ctx, map[string]any{"action": "send", "topic": "Nonexistent", "message": "x"}) if !r.IsError { t.Error("unknown topic must return an error") } } func TestMessageToolTopicSendReturnsMessageID(t *testing.T) { var gotThread int tool := NewMessageTool("", true) tool.SetTopicResolver(func(_ context.Context, _, _, name string) (string, bool) { if name == "Announcements" { return "77", true } return "", false }) // Synchronous poster returns the sent message id. tool.SetTopicPoster(func(_ context.Context, ch, chatID string, threadID int, content string) (int, error) { gotThread = threadID return 4242, nil }) ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") r := tool.Execute(ctx, map[string]any{ "action": "send", "topic": "Announcements", "message": "Terminator 2 — not watched", }) if r.IsError { t.Fatalf("unexpected error: %s", r.ForLLM) } if gotThread != 77 { t.Errorf("poster got thread %d, want 77", gotThread) } if !strings.Contains(r.ForLLM, `"message_id":4242`) { t.Errorf("result must include the sent message_id, got: %s", r.ForLLM) } }