mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 05:04:42 +00:00
6a9b747b65
Dashboard users (and other tenant-authenticated admin/operator/owner
actors) dispatching team tasks to agents in Telegram/Discord groups
previously hit the empty-sender DENY rule when the assigned agent
tried to write files — because dashboard users have tenant identities
(e.g. "viettx") that don't match the numeric file-writer grants stored
per channel.
Two-part fix:
1. CheckFileWriterPermission / CheckCronPermission add an RBAC bypass
at the store layer: if ctx carries RoleFromContext in {admin,
operator, owner}, skip the per-user grant check. Tenant RBAC already
pre-authenticated these callers at the gateway edge; requiring a
redundant per-channel grant blocks legitimate admin work.
2. Role propagation through the full dispatch chain:
ctx (RoleFromContext)
→ RunRequest.Role
→ SubagentTask.OriginRole / AnnounceMetadata.OriginRole
/ DelegateRequest.Role
→ InboundMessage.Metadata[MetaOriginRole]
→ subagentAnnounceRouting.Role / teammateRole
→ RunRequest.Role (re-ingress)
→ store.WithRole(ctx, ...) in loop_context
Covers subagent announce, delegate announce, teammate dispatch
(WS dashboard, tool), session_send, and processNormalMessage's
synthetic-sender override path. team_tasks_create persists
origin_role for deferred dispatches.
Viewer and empty roles fall through to the existing sender-based path
(no bypass).
Regression tests:
- A.10 AdminRoleBypass: admin/operator/owner in ctx allowed even with
empty sender (normally DENY).
- A.11 ViewerRoleDoesNotBypass: viewer and empty roles still hit DENY.
Builds + integration + unit tests green.
344 lines
13 KiB
Go
344 lines
13 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, err := t.manager.ResolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Determine if caller is a lead or a member.
|
|
isLead := agentID == team.LeadAgentID
|
|
channel := ToolChannelFromCtx(ctx)
|
|
if channel == ChannelTeammate || channel == ChannelSystem {
|
|
isLead = true // system/teammate channels act on behalf of the lead
|
|
}
|
|
|
|
taskType, _ := args["task_type"].(string)
|
|
if taskType == "" {
|
|
taskType = "general"
|
|
}
|
|
|
|
if !isLead {
|
|
// Members may only create "request" tasks when the feature is enabled.
|
|
memberCfg := ParseMemberRequestConfig(team.Settings)
|
|
if !memberCfg.Enabled {
|
|
return ErrorResult("Members cannot create tasks. Use team_tasks(action=\"comment\") to communicate.")
|
|
}
|
|
if taskType != "request" {
|
|
return ErrorResult("Members can only create task_type=\"request\". Use team_tasks(action=\"comment\") to communicate.")
|
|
}
|
|
} else if err := t.manager.RequireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Gate: must list tasks before creating to prevent duplicates in concurrent group chat.
|
|
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
|
|
return ErrorResult("You must check existing tasks first. Call team_tasks(action=\"search\", query=\"<keywords>\") to check for similar tasks before creating — this saves tokens vs listing all. Alternatively use action=\"list\" to see the full board.")
|
|
}
|
|
|
|
subject, _ := args["subject"].(string)
|
|
if subject == "" {
|
|
return ErrorResult("subject is required for create action")
|
|
}
|
|
|
|
description, _ := args["description"].(string)
|
|
priority := 0
|
|
if p, ok := args["priority"].(float64); ok {
|
|
priority = int(p)
|
|
}
|
|
|
|
var blockedBy []uuid.UUID
|
|
if raw, ok := args["blocked_by"].([]any); ok {
|
|
for _, v := range raw {
|
|
if s, ok := v.(string); ok {
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("blocked_by contains invalid task ID %q — must be a real task UUID from a previous create call. Create dependency tasks first, then use their IDs.", s))
|
|
}
|
|
blockedBy = append(blockedBy, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate that all blocked_by tasks belong to the same team and are not terminal.
|
|
for _, depID := range blockedBy {
|
|
depTask, err := t.manager.Store().GetTask(ctx, depID)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("blocked_by task %s not found: %v", depID, err))
|
|
}
|
|
if depTask.TeamID != team.ID {
|
|
return ErrorResult(fmt.Sprintf("blocked_by task %s belongs to a different team", depID))
|
|
}
|
|
switch depTask.Status {
|
|
case store.TeamTaskStatusCompleted, store.TeamTaskStatusCancelled, store.TeamTaskStatusFailed:
|
|
return ErrorResult(fmt.Sprintf(
|
|
"blocked_by task %s (%s) is already %s. "+
|
|
"Do not block on finished tasks — create this task without blocked_by, "+
|
|
"or pass the completed task's result in the description instead.",
|
|
depID, depTask.Subject, depTask.Status))
|
|
}
|
|
}
|
|
|
|
// Resolve assignee (agent key → UUID). Required — every task must be assigned.
|
|
assigneeKey, _ := args["assignee"].(string)
|
|
if assigneeKey == "" {
|
|
return ErrorResult("assignee is required — specify which team member should handle this task")
|
|
}
|
|
assigneeID, err := t.manager.ResolveAgentByKey(ctx, assigneeKey)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err))
|
|
}
|
|
// Verify assignee is a member of this team.
|
|
members, err := t.manager.CachedListMembers(ctx, team.ID, agentID)
|
|
if err != nil {
|
|
return ErrorResult("failed to verify team membership: " + err.Error())
|
|
}
|
|
isMember := false
|
|
for _, m := range members {
|
|
if m.AgentID == assigneeID {
|
|
isMember = true
|
|
break
|
|
}
|
|
}
|
|
if !isMember {
|
|
return ErrorResult(fmt.Sprintf("agent %q is not a member of this team", assigneeKey))
|
|
}
|
|
// Prevent lead from self-assigning — causes dual-session execution + loop.
|
|
// buildCreateHint already hides the lead from the member list hint,
|
|
// but weaker models may still attempt self-assignment.
|
|
if assigneeID == team.LeadAgentID {
|
|
return ErrorResult("team lead cannot assign tasks to itself — delegate to a team member instead. You are the team lead; handle this work directly or assign to one of your members.")
|
|
}
|
|
|
|
requireApproval, _ := args["require_approval"].(bool)
|
|
status := store.TeamTaskStatusPending
|
|
if requireApproval {
|
|
status = store.TeamTaskStatusInReview
|
|
} else if len(blockedBy) > 0 {
|
|
status = store.TeamTaskStatusBlocked
|
|
}
|
|
// Assigned tasks without blockers stay pending — dispatched after the turn
|
|
// ends via post-turn processing (avoids race with blocked_by setup).
|
|
|
|
// Member requests without auto_dispatch stay pending for leader review.
|
|
memberCfgForDispatch := ParseMemberRequestConfig(team.Settings)
|
|
skipAutoDispatch := !isLead && taskType == "request" && !memberCfgForDispatch.AutoDispatch
|
|
|
|
chatID := ToolChatIDFromCtx(ctx)
|
|
|
|
// Compute team workspace via layered pipeline: tenant → team → user/chat.
|
|
shared := IsSharedWorkspace(team.Settings)
|
|
taskMeta := make(map[string]any)
|
|
teamWsDir := ResolveWorkspace(t.manager.DataDir(),
|
|
TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
|
|
TeamLayer(team.ID),
|
|
UserChatLayer(chatID, shared),
|
|
)
|
|
taskMeta[TaskMetaTeamWorkspace] = teamWsDir
|
|
// Auto-collect media files from current run to team workspace.
|
|
// When leader received files from user and creates a task, copy those
|
|
// files to the team workspace so members can access them via read_file.
|
|
// Also rewrite any media paths in the description to point to the workspace copy,
|
|
// since members can't access the original .media/ paths outside their workspace.
|
|
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
|
|
if wsDir, _ := taskMeta[TaskMetaTeamWorkspace].(string); wsDir != "" {
|
|
nameMap := RunMediaNamesFromCtx(ctx)
|
|
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
|
|
// Store as []any so type assertion works both before and after JSON round-trip.
|
|
files := make([]any, len(copiedPaths))
|
|
for i, p := range copiedPaths {
|
|
files[i] = p
|
|
}
|
|
taskMeta["attached_files"] = files
|
|
|
|
// Rewrite media paths in description so members see workspace paths.
|
|
for i, src := range mediaPaths {
|
|
if i < len(copiedPaths) {
|
|
description = strings.ReplaceAll(description, src, copiedPaths[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
|
|
if len(blockedBy) > 0 {
|
|
ids := make([]string, len(blockedBy))
|
|
for i, id := range blockedBy {
|
|
ids[i] = id.String()
|
|
}
|
|
taskMeta["original_blocked_by"] = ids
|
|
}
|
|
// Store peer kind so dispatches preserve the correct session scope (group vs direct).
|
|
if pk := ToolPeerKindFromCtx(ctx); pk != "" {
|
|
taskMeta[TaskMetaPeerKind] = pk
|
|
}
|
|
// Store local key so forum-topic routing works on deferred/unblocked dispatches.
|
|
if lk := ToolLocalKeyFromCtx(ctx); lk != "" {
|
|
taskMeta[TaskMetaLocalKey] = lk
|
|
}
|
|
// Store origin session key so deferred dispatches route announces correctly.
|
|
// WS sessions use non-standard key format that BuildScopedSessionKey() cannot reproduce.
|
|
if sk := ToolSessionKeyFromCtx(ctx); sk != "" {
|
|
taskMeta[TaskMetaOriginSession] = sk
|
|
}
|
|
// Store leader's trace context so unblocked dispatch links back to the leader's trace.
|
|
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
|
|
taskMeta[TaskMetaOriginTrace] = traceID.String()
|
|
}
|
|
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
|
|
taskMeta[TaskMetaOriginRootSpan] = rootSpanID.String()
|
|
}
|
|
// Persist the real acting sender so deferred/dashboard dispatches can
|
|
// restore permission attribution when the teammate runs (#915 Flow F).
|
|
if sender := store.SenderIDFromContext(ctx); sender != "" {
|
|
taskMeta["origin_sender_id"] = sender
|
|
}
|
|
// Persist caller role for RBAC-aware bypass at dispatch time.
|
|
if role := store.RoleFromContext(ctx); role != "" {
|
|
taskMeta["origin_role"] = role
|
|
}
|
|
|
|
task := &store.TeamTaskData{
|
|
TeamID: team.ID,
|
|
Subject: subject,
|
|
Description: description,
|
|
Status: status,
|
|
BlockedBy: blockedBy,
|
|
Priority: priority,
|
|
// SCOPE-intentional (#915 audit 2026-04-16): team task visibility is
|
|
// per-chat, not per-user. team_tasks_read.go filters end-user lists by
|
|
// this same UserID. Migrating to ActorIDFromContext would hide group
|
|
// members' shared work from each other.
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
TaskType: taskType,
|
|
CreatedByAgentID: &agentID,
|
|
ChatID: chatID,
|
|
Metadata: taskMeta,
|
|
}
|
|
task.OwnerAgentID = &assigneeID
|
|
|
|
// Auto-link member request to the member's current task as parent.
|
|
if !isLead && taskType == "request" {
|
|
if parentIDStr := TeamTaskIDFromCtx(ctx); parentIDStr != "" {
|
|
if parentUUID, err := uuid.Parse(parentIDStr); err == nil {
|
|
task.ParentID = &parentUUID
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := t.manager.Store().CreateTask(ctx, task); err != nil {
|
|
return ErrorResult("failed to create task: " + err.Error())
|
|
}
|
|
|
|
// Auto-copy files referenced in subject+description from leader's personal workspace
|
|
// to team workspace so members can access them.
|
|
if isLead {
|
|
personalWs := ToolWorkspaceFromCtx(ctx)
|
|
searchText := subject + "\n" + description
|
|
if n := autoShareFiles(searchText, personalWs, teamWsDir); n > 0 {
|
|
slog.Info("team_tasks.create: auto-shared files", "count", n, "task_id", task.ID)
|
|
}
|
|
}
|
|
|
|
// Persist media files copied during task creation as DB attachments,
|
|
// so the UI and queries see them in team_task_attachments table.
|
|
// (Members get auto-attached via WorkspaceInterceptor, but leaders
|
|
// don't run inside a task context — handle explicitly here.)
|
|
if files, ok := taskMeta["attached_files"].([]any); ok {
|
|
for _, f := range files {
|
|
filePath, ok := f.(string)
|
|
if !ok || filePath == "" {
|
|
continue
|
|
}
|
|
var fileSize int64
|
|
if info, err := os.Stat(filePath); err == nil {
|
|
fileSize = info.Size()
|
|
}
|
|
att := &store.TeamTaskAttachmentData{
|
|
TaskID: task.ID,
|
|
TeamID: team.ID,
|
|
ChatID: chatID,
|
|
Path: filePath,
|
|
FileSize: fileSize,
|
|
MimeType: mimeFromExt(filepath.Ext(filePath)),
|
|
CreatedByAgentID: &agentID,
|
|
}
|
|
if err := t.manager.Store().AttachFileToTask(ctx, att); err != nil {
|
|
slog.Warn("executeCreate: auto-attach media failed", "task_id", task.ID, "path", filePath, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
agentKey := t.manager.AgentKeyFromID(ctx, agentID)
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, BuildTaskEventPayload(
|
|
team.ID.String(), task.ID.String(),
|
|
status,
|
|
"agent", agentKey,
|
|
WithSubject(subject),
|
|
WithContextInfo(ctx),
|
|
WithTimestamp(task.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")),
|
|
))
|
|
// Track for post-turn dispatch. If no post-turn hook (e.g. HTTP API), dispatch immediately.
|
|
// Member requests with auto_dispatch=false stay pending for leader review — skip dispatch.
|
|
if status == store.TeamTaskStatusPending && !skipAutoDispatch {
|
|
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil {
|
|
ptd.Add(team.ID, task.ID)
|
|
} else {
|
|
// Fallback: assign (pending → in_progress + lock) then dispatch.
|
|
if err := t.manager.Store().AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil {
|
|
slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err)
|
|
} else {
|
|
t.manager.BroadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, BuildTaskEventPayload(
|
|
team.ID.String(), task.ID.String(),
|
|
store.TeamTaskStatusInProgress,
|
|
"system", "fallback_dispatch",
|
|
WithTaskInfo(task.TaskNumber, task.Subject),
|
|
WithOwnerAgentKey(t.manager.AgentKeyFromID(ctx, assigneeID)),
|
|
WithChannel(task.Channel),
|
|
WithChatID(task.ChatID),
|
|
WithPeerKind(ToolPeerKindFromCtx(ctx)),
|
|
WithLocalKey(ToolLocalKeyFromCtx(ctx)),
|
|
))
|
|
t.manager.DispatchTaskToAgent(ctx, task, team, assigneeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
assigneeName := t.manager.AgentDisplayName(ctx, t.manager.AgentKeyFromID(ctx, assigneeID))
|
|
if assigneeName == "" {
|
|
assigneeName = t.manager.AgentKeyFromID(ctx, assigneeID)
|
|
}
|
|
msg := fmt.Sprintf("Task created: %s (id=%s, task_number=%d, status=%s, assignee=%s)", subject, task.ID, task.TaskNumber, status, assigneeName)
|
|
|
|
// Soft guardrail: warn if subject suggests multiple deliverables.
|
|
// Only checks subject (not description) — detailed descriptions are fine.
|
|
if subject != "" {
|
|
subjLower := strings.ToLower(subject)
|
|
hasCompound := strings.Contains(subjLower, " and ") &&
|
|
(strings.Contains(subjLower, "implement") || strings.Contains(subjLower, "create") ||
|
|
strings.Contains(subjLower, "build") || strings.Contains(subjLower, "design") ||
|
|
strings.Contains(subjLower, "write") || strings.Contains(subjLower, "develop"))
|
|
if hasCompound {
|
|
msg += "\n\nWarning: This task subject suggests multiple deliverables. Consider splitting into separate tasks if they need different skills."
|
|
}
|
|
}
|
|
return NewResult(msg)
|
|
}
|