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:
viettranx committed 2026-03-31 12:10:55 +07:00
1 parent 8193d10fe9
commit 36b43ed7f1
5 files changed
+264 -3

No files matched your search

+2 -3
View File
@@ -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())
}
+2
View File
@@ -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",
+10
View File
@@ -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
+129
View File
@@ -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)
}
}