diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 3ddcce6b..6706b630 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -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()) } diff --git a/internal/agent/systemprompt_sections.go b/internal/agent/systemprompt_sections.go index 33f51604..cf5c5d06 100644 --- a/internal/agent/systemprompt_sections.go +++ b/internal/agent/systemprompt_sections.go @@ -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", diff --git a/internal/tools/team_tasks_create.go b/internal/tools/team_tasks_create.go index 405c7b6c..f7b9965f 100644 --- a/internal/tools/team_tasks_create.go +++ b/internal/tools/team_tasks_create.go @@ -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 diff --git a/internal/tools/team_workspace_auto_share.go b/internal/tools/team_workspace_auto_share.go new file mode 100644 index 00000000..6e704ded --- /dev/null +++ b/internal/tools/team_workspace_auto_share.go @@ -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. diff --git a/internal/tools/team_workspace_auto_share_test.go b/internal/tools/team_workspace_auto_share_test.go new file mode 100644 index 00000000..768f90d7 --- /dev/null +++ b/internal/tools/team_workspace_auto_share_test.go @@ -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) + } +}