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:
viettranx committed 2026-03-16 20:05:26 +07:00
1 parent 0b5124a8f1
commit 27fb900510
10 files changed
+32 -805

No files matched your search

-6
View File
@@ -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
View File
@@ -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) {
+5 -4
View File
@@ -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)
}
+2 -4
View File
@@ -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.
+2 -2
View File
@@ -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",
},
}
-2
View File
@@ -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.
+5
View File
@@ -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)
+8 -209
View File
@@ -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,
}
-236
View File
@@ -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(...)
-339
View File
@@ -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)
}
}