Files
goclaw/internal/http/tools_invoke.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- Secure CLI credential injection via AES-256-GCM encrypted env vars
- API key management with fine-grained RBAC scopes
- resolveAuth/requireAuth middleware across all 25+ HTTP handlers
- In-memory API key cache with TTL, negative caching, pubsub invalidation
- Sandbox-first execution (fails if unavailable, no silent fallback)
- Credential scrubbing, constant-time token comparison, Admin-only CLI creds
- SQL migration 000020: secure_cli_binaries + api_keys tables
- 14 unit tests for cache and RBAC with race detector

Closes #197
2026-03-15 20:13:18 +07:00

158 lines
4.5 KiB
Go

package http
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// ToolsInvokeHandler handles POST /v1/tools/invoke (direct tool invocation).
type ToolsInvokeHandler struct {
registry *tools.Registry
token string
agentStore store.AgentStore // nil if not configured
}
// NewToolsInvokeHandler creates a handler for the tools invoke endpoint.
func NewToolsInvokeHandler(registry *tools.Registry, token string, agentStore store.AgentStore) *ToolsInvokeHandler {
return &ToolsInvokeHandler{
registry: registry,
token: token,
agentStore: agentStore,
}
}
type toolsInvokeRequest struct {
Tool string `json:"tool"`
Action string `json:"action,omitempty"`
Args map[string]any `json:"args"`
SessionKey string `json:"sessionKey,omitempty"`
AgentID string `json:"agentId,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
Channel string `json:"channel,omitempty"` // tool context: channel name
ChatID string `json:"chatId,omitempty"` // tool context: chat ID
PeerKind string `json:"peerKind,omitempty"` // tool context: "direct" or "group"
}
func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": i18n.T(locale, i18n.MsgMethodNotAllowed)})
return
}
auth := resolveAuth(r, h.token)
if !auth.Authenticated {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
return
}
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path)})
return
}
var req toolsInvokeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.Tool == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tool")})
return
}
slog.Info("tools invoke request", "tool", req.Tool, "dry_run", req.DryRun)
if req.DryRun {
// Just check if tool exists and return its schema
tool, ok := h.registry.Get(req.Tool)
if !ok {
writeToolError(w, http.StatusNotFound, "NOT_FOUND", i18n.T(locale, i18n.MsgNotFound, "tool", req.Tool))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"tool": req.Tool,
"description": tool.Description(),
"parameters": tool.Parameters(),
"dryRun": true,
})
return
}
// Inject userID and agentID into context for interceptors (bootstrap, memory).
ctx := r.Context()
if userID := extractUserID(r); userID != "" {
ctx = store.WithUserID(ctx, userID)
}
agentIDStr := req.AgentID
if agentIDStr == "" {
agentIDStr = extractAgentID(r, "")
}
if agentIDStr != "" && h.agentStore != nil {
ag, err := h.agentStore.GetByKey(ctx, agentIDStr)
if err == nil {
ctx = store.WithAgentID(ctx, ag.ID)
}
}
// Inject tool context keys (channel, chatID, peerKind) for message routing.
if req.Channel != "" {
ctx = tools.WithToolChannel(ctx, req.Channel)
}
if req.ChatID != "" {
ctx = tools.WithToolChatID(ctx, req.ChatID)
}
if req.PeerKind != "" {
ctx = tools.WithToolPeerKind(ctx, req.PeerKind)
}
// Execute the tool
args := req.Args
if args == nil {
args = make(map[string]any)
}
// If action is specified, add it to args
if req.Action != "" {
args["action"] = req.Action
}
result := h.registry.ExecuteWithContext(ctx, req.Tool, args, "http", "api", "direct", "", nil)
if result.IsError {
writeToolError(w, http.StatusBadRequest, "TOOL_ERROR", result.ForLLM)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"output": result.ForLLM,
"forUser": result.ForUser,
"metadata": map[string]any{},
},
})
}
func writeToolError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{
"code": code,
"message": message,
},
})
}