mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
feat(workspace): leader dual workspace with auto-copy to team on delegate
- Revert leader workspace override: leader keeps personal workspace as default, team workspace accessible via ToolTeamWorkspaceFromCtx - Auto-copy: when leader creates team_tasks, scan subject+description for file paths → copy from personal to team workspace so members can access them - Safety: Lstat (reject symlinks), 10MB size cap, .env excluded from allowed extensions, path traversal blocked - Prompt hint: clarify members can only access team workspace files, referenced files are auto-copied
This commit is contained in:
1 parent
8193d10fe9
commit
36b43ed7f1
5 files changed
+264
-3
No files matched your search
@@ -167,9 +167,8 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
|
||||
slog.Warn("failed to create team workspace directory", "workspace", wsDir, "error", err)
|
||||
}
|
||||
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
|
||||
if team.LeadAgentID == l.agentUUID {
|
||||
ctx = tools.WithToolWorkspace(ctx, wsDir)
|
||||
}
|
||||
// Leader keeps personal workspace (set at line 110-132) as default.
|
||||
// Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation.
|
||||
if req.TeamID == "" {
|
||||
ctx = tools.WithToolTeamID(ctx, team.ID.String())
|
||||
}
|
||||
|
||||
@@ -390,7 +390,9 @@ func buildTeamWorkspaceSection(teamWsPath string) []string {
|
||||
fmt.Sprintf("- Use read_file(path=\"%s/filename.md\") to read team files", teamWsPath),
|
||||
fmt.Sprintf("- Use write_file(path=\"%s/filename.md\", content=\"...\") to write team files", teamWsPath),
|
||||
"- All files in the team workspace are visible to all team members",
|
||||
"- When you delegate tasks, members can ONLY access team workspace files",
|
||||
"- Your default workspace (for relative paths) is your personal workspace",
|
||||
"- Files referenced in task descriptions are auto-copied to team workspace",
|
||||
"- To delete a team file, use write_file with empty content",
|
||||
"",
|
||||
"## Auto-Status Updates",
|
||||
|
||||
@@ -234,6 +234,16 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
|
||||
return ErrorResult("failed to create task: " + err.Error())
|
||||
}
|
||||
|
||||
// Auto-copy files referenced in subject+description from leader's personal workspace
|
||||
// to team workspace so members can access them.
|
||||
if isLead {
|
||||
personalWs := ToolWorkspaceFromCtx(ctx)
|
||||
searchText := subject + "\n" + description
|
||||
if n := autoShareFiles(searchText, personalWs, teamWsDir); n > 0 {
|
||||
slog.Info("team_tasks.create: auto-shared files", "count", n, "task_id", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist media files copied during task creation as DB attachments,
|
||||
// so the UI and queries see them in team_task_attachments table.
|
||||
// (Members get auto-attached via WorkspaceInterceptor, but leaders
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxAutoShareFileSize caps individual file copies to prevent large file transfers.
|
||||
const maxAutoShareFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// knownFileExtensions lists extensions that are likely intentional file references.
|
||||
// Excludes .env (secrets risk) and binary formats.
|
||||
var knownFileExtensions = map[string]bool{
|
||||
".md": true, ".txt": true, ".json": true, ".csv": true, ".yaml": true, ".yml": true,
|
||||
".py": true, ".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
|
||||
".html": true, ".css": true, ".sql": true, ".xml": true, ".toml": true,
|
||||
".cfg": true, ".ini": true, ".log": true, ".pdf": true,
|
||||
}
|
||||
|
||||
// filePathPatterns matches file references in task descriptions.
|
||||
// Order matters — more specific patterns first.
|
||||
var filePathPatterns = []*regexp.Regexp{
|
||||
// path="file.md" or path='file.md' (tool call references)
|
||||
regexp.MustCompile(`path\s*=\s*["']([^"']+)["']`),
|
||||
// "file.md" or 'file.md' (quoted references)
|
||||
regexp.MustCompile(`["']([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)["']`),
|
||||
// [text](file.md) (markdown links — exclude URLs)
|
||||
regexp.MustCompile(`\]\(([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)\)`),
|
||||
// backtick references: `file.md`
|
||||
regexp.MustCompile("`([a-zA-Z0-9_./-]+\\.[a-zA-Z0-9]+)`"),
|
||||
}
|
||||
|
||||
// extractFilePaths finds file path references in text.
|
||||
// Returns deduplicated list of relative paths with known extensions.
|
||||
func extractFilePaths(text string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var paths []string
|
||||
|
||||
for _, pat := range filePathPatterns {
|
||||
for _, match := range pat.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
p := match[1]
|
||||
if !isValidFilePath(p) {
|
||||
continue
|
||||
}
|
||||
if !seen[p] {
|
||||
seen[p] = true
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// isValidFilePath checks if a string looks like an intentional file reference.
|
||||
func isValidFilePath(p string) bool {
|
||||
// Skip URLs
|
||||
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") || strings.HasPrefix(p, "ftp://") {
|
||||
return false
|
||||
}
|
||||
// Skip absolute paths (security)
|
||||
if filepath.IsAbs(p) {
|
||||
return false
|
||||
}
|
||||
// Skip path traversal
|
||||
if strings.Contains(p, "..") {
|
||||
return false
|
||||
}
|
||||
// Must have a known extension
|
||||
ext := strings.ToLower(filepath.Ext(p))
|
||||
return knownFileExtensions[ext]
|
||||
}
|
||||
|
||||
// autoShareFiles scans description for file paths, copies files from personal
|
||||
// workspace to team workspace if they exist in personal but not in team.
|
||||
// Fire-and-forget: logs errors, never fails the caller.
|
||||
func autoShareFiles(description, personalWs, teamWs string) int {
|
||||
if personalWs == "" || teamWs == "" || personalWs == teamWs {
|
||||
return 0
|
||||
}
|
||||
|
||||
paths := extractFilePaths(description)
|
||||
if len(paths) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
copied := 0
|
||||
for _, relPath := range paths {
|
||||
srcPath := filepath.Join(personalWs, relPath)
|
||||
dstPath := filepath.Join(teamWs, relPath)
|
||||
|
||||
// Check source exists in personal workspace (Lstat to reject symlinks).
|
||||
srcInfo, err := os.Lstat(srcPath)
|
||||
if err != nil || srcInfo.IsDir() || srcInfo.Mode()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
// Skip files exceeding size cap.
|
||||
if srcInfo.Size() > maxAutoShareFileSize {
|
||||
slog.Debug("auto_share: skipping large file", "file", relPath, "size", srcInfo.Size())
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if already in team workspace (same content assumed).
|
||||
if _, err := os.Stat(dstPath); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create parent dirs and copy.
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
slog.Warn("auto_share: mkdir failed", "dst", dstPath, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
slog.Warn("auto_share: copy failed", "src", srcPath, "dst", dstPath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
copied++
|
||||
slog.Info("auto_share: copied file to team workspace", "file", relPath, "src", srcPath, "dst", dstPath)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
// copyFile is defined in workspace_media.go — reused here.
|
||||
@@ -0,0 +1,121 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractFilePaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{"quoted_double", `see "report.md" for details`, []string{"report.md"}},
|
||||
{"quoted_single", `check 'docs/notes.txt' please`, []string{"docs/notes.txt"}},
|
||||
{"path_param", `path="docs/a.md"`, []string{"docs/a.md"}},
|
||||
{"markdown_link", `[report](analysis.md)`, []string{"analysis.md"}},
|
||||
{"backtick", "check `config.yaml` file", []string{"config.yaml"}},
|
||||
{"multiple", `see "a.md" and "b.txt"`, []string{"a.md", "b.txt"}},
|
||||
{"nested", `"docs/sub/report.md"`, []string{"docs/sub/report.md"}},
|
||||
{"skip_url", `see https://example.com/file.md`, nil},
|
||||
{"skip_abs", `see "/etc/passwd"`, nil},
|
||||
{"skip_traversal", `see "../secret.md"`, nil},
|
||||
{"skip_unknown_ext", `see "binary.exe"`, nil},
|
||||
{"no_matches", "do some research on AI", nil},
|
||||
{"dedup", `"report.md" and path="report.md"`, []string{"report.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractFilePaths(tt.text)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("extractFilePaths(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("extractFilePaths(%q)[%d] = %q, want %q", tt.text, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoShareFiles_CopiesFromPersonalToTeam(t *testing.T) {
|
||||
personal := t.TempDir()
|
||||
team := t.TempDir()
|
||||
|
||||
// Create file in personal workspace.
|
||||
os.MkdirAll(filepath.Join(personal, "docs"), 0755)
|
||||
os.WriteFile(filepath.Join(personal, "report.md"), []byte("# Report"), 0644)
|
||||
os.WriteFile(filepath.Join(personal, "docs", "notes.txt"), []byte("notes"), 0644)
|
||||
|
||||
n := autoShareFiles(`see "report.md" and "docs/notes.txt"`, personal, team)
|
||||
if n != 2 {
|
||||
t.Fatalf("autoShareFiles copied %d files, want 2", n)
|
||||
}
|
||||
|
||||
// Verify files exist in team workspace.
|
||||
if _, err := os.Stat(filepath.Join(team, "report.md")); err != nil {
|
||||
t.Error("report.md not copied to team workspace")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(team, "docs", "notes.txt")); err != nil {
|
||||
t.Error("docs/notes.txt not copied to team workspace")
|
||||
}
|
||||
|
||||
// Verify content.
|
||||
data, _ := os.ReadFile(filepath.Join(team, "report.md"))
|
||||
if string(data) != "# Report" {
|
||||
t.Errorf("copied content = %q, want %q", data, "# Report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoShareFiles_SkipAlreadyInTeam(t *testing.T) {
|
||||
personal := t.TempDir()
|
||||
team := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(personal, "report.md"), []byte("personal"), 0644)
|
||||
os.WriteFile(filepath.Join(team, "report.md"), []byte("team version"), 0644)
|
||||
|
||||
n := autoShareFiles(`see "report.md"`, personal, team)
|
||||
if n != 0 {
|
||||
t.Fatalf("autoShareFiles copied %d files, want 0 (already in team)", n)
|
||||
}
|
||||
|
||||
// Team file should be unchanged.
|
||||
data, _ := os.ReadFile(filepath.Join(team, "report.md"))
|
||||
if string(data) != "team version" {
|
||||
t.Errorf("team file was overwritten: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoShareFiles_SkipNonExistent(t *testing.T) {
|
||||
personal := t.TempDir()
|
||||
team := t.TempDir()
|
||||
|
||||
n := autoShareFiles(`see "nonexistent.md"`, personal, team)
|
||||
if n != 0 {
|
||||
t.Fatalf("autoShareFiles copied %d files, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoShareFiles_SameWorkspace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "a.md"), []byte("data"), 0644)
|
||||
|
||||
n := autoShareFiles(`see "a.md"`, dir, dir)
|
||||
if n != 0 {
|
||||
t.Fatalf("autoShareFiles should skip when personal==team, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoShareFiles_EmptyWorkspace(t *testing.T) {
|
||||
n := autoShareFiles(`see "a.md"`, "", "/tmp/team")
|
||||
if n != 0 {
|
||||
t.Fatalf("autoShareFiles should skip empty personal ws, got %d", n)
|
||||
}
|
||||
n = autoShareFiles(`see "a.md"`, "/tmp/personal", "")
|
||||
if n != 0 {
|
||||
t.Fatalf("autoShareFiles should skip empty team ws, got %d", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user