mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 08:17:09 +00:00
refactor(tools): remove workspace_read/workspace_write, use file tools for team workspace
Remove dedicated workspace tools in favor of making existing file tools (read_file, write_file, list_files, edit) team-workspace-aware. - Delete workspace_tool_read.go and workspace_tool_write.go - Clean up workspace_dir.go: export WorkspaceDir, remove dead code (workspaceRelPath, sanitizeFilePath, inferMimeType, templates, etc.) - Remove workspace tool registration from gateway_managed.go - Remove workspace tool references from policy, subagent, MCP bridge - Add PathAllowable/PathDenyable to types.go for interface abstraction
This commit is contained in:
1 parent
0b5124a8f1
commit
27fb900510
10 files changed
+32
-805
No files matched your search
@@ -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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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=\"<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(...)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user