mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
* fix: resolve Zalo group name to real chat ID, notify origin on failed forward Two related root causes behind "forward message to group by name" silently failing while the agent reports success: 1. No tool let the agent resolve a group's display name (e.g. "Ban Dieu Hanh") to the chat ID the message tool actually requires. sessions_list only exposes session keys (numeric group IDs), never human-readable names, so the agent had no reliable way to turn a name into a real target and ended up passing the display name itself as `target`. Adds zalo_list_groups, wrapping the already-used (dashboard picker) protocol.FetchGroups behind the same optional-interface pattern as list_group_members/GroupMemberProvider (GroupListProvider on channels.Manager, gated to zalo_personal via RequiredChannelTypes). 2. When the resulting send fails downstream (e.g. Zalo rejects a bad chat_id), dispatchOutbound only ever retried/notified media failures on the same (already-broken) destination, and dropped text-only failures entirely — even though message.go's own postCrossTargetNotice comment states forwards must never announce a fake delivery. Because the bus publish is fire-and-forget, the tool had already returned "sent" and announced success to the origin chat before the real send was even attempted. message.go now tags cross-target forwards with origin channel/chat in OutboundMessage.Metadata; dispatchOutbound uses it to notify the ORIGIN chat with the real failure instead of silently dropping it or retrying against the same invalid target. * test: cover forward-origin metadata tagging and dispatch failure notice Extracts dispatchOutbound's error branch into handleSendFailure so it can be unit tested without driving the consumer loop/goroutine, and adds coverage for: forward failures notifying the origin chat (not the broken destination), pre-existing non-forward media/text-only behavior staying unchanged, message.go tagging cross-target group forwards with origin metadata alongside group_id, and the new zalo_list_groups tool/Manager delegator.
292 lines
11 KiB
Go
292 lines
11 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/internal/workstation"
|
|
"github.com/nextlevelbuilder/goclaw/internal/workstation/security"
|
|
)
|
|
|
|
// wireExtraTools registers cron, heartbeat, session, message tools and aliases
|
|
// onto the tool registry after setupToolRegistry() and setupSkillsSystem() have run.
|
|
// Returns the heartbeat tool (needed for later wiring) and the hasMemory flag.
|
|
func wireExtraTools(
|
|
pgStores *store.Stores,
|
|
toolsReg *tools.Registry,
|
|
msgBus *bus.MessageBus,
|
|
workspace string,
|
|
dataDir string,
|
|
agentCfg config.AgentDefaults,
|
|
globalSkillsDir string,
|
|
builtinSkillsDir string,
|
|
cronCommandEnabled bool,
|
|
) (heartbeatTool *tools.HeartbeatTool, hasMemory bool) {
|
|
// web_search: tenant-scoped resolve requires stores + msgBus — register here.
|
|
toolsReg.Register(tools.NewWebSearchTool(pgStores.ConfigSecrets, msgBus))
|
|
slog.Info("web_search tool registered (tenant-scoped resolve)")
|
|
|
|
// DateTime tool (precise time for cron scheduling, memory timestamps, etc.)
|
|
toolsReg.Register(tools.NewDateTimeTool())
|
|
toolsReg.Register(tools.NewWaitTool())
|
|
|
|
// Cron tool (agent-facing)
|
|
cronTool := tools.NewCronTool(pgStores.Cron)
|
|
cronTool.SetProviderStore(pgStores.Providers)
|
|
cronTool.SetCommandEnabled(cronCommandEnabled)
|
|
toolsReg.Register(cronTool)
|
|
slog.Info("cron tool registered")
|
|
|
|
// Heartbeat tool (agent-facing)
|
|
heartbeatTool = tools.NewHeartbeatTool(pgStores.Heartbeats, pgStores.ConfigPermissions)
|
|
heartbeatTool.SetAgentStore(pgStores.Agents)
|
|
toolsReg.Register(heartbeatTool)
|
|
slog.Info("heartbeat tool registered")
|
|
|
|
// Session tools (list, status, history, send)
|
|
toolsReg.Register(tools.NewSessionsListTool())
|
|
toolsReg.Register(tools.NewSessionStatusTool())
|
|
toolsReg.Register(tools.NewSessionsHistoryTool())
|
|
toolsReg.Register(tools.NewSessionsSendTool())
|
|
|
|
// Message tool (send to channels)
|
|
toolsReg.Register(tools.NewMessageTool(workspace, agentCfg.RestrictToWorkspace))
|
|
// Send file tool (deliver existing workspace file as attachment)
|
|
toolsReg.Register(tools.NewSendFileTool(workspace, agentCfg.RestrictToWorkspace))
|
|
// Group members tool (list members in group chats)
|
|
toolsReg.Register(tools.NewListGroupMembersTool())
|
|
// Zalo group list tool (resolve a group's real chat ID from its display name)
|
|
toolsReg.Register(tools.NewListGroupsTool())
|
|
// Telegram manager tool (admin/forum/message management; gated by tool policy)
|
|
// create_forum_topic is kept as a backward-compatible wrapper for topic.create.
|
|
toolsReg.Register(tools.NewCreateForumTopicTool(nil))
|
|
toolsReg.Register(tools.NewTelegramManagerTool())
|
|
slog.Info("session + message + send_file + telegram_manager tools registered")
|
|
|
|
// Register legacy tool aliases (backward-compat names from policy.go).
|
|
for alias, canonical := range tools.LegacyToolAliases() {
|
|
toolsReg.RegisterAlias(alias, canonical)
|
|
}
|
|
|
|
// Register Claude Code tool aliases so Claude Code skills work without modification.
|
|
for alias, canonical := range map[string]string{
|
|
"Read": "read_file",
|
|
"Write": "write_file",
|
|
"Edit": "edit",
|
|
"Bash": "exec",
|
|
"WebFetch": "web_fetch",
|
|
"WebSearch": "web_search",
|
|
"Agent": "spawn",
|
|
"Skill": "use_skill",
|
|
"ToolSearch": "mcp_tool_search",
|
|
} {
|
|
toolsReg.RegisterAlias(alias, canonical)
|
|
}
|
|
slog.Info("tool aliases registered", "count", len(toolsReg.Aliases()))
|
|
|
|
// Allow read_file and list_files to access skills directories and CLI workspaces.
|
|
homeDir, _ := os.UserHomeDir()
|
|
skillsAllowPaths := []string{globalSkillsDir, builtinSkillsDir, filepath.Join(dataDir, "tenants")}
|
|
if homeDir != "" {
|
|
skillsAllowPaths = append(skillsAllowPaths, filepath.Join(homeDir, ".agents", "skills"))
|
|
}
|
|
if pgStores.Skills != nil {
|
|
skillsAllowPaths = append(skillsAllowPaths, pgStores.Skills.Dirs()...)
|
|
}
|
|
if readTool, ok := toolsReg.Get("read_file"); ok {
|
|
if pa, ok := readTool.(tools.PathAllowable); ok {
|
|
pa.AllowPaths(skillsAllowPaths...)
|
|
pa.AllowPaths(filepath.Join(dataDir, "cli-workspaces"))
|
|
}
|
|
}
|
|
if listTool, ok := toolsReg.Get("list_files"); ok {
|
|
if pa, ok := listTool.(tools.PathAllowable); ok {
|
|
pa.AllowPaths(skillsAllowPaths...)
|
|
}
|
|
}
|
|
if sendFileTool, ok := toolsReg.Get("send_file"); ok {
|
|
if pa, ok := sendFileTool.(tools.PathAllowable); ok {
|
|
pa.AllowPaths(skillsAllowPaths...)
|
|
}
|
|
}
|
|
|
|
// User-configured allowed paths (config agents.defaults.allowed_paths, for
|
|
// cross-drive access on Windows and shared dirs outside the workspace).
|
|
// Applied via a helper so cmd/gateway.go can re-apply it after
|
|
// ApplySystemConfigs overlays system_configs['allowed_paths'] — see
|
|
// applyUserAllowedPaths. Paths are validated per-request in resolvePath for
|
|
// tenant isolation.
|
|
applyUserAllowedPaths(toolsReg, agentCfg.AllowedPaths)
|
|
|
|
// Memory tools are PG-backed; always available.
|
|
hasMemory = true
|
|
|
|
// Wire SessionStoreAware + BusAware on session tools
|
|
for _, name := range []string{"sessions_list", "session_status", "sessions_history", "sessions_send"} {
|
|
if t, ok := toolsReg.Get(name); ok {
|
|
if sa, ok := t.(tools.SessionStoreAware); ok {
|
|
sa.SetSessionStore(pgStores.Sessions)
|
|
}
|
|
if ba, ok := t.(tools.BusAware); ok {
|
|
ba.SetMessageBus(msgBus)
|
|
}
|
|
}
|
|
}
|
|
// Wire BusAware on message tool
|
|
if t, ok := toolsReg.Get("message"); ok {
|
|
if ba, ok := t.(tools.BusAware); ok {
|
|
ba.SetMessageBus(msgBus)
|
|
}
|
|
}
|
|
|
|
return heartbeatTool, hasMemory
|
|
}
|
|
|
|
// fsAllowPathTools are the filesystem tools that honour user-configured allowed
|
|
// paths beyond the agent workspace (config agents.defaults.allowed_paths).
|
|
var fsAllowPathTools = []string{"read_file", "list_files", "write_file", "edit", "send_file"}
|
|
|
|
// applyUserAllowedPaths grants the user-configured allowed paths to the
|
|
// filesystem tools. ExpandHome resolves a leading "~".
|
|
//
|
|
// Called twice during startup: once while tools are wired (from config.json),
|
|
// and again from cmd/gateway.go after ApplySystemConfigs overlays
|
|
// system_configs['allowed_paths']. Tool wiring runs before that overlay, so
|
|
// without the re-apply DB-driven allowed paths never reach the tools — the
|
|
// AllowPaths analogue of the rate-limiter re-apply (#1111). Safe to call
|
|
// repeatedly: AllowPaths is additive and the prefix check is membership-based,
|
|
// so a duplicated prefix is harmless.
|
|
func applyUserAllowedPaths(toolsReg *tools.Registry, allowedPaths []string) {
|
|
var paths []string
|
|
for _, p := range allowedPaths {
|
|
if expanded := config.ExpandHome(p); expanded != "" {
|
|
paths = append(paths, expanded)
|
|
}
|
|
}
|
|
if len(paths) == 0 {
|
|
return
|
|
}
|
|
for _, name := range fsAllowPathTools {
|
|
if t, ok := toolsReg.Get(name); ok {
|
|
if pa, ok := t.(tools.PathAllowable); ok {
|
|
pa.AllowPaths(paths...)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// wireWorkstationTools registers workstation_exec and claude_remote tools (Standard edition only).
|
|
// Phase 6: wires the real AllowlistChecker permission check replacing the deny-all sentinel.
|
|
// Phase 7: wires the activity sink for exec audit logging.
|
|
//
|
|
// Security model (argv-exec, no sh -c):
|
|
// - C1 fix: cmd is the binary name (argv[0]), not a shell command string — no shell injection possible.
|
|
// - C2 fix: NFKC normalization applied before any check — collapses Unicode lookalikes.
|
|
// - Default-deny: AllowlistChecker rejects any cmd not in workstation's allowlist.
|
|
// - Rate limit: 30 exec/min per agent+workstation, 300/hr per workstation.
|
|
//
|
|
// Also subscribes to workstation update/delete events to keep BackendCache and
|
|
// AllowlistChecker cache consistent with the database.
|
|
func wireWorkstationTools(
|
|
pgStores *store.Stores,
|
|
toolsReg *tools.Registry,
|
|
domainBus eventbus.DomainEventBus,
|
|
) func() {
|
|
if edition.Current().Name != "standard" {
|
|
return func() {}
|
|
}
|
|
if pgStores.Workstations == nil || pgStores.WorkstationLinks == nil {
|
|
slog.Warn("workstation tools skipped: workstation stores not initialised")
|
|
return func() {}
|
|
}
|
|
|
|
backendCache := workstation.NewBackendCache(pgStores.Workstations, 10*time.Minute)
|
|
|
|
workstationExecTool := tools.NewWorkstationExecTool(
|
|
pgStores.Workstations,
|
|
pgStores.WorkstationLinks,
|
|
backendCache,
|
|
domainBus,
|
|
)
|
|
claudeRemoteTool := tools.NewClaudeRemoteTool(workstationExecTool)
|
|
|
|
// Phase 6: wire real permission checker (AllowlistChecker + rate limiter).
|
|
if pgStores.WorkstationPermissions != nil {
|
|
allowlistChecker := security.NewAllowlistChecker(pgStores.WorkstationPermissions, 30*time.Second)
|
|
rateLimiter := security.NewWorkstationRateLimiter()
|
|
|
|
workstationExecTool.SetPermCheck(func(ctx context.Context, ws *store.Workstation, cmd string, args []string, env map[string]string) error {
|
|
// Rate limit check first (cheap, no DB).
|
|
agentID := store.AgentIDFromContext(ctx).String()
|
|
if !rateLimiter.Allow(ws.TenantID, ws.ID, agentID) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
return fmt.Errorf("%s", i18n.T(locale, i18n.MsgWorkstationRateLimit))
|
|
}
|
|
// Env blocklist check — rejects forbidden/sensitive env keys.
|
|
if err := allowlistChecker.CheckEnv(ctx, ws, env); err != nil {
|
|
return err
|
|
}
|
|
// Allowlist + input validation (NFKC normalize, NUL/CRLF, binary match).
|
|
return allowlistChecker.Check(ctx, ws, cmd, args)
|
|
})
|
|
slog.Info("workstation tools registered (Standard edition; Phase 6 AllowlistChecker active)")
|
|
|
|
// Invalidate allowlist cache on permission changes.
|
|
if domainBus != nil {
|
|
domainBus.Subscribe(eventbus.EventWorkstationPermChanged, func(_ context.Context, e eventbus.DomainEvent) error {
|
|
if id, err := uuid.Parse(e.SourceID); err == nil {
|
|
allowlistChecker.Invalidate(id)
|
|
slog.Debug("workstation allowlist cache invalidated", "workstation_id", id)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
} else {
|
|
slog.Warn("workstation tools registered with deny-all: WorkstationPermissions store not initialised")
|
|
}
|
|
|
|
toolsReg.Register(workstationExecTool)
|
|
toolsReg.Register(claudeRemoteTool)
|
|
|
|
// Subscribe to workstation update/delete events to evict stale BackendCache entries.
|
|
if domainBus != nil {
|
|
domainBus.Subscribe(eventbus.EventWorkstationUpdated, func(_ context.Context, e eventbus.DomainEvent) error {
|
|
if id, err := uuid.Parse(e.SourceID); err == nil {
|
|
backendCache.Invalidate(id)
|
|
slog.Debug("workstation backend cache invalidated on update", "workstation_id", id)
|
|
}
|
|
return nil
|
|
})
|
|
domainBus.Subscribe(eventbus.EventWorkstationDeleted, func(_ context.Context, e eventbus.DomainEvent) error {
|
|
if id, err := uuid.Parse(e.SourceID); err == nil {
|
|
backendCache.Invalidate(id)
|
|
slog.Debug("workstation backend cache invalidated on delete", "workstation_id", id)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Phase 7: wire activity audit sink (persists exec done events + nightly prune).
|
|
if pgStores.WorkstationActivity != nil {
|
|
stopSink := workstation.WireActivitySink(domainBus, pgStores.WorkstationActivity)
|
|
slog.Info("workstation activity audit sink registered")
|
|
return func() {
|
|
stopSink()
|
|
pgStores.WorkstationActivity.Stop()
|
|
}
|
|
}
|
|
}
|
|
return func() {}
|
|
}
|