diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index 73dbdba6..3a4e3359 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -127,12 +127,6 @@ func builtinToolSeedData() []store.BuiltinToolDef { {Name: "team_message", DisplayName: "Team Message", Description: "Send a direct message or broadcast to teammates in the agent team", Category: "teams", Enabled: true, Requires: []string{"managed_mode", "teams"}, }, - {Name: "workspace_write", DisplayName: "Workspace Write", Description: "Write files to the team shared workspace visible to all team members", Category: "teams", Enabled: true, - Requires: []string{"managed_mode", "teams"}, - }, - {Name: "workspace_read", DisplayName: "Workspace Read", Description: "Read, list, delete, pin, and tag files in the team shared workspace", Category: "teams", Enabled: true, - Requires: []string{"managed_mode", "teams"}, - }, } } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 0c14fdde..49b73291 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -154,6 +154,7 @@ func wireExtras( DynamicLoader: dynamicLoader, AgentLinkStore: stores.AgentLinks, TeamStore: stores.Teams, + DataDir: workspace, SecureCLIStore: stores.SecureCLI, BuiltinToolStore: stores.BuiltinTools, MCPStore: stores.MCP, @@ -395,9 +396,15 @@ func wireExtras( postTurn = teamMgr toolsReg.Register(tools.NewTeamTasksTool(teamMgr)) toolsReg.Register(tools.NewTeamMessageTool(teamMgr)) - toolsReg.Register(tools.NewWorkspaceWriteTool(teamMgr, workspace)) - toolsReg.Register(tools.NewWorkspaceReadTool(teamMgr, workspace)) - slog.Info("team + workspace tools registered", "workspace", workspace) + // Wire workspace interceptor into write_file so team workspace validation + // and event broadcasting happen transparently via existing file tools. + wsInterceptor := tools.NewWorkspaceInterceptor(teamMgr) + if writeTool, ok := toolsReg.Get("write_file"); ok { + if wia, ok := writeTool.(tools.WorkspaceInterceptorAware); ok { + wia.SetWorkspaceInterceptor(wsInterceptor) + } + } + slog.Info("team tools registered", "workspace", workspace) // Team cache invalidation via pub/sub msgBus.Subscribe(bus.TopicCacheTeam, func(event bus.Event) { diff --git a/internal/agent/resolver_helpers.go b/internal/agent/resolver_helpers.go index fe11bdd6..b8199b01 100644 --- a/internal/agent/resolver_helpers.go +++ b/internal/agent/resolver_helpers.go @@ -175,9 +175,10 @@ func agentToolPolicyWithMCP(policy *config.ToolPolicySpec, hasMCP bool) *config. return policy } -// agentToolPolicyWithWorkspace injects workspace_write and workspace_read into -// alsoAllow when the agent belongs to a team, ensuring the PolicyEngine doesn't -// block them even if the agent has a restrictive allow list. +// agentToolPolicyWithWorkspace injects file tools into alsoAllow when the agent +// belongs to a team, ensuring the PolicyEngine doesn't block them even if the +// agent has a restrictive allow list. File tools are now workspace-aware via +// WorkspaceInterceptor, so no separate workspace_write/workspace_read needed. func agentToolPolicyWithWorkspace(policy *config.ToolPolicySpec, hasTeam bool) *config.ToolPolicySpec { if !hasTeam { return policy @@ -185,7 +186,7 @@ func agentToolPolicyWithWorkspace(policy *config.ToolPolicySpec, hasTeam bool) * if policy == nil { policy = &config.ToolPolicySpec{} } - for _, tool := range []string{"workspace_write", "workspace_read"} { + for _, tool := range []string{"read_file", "write_file", "list_files"} { if !slices.Contains(policy.AlsoAllow, tool) { policy.AlsoAllow = append(policy.AlsoAllow, tool) } diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index e3eb638c..334738d7 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -48,10 +48,8 @@ var BridgeToolNames = map[string]bool{ "sessions_history": true, "sessions_send": true, // Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers) - "team_tasks": true, - "team_message": true, - "workspace_write": true, - "workspace_read": true, + "team_tasks": true, + "team_message": true, } // NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools. diff --git a/internal/tools/policy.go b/internal/tools/policy.go index 1cc2436e..287fd6f2 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -18,7 +18,7 @@ var toolGroups = map[string][]string{ "ui": {"browser"}, "automation": {"cron"}, "messaging": {"message", "create_forum_topic"}, - "team": {"team_tasks", "team_message", "workspace_write", "workspace_read"}, + "team": {"team_tasks", "team_message"}, // Composite group: all goclaw native tools (excludes MCP/custom plugins). "goclaw": { "read_file", "write_file", "list_files", "edit", "exec", @@ -29,7 +29,7 @@ var toolGroups = map[string][]string{ "read_image", "read_document", "read_audio", "read_video", "create_image", "create_video", "skill_search", "mcp_tool_search", "tts", - "team_tasks", "team_message", "workspace_write", "workspace_read", + "team_tasks", "team_message", }, } diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index ccb764ff..aaff8b75 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -153,8 +153,6 @@ var SubagentDenyAlways = []string{ "memory_search", "memory_get", "sessions_send", - "workspace_write", - "workspace_read", } // SubagentDenyLeaf is the additional deny list for subagents at max depth. diff --git a/internal/tools/types.go b/internal/tools/types.go index 08d49e01..b177b265 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -55,6 +55,11 @@ type GroupWriterAware interface { SetGroupWriterCache(*store.GroupWriterCache) } +// WorkspaceInterceptorAware tools can receive a WorkspaceInterceptor for team workspace validation. +type WorkspaceInterceptorAware interface { + SetWorkspaceInterceptor(*WorkspaceInterceptor) +} + // MemoryStoreAware tools can receive a MemoryStore for Postgres queries. type MemoryStoreAware interface { SetMemoryStore(store.MemoryStore) diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index 06f9c466..35997864 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -1,27 +1,24 @@ package tools import ( - "context" - "encoding/json" "fmt" "os" "path/filepath" - "regexp" - "strings" "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" ) -// Workspace limits shared across read/write tools. -const defaultQuotaMB = 500 +// Workspace limits shared across workspace interceptor. +const ( + maxFileSizeBytes = 10 * 1024 * 1024 // 10MB + maxFilesPerScope = 100 +) -// workspaceDir returns the disk directory for a team workspace scope. +// WorkspaceDir returns the disk directory for a team workspace scope. // Pattern: {dataDir}/teams/{teamID}/{chatID}/ // chatID is the system-derived userID (stable across WS reconnects). // Creates directory with 0750 if not exists. -func workspaceDir(dataDir string, teamID uuid.UUID, _, chatID string) (string, error) { +func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) { if chatID == "" { chatID = "_default" } @@ -32,206 +29,8 @@ func workspaceDir(dataDir string, teamID uuid.UUID, _, chatID string) (string, e return dir, nil } -// workspaceRelPath returns the relative path (relative to dataDir) for a workspace file. -func workspaceRelPath(teamID uuid.UUID, chatID, fileName string) string { - if chatID == "" { - chatID = "_default" - } - return filepath.Join("teams", teamID.String(), chatID, fileName) -} - -// ResolveWorkspacePath resolves a workspace file path to an absolute disk path. -// Handles both legacy absolute paths and new relative paths. -func ResolveWorkspacePath(dataDir, path string) string { - if filepath.IsAbs(path) { - return path - } - return filepath.Join(dataDir, path) -} - -// validFileName matches alphanumeric + "-_." only. -var validFileName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}[a-zA-Z0-9._]$`) - -// sanitizeFilePath validates a relative file path for workspace read/delete. -// Allows subdirectory access (e.g. "generated/2026-03-16/image.png") while -// preventing path traversal and absolute paths. -func sanitizeFilePath(name string) (string, error) { - if name == "" { - return "", fmt.Errorf("file_name is required") - } - if len(name) > 512 { - return "", fmt.Errorf("file_name exceeds 512 characters") - } - if strings.Contains(name, "\x00") { - return "", fmt.Errorf("file_name contains null bytes") - } - if strings.Contains(name, "\\") { - return "", fmt.Errorf("file_name contains backslash") - } - cleaned := filepath.Clean(name) - if filepath.IsAbs(cleaned) { - return "", fmt.Errorf("file_name must be a relative path") - } - for _, part := range strings.Split(cleaned, string(filepath.Separator)) { - if part == ".." { - return "", fmt.Errorf("file_name contains path traversal") - } - } - return cleaned, nil -} - -// sanitizeFileName validates file name: max 255 chars, no path separators, -// no null bytes, no "..", alphanumeric + "-_." only. -func sanitizeFileName(name string) (string, error) { - if name == "" { - return "", fmt.Errorf("file_name is required") - } - if len(name) > 255 { - return "", fmt.Errorf("file_name exceeds 255 characters") - } - if strings.Contains(name, "\x00") { - return "", fmt.Errorf("file_name contains null bytes") - } - if strings.Contains(name, "..") { - return "", fmt.Errorf("file_name contains path traversal") - } - if strings.ContainsAny(name, "/\\") { - return "", fmt.Errorf("file_name contains path separators") - } - // Allow single-char names like "a" or "1" - if len(name) == 1 { - if (name[0] >= 'a' && name[0] <= 'z') || (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= '0' && name[0] <= '9') { - return name, nil - } - return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only") - } - if !validFileName.MatchString(name) { - return "", fmt.Errorf("file_name must be alphanumeric with hyphens, underscores, and dots only") - } - return name, nil -} - -// blockedExtensions lists executable file types that are not allowed. +// blockedExtensions lists executable file types that are not allowed in team workspaces. var blockedExtensions = map[string]bool{ ".exe": true, ".sh": true, ".bat": true, ".cmd": true, ".ps1": true, ".com": true, ".msi": true, ".scr": true, } - -// mimeTypes maps file extensions to MIME types (package-level to avoid re-allocation). -var mimeTypes = map[string]string{ - ".txt": "text/plain", - ".md": "text/markdown", - ".json": "application/json", - ".csv": "text/csv", - ".xml": "text/xml", - ".html": "text/html", - ".css": "text/css", - ".js": "application/javascript", - ".ts": "application/typescript", - ".py": "text/x-python", - ".go": "text/x-go", - ".rs": "text/x-rust", - ".java": "text/x-java", - ".yaml": "application/x-yaml", - ".yml": "application/x-yaml", - ".toml": "application/toml", - ".sql": "application/sql", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".pdf": "application/pdf", - ".zip": "application/zip", - ".tar": "application/x-tar", - ".gz": "application/gzip", -} - -// inferMimeType returns MIME type from file extension. -// Blocks executable types. -func inferMimeType(fileName string) (string, error) { - ext := strings.ToLower(filepath.Ext(fileName)) - if blockedExtensions[ext] { - return "", fmt.Errorf("executable file type %q is not allowed", ext) - } - if mt, ok := mimeTypes[ext]; ok { - return mt, nil - } - return "application/octet-stream", nil -} - -// isBinaryMime returns true if the MIME type represents binary content. -func isBinaryMime(mimeType string) bool { - if strings.HasPrefix(mimeType, "text/") { - return false - } - switch mimeType { - case "application/json", "application/javascript", "application/typescript", - "application/x-yaml", "application/toml", "application/sql", - "application/xml": - return false - } - return true -} - -// resolveWorkspaceScope resolves the workspace scope (channel, chatID) for tools. -// Scope is (team_id, userID) where userID is the system-derived stable user ID. -// channel is always "" (kept for signature compatibility); chatID = userID. -// Priority: WorkspaceChatID context key (set during delegation) > store.UserIDFromContext -func resolveWorkspaceScope(ctx context.Context) (channel, chatID string) { - chatID = WorkspaceChatIDFromCtx(ctx) - if chatID == "" { - chatID = store.UserIDFromContext(ctx) - } - return "", chatID -} - -// workspaceSettings parses workspace-related fields from team settings JSON once. -type workspaceSettings struct { - WorkspaceScope string `json:"workspace_scope"` - WorkspaceQuotaMB *int `json:"workspace_quota_mb"` - WorkspaceTemplates []workspaceTempl `json:"workspace_templates"` -} - -type workspaceTempl struct { - FileName string `json:"file_name"` - Content string `json:"content"` -} - -func parseWorkspaceSettings(raw json.RawMessage) workspaceSettings { - var ws workspaceSettings - if len(raw) > 0 { - _ = json.Unmarshal(raw, &ws) - } - return ws -} - -func (ws workspaceSettings) isShared() bool { - return ws.WorkspaceScope == "shared" -} - -func (ws workspaceSettings) quotaMB(defaultMB int) int { - if ws.WorkspaceQuotaMB != nil { - return *ws.WorkspaceQuotaMB - } - return defaultMB -} - -// resolveWorkspaceScopeFromArgs resolves scope (channel, chatID) from team settings + context. -// Scope is determined by team config (workspace_scope=shared), not by agent args. -// When shared is enabled, chatID is cleared so all members share the same directory. -func resolveWorkspaceScopeFromArgs(ctx context.Context, _ map[string]any, ws workspaceSettings) (channel, chatID, errMsg string) { - channel, chatID = resolveWorkspaceScope(ctx) - if ws.isShared() { - chatID = "" - } - return "", chatID, "" -} - -// validTags is the set of allowed tag values. -var validTags = map[string]bool{ - "deliverable": true, - "handoff": true, - "reference": true, - "draft": true, -} diff --git a/internal/tools/workspace_tool_read.go b/internal/tools/workspace_tool_read.go deleted file mode 100644 index b05e3ba4..00000000 --- a/internal/tools/workspace_tool_read.go +++ /dev/null @@ -1,236 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// WorkspaceReadTool allows agents to read, list, and delete files in the team shared workspace. -// [DB-DISABLED] pin, tag, history, comment, comments actions are temporarily disabled (require DB). -type WorkspaceReadTool struct { - manager *TeamToolManager - dataDir string -} - -func NewWorkspaceReadTool(manager *TeamToolManager, dataDir string) *WorkspaceReadTool { - return &WorkspaceReadTool{manager: manager, dataDir: dataDir} -} - -func (t *WorkspaceReadTool) Name() string { return "workspace_read" } - -func (t *WorkspaceReadTool) Description() string { - return "Read and manage files in the team shared workspace. Actions: list, read (default), delete." -} - -func (t *WorkspaceReadTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "description": "'list', 'read' (default), 'delete'", - }, - "file_name": map[string]any{ - "type": "string", - "description": "File name (required for read and delete)", - }, - // [DB-DISABLED] These parameters require DB-backed features: - // "pinned": — pin/unpin (lead only) - // "tags": — tag files (lead only) - // "version": — read specific version - // "text": — add comment - }, - } -} - -func (t *WorkspaceReadTool) Execute(ctx context.Context, args map[string]any) *Result { - team, agentID, err := t.manager.resolveTeam(ctx) - if err != nil { - return ErrorResult(err.Error()) - } - - role, err := t.manager.resolveTeamRole(ctx, team, agentID) - if err != nil { - return ErrorResult(err.Error()) - } - - ws := parseWorkspaceSettings(team.Settings) - - // Resolve scope. - channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws) - if scopeErr != "" { - return ErrorResult(scopeErr) - } - - action, _ := args["action"].(string) - if action == "" { - action = "read" - } - - switch action { - case "list": - return t.executeList(team, channel, chatID) - case "read": - return t.executeRead(args, team, channel, chatID) - case "delete": - return t.executeDelete(args, team, agentID, role, channel, chatID) - // [DB-DISABLED] These actions require DB: - // case "pin": - // return t.executePin(ctx, args, team, role, channel, chatID) - // case "tag": - // return t.executeTag(ctx, args, team, role, channel, chatID) - // case "history": - // return t.executeHistory(ctx, args, team, channel, chatID) - // case "comment": - // return t.executeComment(ctx, args, team, agentID, channel, chatID) - // case "comments": - // return t.executeComments(ctx, args, team, channel, chatID) - default: - return ErrorResult(fmt.Sprintf("unknown action %q (use 'list', 'read', or 'delete')", action)) - } -} - -func (t *WorkspaceReadTool) executeList(team *store.TeamData, channel, chatID string) *Result { - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - entries, err := os.ReadDir(dir) - if err != nil { - return NewResult("No workspace files in this scope.") - } - - var lines []string - var totalSize int64 - for _, entry := range entries { - if entry.IsDir() { - lines = append(lines, fmt.Sprintf("- %s/ (directory)", entry.Name())) - continue - } - info, err := entry.Info() - if err != nil { - continue - } - totalSize += info.Size() - mimeType, _ := inferMimeType(entry.Name()) - lines = append(lines, fmt.Sprintf("- %s (%s, %s)", entry.Name(), mimeType, formatBytes(info.Size()))) - } - - if len(lines) == 0 { - return NewResult("No workspace files in this scope.") - } - - const maxListFiles = 50 - header := fmt.Sprintf("Workspace path: %s\nWorkspace files (%d files, %s):\n", dir, len(lines), formatBytes(totalSize)) - footer := "\n\nTo read a file, use workspace_read(action=read, file_name=\"\") — do NOT use read_file for workspace files." - if len(lines) > maxListFiles { - result := header + strings.Join(lines[:maxListFiles], "\n") - result += fmt.Sprintf("\n\n[...truncated, showing %d of %d files. Use bash `ls %s` to see all files, or `find %s -name '*.ext'` to filter by type]", - maxListFiles, len(lines), dir, dir) - return NewResult(result + footer) - } - return NewResult(header + strings.Join(lines, "\n") + footer) -} - -func (t *WorkspaceReadTool) executeRead(args map[string]any, team *store.TeamData, channel, chatID string) *Result { - fileName, _ := args["file_name"].(string) - if fileName == "" { - return ErrorResult("file_name is required for action=read") - } - - // Sanitize: allow subdirectory paths but prevent traversal. - name, err := sanitizeFilePath(fileName) - if err != nil { - return ErrorResult(err.Error()) - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - diskPath := filepath.Join(dir, name) - info, err := os.Stat(diskPath) - if err != nil { - return ErrorResult(fmt.Sprintf("file %q not found in workspace — use workspace_read(action=list) to see available files", name)) - } - - mimeType, _ := inferMimeType(name) - - // Binary files: return metadata only. - if isBinaryMime(mimeType) { - return NewResult(fmt.Sprintf("Binary file: %s (%s, %s). Use other tools to process binary files.", - name, mimeType, formatBytes(info.Size()))) - } - - data, err := os.ReadFile(diskPath) - if err != nil { - return ErrorResult("failed to read file: " + err.Error()) - } - content := string(data) - if len(content) > 100000 { - content = content[:100000] + "\n\n[...truncated at 100K chars]" - } - - return NewResult(fmt.Sprintf("--- %s (%s, %s) ---\n%s", - name, mimeType, formatBytes(info.Size()), content)) -} - -func (t *WorkspaceReadTool) executeDelete(args map[string]any, team *store.TeamData, _ any, role, channel, chatID string) *Result { - fileName, _ := args["file_name"].(string) - if fileName == "" { - return ErrorResult("file_name is required for action=delete") - } - - if role == store.TeamRoleReviewer { - return ErrorResult("reviewers cannot delete workspace files") - } - - // Sanitize: allow subdirectory paths but prevent traversal. - name, err := sanitizeFilePath(fileName) - if err != nil { - return ErrorResult(err.Error()) - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - diskPath := filepath.Join(dir, name) - if _, err := os.Stat(diskPath); os.IsNotExist(err) { - return ErrorResult(fmt.Sprintf("file %q not found in workspace — use workspace_read(action=list) to see available files", name)) - } - - if err := os.Remove(diskPath); err != nil { - return ErrorResult("failed to delete file: " + err.Error()) - } - - // Broadcast event. - t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ - "team_id": team.ID.String(), - "channel": channel, - "chat_id": chatID, - "file_name": name, - "action": "delete", - }) - - return NewResult(fmt.Sprintf("Deleted workspace file %q", name)) -} - -// [DB-DISABLED] The following methods are temporarily disabled — they require the -// team_workspace_files DB table for metadata (pins, tags, versions, comments). -// They will be re-enabled when DB-backed workspace tracking is needed. -// -// func (t *WorkspaceReadTool) executePin(...) -// func (t *WorkspaceReadTool) executeTag(...) -// func (t *WorkspaceReadTool) executeHistory(...) -// func (t *WorkspaceReadTool) executeComment(...) -// func (t *WorkspaceReadTool) executeComments(...) diff --git a/internal/tools/workspace_tool_write.go b/internal/tools/workspace_tool_write.go deleted file mode 100644 index c14f5973..00000000 --- a/internal/tools/workspace_tool_write.go +++ /dev/null @@ -1,339 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -const ( - maxFileSizeBytes = 10 * 1024 * 1024 // 10MB - maxFilesPerScope = 100 - maxBatchSize = 20 - // maxVersionsPerFile = 5 // [DB-DISABLED] versioning requires DB -) - -// WorkspaceWriteTool allows agents to write files to the team shared workspace. -type WorkspaceWriteTool struct { - manager *TeamToolManager - dataDir string -} - -func NewWorkspaceWriteTool(manager *TeamToolManager, dataDir string) *WorkspaceWriteTool { - return &WorkspaceWriteTool{manager: manager, dataDir: dataDir} -} - -func (t *WorkspaceWriteTool) Name() string { return "workspace_write" } - -func (t *WorkspaceWriteTool) Description() string { - return "Write files to the team shared workspace (visible to all members). Supports batch write and template management (lead only)." -} - -func (t *WorkspaceWriteTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{ - "action": map[string]any{ - "type": "string", - "description": "'write' (default) or 'set_template' (lead only)", - }, - "file_name": map[string]any{ - "type": "string", - "description": "File name (alphanumeric + hyphens/underscores/dots, max 255 chars)", - }, - "content": map[string]any{ - "type": "string", - "description": "File content (text)", - }, - "files": map[string]any{ - "type": "array", - "description": "Batch write: array of {file_name, content} objects (max 20)", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "file_name": map[string]any{"type": "string"}, - "content": map[string]any{"type": "string"}, - }, - }, - }, - // [DB-DISABLED] task_id linkage requires DB - // "task_id": map[string]any{ - // "type": "string", - // "description": "Link file to a team task ID (optional)", - // }, - "templates": map[string]any{ - "type": "array", - "description": "For action=set_template: array of {file_name, content}", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "file_name": map[string]any{"type": "string"}, - "content": map[string]any{"type": "string"}, - }, - }, - }, - }, - } -} - -type writeFileEntry struct { - FileName string `json:"file_name"` - Content string `json:"content"` -} - -func (t *WorkspaceWriteTool) Execute(ctx context.Context, args map[string]any) *Result { - action, _ := args["action"].(string) - if action == "" { - action = "write" - } - - team, agentID, err := t.manager.resolveTeam(ctx) - if err != nil { - return ErrorResult(err.Error()) - } - - role, err := t.manager.resolveTeamRole(ctx, team, agentID) - if err != nil { - return ErrorResult(err.Error()) - } - - ws := parseWorkspaceSettings(team.Settings) - - switch action { - case "set_template": - return t.executeSetTemplate(ctx, args, team, role) - case "write": - return t.executeWrite(ctx, args, team, agentID, role, ws) - default: - return ErrorResult(fmt.Sprintf("unknown action %q (use 'write' or 'set_template')", action)) - } -} - -func (t *WorkspaceWriteTool) executeSetTemplate(ctx context.Context, args map[string]any, team *store.TeamData, role string) *Result { - if role != store.TeamRoleLead { - return ErrorResult("only the team lead can set workspace templates") - } - - // Check escalation policy. - if esc := t.manager.checkEscalation(team, "set_template"); esc != EscalationNone { - if esc == EscalationReject { - return ErrorResult("set_template action is not allowed by team escalation policy") - } - agentID := store.AgentIDFromContext(ctx) - return t.manager.createEscalationTask(ctx, team, agentID, - "Set workspace templates", - "Agent requested to update workspace templates.") - } - - templatesRaw, ok := args["templates"] - if !ok { - return ErrorResult("templates parameter is required for action=set_template") - } - templatesJSON, err := json.Marshal(templatesRaw) - if err != nil { - return ErrorResult("invalid templates format") - } - var templates []writeFileEntry - if err := json.Unmarshal(templatesJSON, &templates); err != nil { - return ErrorResult("templates must be array of {file_name, content}") - } - - // Validate template file names. - for _, tmpl := range templates { - if _, err := sanitizeFileName(tmpl.FileName); err != nil { - return ErrorResult(fmt.Sprintf("template %q: %s", tmpl.FileName, err)) - } - } - - // Update team settings with templates (stored in team settings JSON, not workspace DB). - var settings map[string]any - if team.Settings != nil { - _ = json.Unmarshal(team.Settings, &settings) - } - if settings == nil { - settings = make(map[string]any) - } - settings["workspace_templates"] = templates - settingsJSON, _ := json.Marshal(settings) - - if err := t.manager.teamStore.UpdateTeam(ctx, team.ID, map[string]any{"settings": settingsJSON}); err != nil { - return ErrorResult("failed to save templates: " + err.Error()) - } - t.manager.InvalidateTeam() - - return NewResult(fmt.Sprintf("Set %d workspace template(s)", len(templates))) -} - -func (t *WorkspaceWriteTool) executeWrite(ctx context.Context, args map[string]any, team *store.TeamData, _ /* agentID */ any, role string, ws workspaceSettings) *Result { - if role == store.TeamRoleReviewer { - return ErrorResult("reviewers cannot write to the workspace") - } - - // Resolve scope. - channel, chatID, scopeErr := resolveWorkspaceScopeFromArgs(ctx, args, ws) - if scopeErr != "" { - return ErrorResult(scopeErr) - } - - // [DB-DISABLED] task linkage requires DB - // var taskID *uuid.UUID - // if tid, ok := args["task_id"].(string); ok && tid != "" { - // parsed, err := uuid.Parse(tid) - // if err != nil { - // return ErrorResult("invalid task_id: " + err.Error()) - // } - // taskID = &parsed - // } else if ctxTID := TeamTaskIDFromCtx(ctx); ctxTID != "" { - // if parsed, err := uuid.Parse(ctxTID); err == nil { - // taskID = &parsed - // } - // } - - // Normalize input to batch. - var entries []writeFileEntry - if filesRaw, ok := args["files"]; ok { - filesJSON, err := json.Marshal(filesRaw) - if err != nil { - return ErrorResult("invalid files format") - } - if err := json.Unmarshal(filesJSON, &entries); err != nil { - return ErrorResult("files must be array of {file_name, content}") - } - } else { - fn, _ := args["file_name"].(string) - content, _ := args["content"].(string) - if fn == "" { - return ErrorResult("file_name is required") - } - entries = []writeFileEntry{{FileName: fn, Content: content}} - } - - if len(entries) == 0 { - return ErrorResult("no files to write") - } - if len(entries) > maxBatchSize { - return ErrorResult(fmt.Sprintf("batch size exceeds limit (%d max)", maxBatchSize)) - } - - // Validate all entries before writing. - for i, e := range entries { - name, err := sanitizeFileName(e.FileName) - if err != nil { - return ErrorResult(fmt.Sprintf("file %d: %s", i+1, err)) - } - entries[i].FileName = name - - if _, err := inferMimeType(name); err != nil { - return ErrorResult(fmt.Sprintf("file %q: %s", name, err)) - } - if len(e.Content) > maxFileSizeBytes { - return ErrorResult(fmt.Sprintf("file %q exceeds max size (10MB)", name)) - } - } - - // Create workspace directory. - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return ErrorResult(err.Error()) - } - - // Check file count limit from filesystem. - existingFiles, _ := os.ReadDir(dir) - fileCount := 0 - for _, f := range existingFiles { - if !f.IsDir() { - fileCount++ - } - } - - // Auto-seed templates on first write to this scope. - if fileCount == 0 { - t.seedTemplates(team, channel, chatID, ws) - // Recount after seeding. - existingFiles, _ = os.ReadDir(dir) - fileCount = 0 - for _, f := range existingFiles { - if !f.IsDir() { - fileCount++ - } - } - } - - if fileCount+len(entries) > maxFilesPerScope { - return ErrorResult(fmt.Sprintf("workspace file limit reached (%d/%d)", fileCount, maxFilesPerScope)) - } - - // Write files directly to disk (no DB). - var results []string - var errors []string - for _, e := range entries { - diskPath := filepath.Join(dir, e.FileName) - - if err := os.WriteFile(diskPath, []byte(e.Content), 0640); err != nil { - errors = append(errors, fmt.Sprintf("%s: %s", e.FileName, err)) - continue - } - - results = append(results, fmt.Sprintf("%s (%s)", e.FileName, formatBytes(int64(len(e.Content))))) - - // Broadcast event. - t.manager.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ - "team_id": team.ID.String(), - "channel": channel, - "chat_id": chatID, - "file_name": e.FileName, - "action": "write", - }) - } - - if len(results) == 0 && len(errors) > 0 { - return ErrorResult("all writes failed:\n" + strings.Join(errors, "\n")) - } - - msg := fmt.Sprintf("Written %d file(s) to workspace: %s", len(results), strings.Join(results, ", ")) - if len(errors) > 0 { - msg += fmt.Sprintf("\n%d failed: %s", len(errors), strings.Join(errors, "; ")) - } - return NewResult(msg) -} - -func (t *WorkspaceWriteTool) seedTemplates(team *store.TeamData, channel, chatID string, ws workspaceSettings) { - if len(ws.WorkspaceTemplates) == 0 { - return - } - - dir, err := workspaceDir(t.dataDir, team.ID, channel, chatID) - if err != nil { - return - } - - for _, tmpl := range ws.WorkspaceTemplates { - name, err := sanitizeFileName(tmpl.FileName) - if err != nil { - continue - } - diskPath := filepath.Join(dir, name) - if err := os.WriteFile(diskPath, []byte(tmpl.Content), 0640); err != nil { - slog.Warn("workspace: template seed failed", "file", name, "error", err) - } - } - slog.Info("workspace: seeded templates", "count", len(ws.WorkspaceTemplates), "team", team.ID, "channel", channel, "chat_id", chatID) -} - -func formatBytes(b int64) string { - switch { - case b >= 1024*1024: - return fmt.Sprintf("%.1f MB", float64(b)/(1024*1024)) - case b >= 1024: - return fmt.Sprintf("%.1f KB", float64(b)/1024) - default: - return fmt.Sprintf("%d B", b) - } -}