返回 DeepSeek-Reasonix
write_claims.go
根目录 / internal / agent / write_claims.go
1 package agent
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 )
10
11 // DefaultMaxSubagentConcurrency is the session-wide sub-agent concurrency
12 // default (task, fleet items, profile skills, nested children).
13 const DefaultMaxSubagentConcurrency = 6
14
15 // DefaultMaxParallelWriters is the default cap on concurrent writer-capable
16 // sub-agents that declare non-overlapping write_paths.
17 const DefaultMaxParallelWriters = 3
18
19 // MaxSubagentConcurrencyLimit is the upper bound for both concurrency knobs.
20 const MaxSubagentConcurrencyLimit = 32
21
22 // WritePathSet is a normalized claim over workspace paths a sub-agent may write.
23 // WholeWorkspace is true when a writer-capable task omitted write_paths and
24 // therefore claims the entire workspace (forcing writer serialization).
25 type WritePathSet struct {
26 // Paths are absolute, cleaned, and symlink-resolved when possible.
27 Paths []string
28 // WholeWorkspace claims the entire workspace root.
29 WholeWorkspace bool
30 // WorkspaceRoot is the absolute workspace root used for WholeWorkspace claims.
31 WorkspaceRoot string
32 }
33
34 // Empty reports whether the set claims nothing (read-only work).
35 func (s WritePathSet) Empty() bool {
36 return !s.WholeWorkspace && len(s.Paths) == 0
37 }
38
39 // NormalizeConcurrencyLimits clamps total/writer limits into the public range
40 // 1–32 and ensures writers never exceed total. Zero inputs become defaults so
41 // old configs stay at 6/3 without migration.
42 func NormalizeConcurrencyLimits(total, writers int) (int, int) {
43 if total <= 0 {
44 total = DefaultMaxSubagentConcurrency
45 }
46 if writers <= 0 {
47 writers = DefaultMaxParallelWriters
48 }
49 if total > MaxSubagentConcurrencyLimit {
50 total = MaxSubagentConcurrencyLimit
51 }
52 if writers > MaxSubagentConcurrencyLimit {
53 writers = MaxSubagentConcurrencyLimit
54 }
55 if writers > total {
56 writers = total
57 }
58 return total, writers
59 }
60
61 // NormalizeWritePaths validates and normalizes declared write_paths against a
62 // workspace root. It rejects globs, empty entries, workspace-escape paths, and
63 // symlink escapes. An empty raw list yields an empty set (read-only / no claim).
64 func NormalizeWritePaths(workspaceRoot string, raw []string) (WritePathSet, error) {
65 root, err := normalizeExistingRoot(workspaceRoot)
66 if err != nil {
67 return WritePathSet{}, err
68 }
69 if len(raw) == 0 {
70 return WritePathSet{}, nil
71 }
72 out := WritePathSet{WorkspaceRoot: root}
73 seen := map[string]bool{}
74 for i, entry := range raw {
75 entry = strings.TrimSpace(entry)
76 if entry == "" {
77 return WritePathSet{}, fmt.Errorf("write_paths[%d]: path is required", i)
78 }
79 if strings.ContainsAny(entry, "*?[") {
80 return WritePathSet{}, fmt.Errorf("write_paths[%d]: globs are not allowed (%q)", i, entry)
81 }
82 abs, err := resolveWriteClaimPath(root, entry)
83 if err != nil {
84 return WritePathSet{}, fmt.Errorf("write_paths[%d]: %w", i, err)
85 }
86 if !pathWithinFold(root, abs) {
87 return WritePathSet{}, fmt.Errorf("write_paths[%d]: path %q is outside the workspace", i, entry)
88 }
89 key := foldPathKey(abs)
90 if seen[key] {
91 continue
92 }
93 seen[key] = true
94 out.Paths = append(out.Paths, abs)
95 }
96 return out, nil
97 }
98
99 // WholeWorkspaceWriteClaim claims the entire workspace for a writer that did
100 // not declare write_paths. Such tasks may only run serially among writers.
101 func WholeWorkspaceWriteClaim(workspaceRoot string) (WritePathSet, error) {
102 root, err := normalizeExistingRoot(workspaceRoot)
103 if err != nil {
104 return WritePathSet{}, err
105 }
106 return WritePathSet{WholeWorkspace: true, WorkspaceRoot: root}, nil
107 }
108
109 // Overlaps reports whether two write claims conflict (identical, parent/child,
110 // or case-equivalent on case-insensitive filesystems).
111 func (s WritePathSet) Overlaps(other WritePathSet) bool {
112 if s.Empty() || other.Empty() {
113 return false
114 }
115 if s.WholeWorkspace || other.WholeWorkspace {
116 // Whole-workspace claims collide with every other writer claim that
117 // shares the same workspace root (or has an empty root).
118 if s.WorkspaceRoot == "" || other.WorkspaceRoot == "" {
119 return true
120 }
121 return pathWithinFold(s.WorkspaceRoot, other.WorkspaceRoot) ||
122 pathWithinFold(other.WorkspaceRoot, s.WorkspaceRoot)
123 }
124 for _, a := range s.Paths {
125 for _, b := range other.Paths {
126 if pathWithinFold(a, b) || pathWithinFold(b, a) {
127 return true
128 }
129 }
130 }
131 return false
132 }
133
134 // ValidateNonOverlappingWriteClaims fails if any pair of claims overlaps.
135 // Used by fleet preflight so no task starts when path division is invalid.
136 func ValidateNonOverlappingWriteClaims(claims []WritePathSet) error {
137 for i := 0; i < len(claims); i++ {
138 if claims[i].Empty() {
139 continue
140 }
141 for j := i + 1; j < len(claims); j++ {
142 if claims[j].Empty() {
143 continue
144 }
145 if claims[i].Overlaps(claims[j]) {
146 return fmt.Errorf("write path conflict between task %d and task %d", i+1, j+1)
147 }
148 }
149 }
150 return nil
151 }
152
153 // AllowsPath reports whether target is inside this claim (for re-bound writers).
154 func (s WritePathSet) AllowsPath(target string) bool {
155 if s.Empty() {
156 return false
157 }
158 abs, err := realPathForClaim(target)
159 if err != nil {
160 return false
161 }
162 if s.WholeWorkspace {
163 if s.WorkspaceRoot == "" {
164 return true
165 }
166 return pathWithinFold(s.WorkspaceRoot, abs)
167 }
168 for _, root := range s.Paths {
169 if pathWithinFold(root, abs) {
170 return true
171 }
172 }
173 return false
174 }
175
176 // Roots returns the concrete root list used to re-confine built-in writers and
177 // bash sandbox WriteRoots. Whole-workspace claims return the workspace root.
178 func (s WritePathSet) Roots() []string {
179 if s.WholeWorkspace {
180 if s.WorkspaceRoot == "" {
181 return nil
182 }
183 return []string{s.WorkspaceRoot}
184 }
185 return append([]string(nil), s.Paths...)
186 }
187
188 func normalizeExistingRoot(root string) (string, error) {
189 root = strings.TrimSpace(root)
190 if root == "" {
191 return "", fmt.Errorf("workspace root is required for write_paths")
192 }
193 abs, err := filepath.Abs(root)
194 if err != nil {
195 return "", fmt.Errorf("resolve workspace root: %w", err)
196 }
197 abs = filepath.Clean(abs)
198 real, err := filepath.EvalSymlinks(abs)
199 if err != nil {
200 // Workspace may not exist yet in some tests; keep cleaned abs.
201 return abs, nil
202 }
203 return real, nil
204 }
205
206 func resolveWriteClaimPath(workspaceRoot, raw string) (string, error) {
207 path := raw
208 if !filepath.IsAbs(path) {
209 path = filepath.Join(workspaceRoot, path)
210 }
211 return realPathForClaim(path)
212 }
213
214 // realPathForClaim mirrors the write-tool realPath helper: resolve the deepest
215 // existing ancestor so a not-yet-created file claim still cannot escape via a
216 // symlinked parent.
217 func realPathForClaim(path string) (string, error) {
218 abs, err := filepath.Abs(path)
219 if err != nil {
220 return "", err
221 }
222 abs = filepath.Clean(abs)
223 tail := ""
224 cur := abs
225 for {
226 if real, err := filepath.EvalSymlinks(cur); err == nil {
227 return filepath.Join(real, tail), nil
228 }
229 parent := filepath.Dir(cur)
230 if parent == cur {
231 return abs, nil
232 }
233 // Reject intermediate symlink escapes when parent exists as a symlink
234 // that leaves the tree — EvalSymlinks failed on cur but may succeed on
235 // parent; loop continues.
236 info, err := os.Lstat(cur)
237 if err == nil && info.Mode()&os.ModeSymlink != 0 {
238 // Symlink that does not resolve — treat as escape risk.
239 return "", fmt.Errorf("cannot resolve symlink path %q", path)
240 }
241 tail = filepath.Join(filepath.Base(cur), tail)
242 cur = parent
243 }
244 }
245
246 func pathWithinFold(root, path string) bool {
247 if root == "" || path == "" {
248 return false
249 }
250 if foldPaths() {
251 root = strings.ToLower(root)
252 path = strings.ToLower(path)
253 }
254 rel, err := filepath.Rel(root, path)
255 if err != nil {
256 return false
257 }
258 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
259 }
260
261 func foldPathKey(path string) string {
262 if foldPaths() {
263 return strings.ToLower(path)
264 }
265 return path
266 }
267
268 func foldPaths() bool {
269 return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
270 }
271
271 lines GO