| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // DefaultMaxSubagentConcurrency is the session-wide sub-agent concurrency |
| 13 | // default (task, fleet items, profile skills, nested children). |
| 14 | const DefaultMaxSubagentConcurrency = 6 |
| 15 | |
| 16 | // DefaultMaxParallelWriters is the default cap on concurrent writer-capable |
| 17 | // sub-agents that declare non-overlapping write_paths. |
| 18 | const DefaultMaxParallelWriters = 3 |
| 19 | |
| 20 | // MaxSubagentConcurrencyLimit is the upper bound for both concurrency knobs. |
| 21 | const MaxSubagentConcurrencyLimit = 32 |
| 22 | |
| 23 | // pathKind classifies one declared write_paths entry. Directory claims are |
| 24 | // capability prefixes (sandbox AllowsPath) but do not serialize against each |
| 25 | // other at schedule time; file claims do. |
| 26 | type pathKind uint8 |
| 27 | |
| 28 | const ( |
| 29 | pathKindFile pathKind = iota |
| 30 | pathKindDir |
| 31 | ) |
| 32 | |
| 33 | // WritePathSet is a normalized claim over workspace paths a sub-agent may write. |
| 34 | // WholeWorkspace is true when a writer-capable task omitted write_paths and |
| 35 | // therefore claims the entire workspace (forcing writer serialization). |
| 36 | type WritePathSet struct { |
| 37 | // Paths are absolute, cleaned, and symlink-resolved when possible. |
| 38 | Paths []string |
| 39 | // Kinds is parallel to Paths. Empty when WholeWorkspace or Paths is empty. |
| 40 | Kinds []pathKind |
| 41 | // WholeWorkspace claims the entire workspace root. |
| 42 | WholeWorkspace bool |
| 43 | // WorkspaceRoot is the absolute workspace root used for WholeWorkspace claims. |
| 44 | WorkspaceRoot string |
| 45 | } |
| 46 | |
| 47 | // Empty reports whether the set claims nothing (read-only work). |
| 48 | func (s WritePathSet) Empty() bool { |
| 49 | return !s.WholeWorkspace && len(s.Paths) == 0 |
| 50 | } |
| 51 | |
| 52 | // NormalizeConcurrencyLimits clamps total/writer limits into the public range |
| 53 | // 1–32 and ensures writers never exceed total. Zero inputs become defaults so |
| 54 | // old configs stay at 6/3 without migration. |
| 55 | func NormalizeConcurrencyLimits(total, writers int) (int, int) { |
| 56 | if total <= 0 { |
| 57 | total = DefaultMaxSubagentConcurrency |
| 58 | } |
| 59 | if writers <= 0 { |
| 60 | writers = DefaultMaxParallelWriters |
| 61 | } |
| 62 | if total > MaxSubagentConcurrencyLimit { |
| 63 | total = MaxSubagentConcurrencyLimit |
| 64 | } |
| 65 | if writers > MaxSubagentConcurrencyLimit { |
| 66 | writers = MaxSubagentConcurrencyLimit |
| 67 | } |
| 68 | if writers > total { |
| 69 | writers = total |
| 70 | } |
| 71 | return total, writers |
| 72 | } |
| 73 | |
| 74 | // NormalizeWritePaths validates and normalizes declared write_paths against a |
| 75 | // workspace root. It rejects globs, empty entries, workspace-escape paths, and |
| 76 | // symlink escapes. An empty raw list yields an empty set (read-only / no claim). |
| 77 | func NormalizeWritePaths(workspaceRoot string, raw []string) (WritePathSet, error) { |
| 78 | root, err := normalizeExistingRoot(workspaceRoot) |
| 79 | if err != nil { |
| 80 | return WritePathSet{}, err |
| 81 | } |
| 82 | if len(raw) == 0 { |
| 83 | return WritePathSet{}, nil |
| 84 | } |
| 85 | out := WritePathSet{WorkspaceRoot: root} |
| 86 | seen := map[string]bool{} |
| 87 | for i, entry := range raw { |
| 88 | entry = strings.TrimSpace(entry) |
| 89 | if entry == "" { |
| 90 | return WritePathSet{}, fmt.Errorf("write_paths[%d]: path is required", i) |
| 91 | } |
| 92 | if strings.ContainsAny(entry, "*?[") { |
| 93 | return WritePathSet{}, fmt.Errorf("write_paths[%d]: globs are not allowed (%q)", i, entry) |
| 94 | } |
| 95 | trailingSep := strings.HasSuffix(entry, "/") || strings.HasSuffix(entry, `\`) |
| 96 | trimmed := strings.TrimRight(entry, `/\`) |
| 97 | if trimmed == "" { |
| 98 | trimmed = entry |
| 99 | } |
| 100 | abs, err := resolveWriteClaimPath(root, trimmed) |
| 101 | if err != nil { |
| 102 | return WritePathSet{}, fmt.Errorf("write_paths[%d]: %w", i, err) |
| 103 | } |
| 104 | if !pathWithinFold(root, abs) { |
| 105 | return WritePathSet{}, fmt.Errorf("write_paths[%d]: path %q is outside the workspace", i, entry) |
| 106 | } |
| 107 | key := foldPathKey(abs) |
| 108 | if seen[key] { |
| 109 | continue |
| 110 | } |
| 111 | seen[key] = true |
| 112 | out.Paths = append(out.Paths, abs) |
| 113 | out.Kinds = append(out.Kinds, classifyWritePath(abs, trailingSep)) |
| 114 | } |
| 115 | return out, nil |
| 116 | } |
| 117 | |
| 118 | type subagentWriteClaimKey struct{} |
| 119 | type subagentClaimIDKey struct{} |
| 120 | |
| 121 | // WithSubagentWriteClaim carries a child's declared write claim into its run so |
| 122 | // the host can audit, after the fact, that every mutation it observed fell |
| 123 | // inside the claim the scheduler parallelized on. |
| 124 | func WithSubagentWriteClaim(ctx context.Context, claims WritePathSet) context.Context { |
| 125 | return context.WithValue(ctx, subagentWriteClaimKey{}, claims) |
| 126 | } |
| 127 | |
| 128 | // WithSubagentClaimID carries the scheduler live-claim id so path-bound tools |
| 129 | // can Realize and opaque writers can MarkOpaque. |
| 130 | func WithSubagentClaimID(ctx context.Context, id int64) context.Context { |
| 131 | if id == 0 { |
| 132 | return ctx |
| 133 | } |
| 134 | return context.WithValue(ctx, subagentClaimIDKey{}, id) |
| 135 | } |
| 136 | |
| 137 | // SubagentClaimID returns the live claim id of the running child, if any. |
| 138 | func SubagentClaimID(ctx context.Context) int64 { |
| 139 | id, _ := ctx.Value(subagentClaimIDKey{}).(int64) |
| 140 | return id |
| 141 | } |
| 142 | |
| 143 | // SubagentWriteClaim returns the write claim of the running child, if any. |
| 144 | func SubagentWriteClaim(ctx context.Context) WritePathSet { |
| 145 | claims, _ := ctx.Value(subagentWriteClaimKey{}).(WritePathSet) |
| 146 | return claims |
| 147 | } |
| 148 | |
| 149 | // WholeWorkspaceWriteClaim claims the entire workspace for a writer that did |
| 150 | // not declare write_paths. Such tasks may only run serially among writers. |
| 151 | func WholeWorkspaceWriteClaim(workspaceRoot string) (WritePathSet, error) { |
| 152 | root, err := normalizeExistingRoot(workspaceRoot) |
| 153 | if err != nil { |
| 154 | return WritePathSet{}, err |
| 155 | } |
| 156 | return WritePathSet{WholeWorkspace: true, WorkspaceRoot: root}, nil |
| 157 | } |
| 158 | |
| 159 | // ScheduleOverlaps reports whether two claims must not start at the same time. |
| 160 | // Capability Overlaps stays stricter: identical directory claims still overlap |
| 161 | // for sandbox/AllowsPath. Directory-vs-directory claims (including identity) |
| 162 | // may run in parallel; a directory still blocks a concrete file inside it. |
| 163 | func ScheduleOverlaps(a, b WritePathSet) bool { |
| 164 | if a.Empty() || b.Empty() { |
| 165 | return false |
| 166 | } |
| 167 | if a.WholeWorkspace || b.WholeWorkspace { |
| 168 | return a.Overlaps(b) |
| 169 | } |
| 170 | for i, pa := range a.Paths { |
| 171 | for j, pb := range b.Paths { |
| 172 | if !pathWithinFold(pa, pb) && !pathWithinFold(pb, pa) { |
| 173 | continue |
| 174 | } |
| 175 | if a.kindAt(i) == pathKindDir && b.kindAt(j) == pathKindDir { |
| 176 | continue |
| 177 | } |
| 178 | return true |
| 179 | } |
| 180 | } |
| 181 | return false |
| 182 | } |
| 183 | |
| 184 | func (s WritePathSet) kindAt(i int) pathKind { |
| 185 | if i >= 0 && i < len(s.Kinds) { |
| 186 | return s.Kinds[i] |
| 187 | } |
| 188 | return pathKindFile |
| 189 | } |
| 190 | |
| 191 | func classifyWritePath(abs string, trailingSep bool) pathKind { |
| 192 | if trailingSep { |
| 193 | return pathKindDir |
| 194 | } |
| 195 | info, err := os.Stat(abs) |
| 196 | if err == nil && info.IsDir() { |
| 197 | return pathKindDir |
| 198 | } |
| 199 | return pathKindFile |
| 200 | } |
| 201 | |
| 202 | // Overlaps reports whether two write claims conflict (identical, parent/child, |
| 203 | // or case-equivalent on case-insensitive filesystems). |
| 204 | func (s WritePathSet) Overlaps(other WritePathSet) bool { |
| 205 | if s.Empty() || other.Empty() { |
| 206 | return false |
| 207 | } |
| 208 | if s.WholeWorkspace || other.WholeWorkspace { |
| 209 | // Whole-workspace claims collide with every other writer claim that |
| 210 | // shares the same workspace root (or has an empty root). |
| 211 | if s.WorkspaceRoot == "" || other.WorkspaceRoot == "" { |
| 212 | return true |
| 213 | } |
| 214 | return pathWithinFold(s.WorkspaceRoot, other.WorkspaceRoot) || |
| 215 | pathWithinFold(other.WorkspaceRoot, s.WorkspaceRoot) |
| 216 | } |
| 217 | for _, a := range s.Paths { |
| 218 | for _, b := range other.Paths { |
| 219 | if pathWithinFold(a, b) || pathWithinFold(b, a) { |
| 220 | return true |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | return false |
| 225 | } |
| 226 | |
| 227 | // ValidateNonOverlappingWriteClaims fails if any pair of claims overlaps. |
| 228 | // Used by fleet preflight so no task starts when path division is invalid. |
| 229 | func ValidateNonOverlappingWriteClaims(claims []WritePathSet) error { |
| 230 | for i := range claims { |
| 231 | if claims[i].Empty() { |
| 232 | continue |
| 233 | } |
| 234 | for j := i + 1; j < len(claims); j++ { |
| 235 | if claims[j].Empty() { |
| 236 | continue |
| 237 | } |
| 238 | if claims[i].Overlaps(claims[j]) { |
| 239 | return fmt.Errorf("write path conflict between task %d and task %d", i+1, j+1) |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | return nil |
| 244 | } |
| 245 | |
| 246 | // AllowsPath reports whether target is inside this claim (for re-bound writers). |
| 247 | func (s WritePathSet) AllowsPath(target string) bool { |
| 248 | if s.Empty() { |
| 249 | return false |
| 250 | } |
| 251 | abs, err := realPathForClaim(target) |
| 252 | if err != nil { |
| 253 | return false |
| 254 | } |
| 255 | if s.WholeWorkspace { |
| 256 | if s.WorkspaceRoot == "" { |
| 257 | return true |
| 258 | } |
| 259 | return pathWithinFold(s.WorkspaceRoot, abs) |
| 260 | } |
| 261 | for _, root := range s.Paths { |
| 262 | if pathWithinFold(root, abs) { |
| 263 | return true |
| 264 | } |
| 265 | } |
| 266 | return false |
| 267 | } |
| 268 | |
| 269 | // Roots returns the concrete root list used to re-confine built-in writers and |
| 270 | // bash sandbox WriteRoots. Whole-workspace claims return the workspace root. |
| 271 | func (s WritePathSet) Roots() []string { |
| 272 | if s.WholeWorkspace { |
| 273 | if s.WorkspaceRoot == "" { |
| 274 | return nil |
| 275 | } |
| 276 | return []string{s.WorkspaceRoot} |
| 277 | } |
| 278 | return append([]string(nil), s.Paths...) |
| 279 | } |
| 280 | |
| 281 | func normalizeExistingRoot(root string) (string, error) { |
| 282 | root = strings.TrimSpace(root) |
| 283 | if root == "" { |
| 284 | return "", fmt.Errorf("workspace root is required for write_paths") |
| 285 | } |
| 286 | abs, err := filepath.Abs(root) |
| 287 | if err != nil { |
| 288 | return "", fmt.Errorf("resolve workspace root: %w", err) |
| 289 | } |
| 290 | abs = filepath.Clean(abs) |
| 291 | real, err := filepath.EvalSymlinks(abs) |
| 292 | if err != nil { |
| 293 | // Workspace may not exist yet in some tests; keep cleaned abs. |
| 294 | return abs, nil |
| 295 | } |
| 296 | return real, nil |
| 297 | } |
| 298 | |
| 299 | func resolveWriteClaimPath(workspaceRoot, raw string) (string, error) { |
| 300 | path := raw |
| 301 | if !filepath.IsAbs(path) { |
| 302 | path = filepath.Join(workspaceRoot, path) |
| 303 | } |
| 304 | return realPathForClaim(path) |
| 305 | } |
| 306 | |
| 307 | // realPathForClaim mirrors the write-tool realPath helper: resolve the deepest |
| 308 | // existing ancestor so a not-yet-created file claim still cannot escape via a |
| 309 | // symlinked parent. |
| 310 | func realPathForClaim(path string) (string, error) { |
| 311 | abs, err := filepath.Abs(path) |
| 312 | if err != nil { |
| 313 | return "", err |
| 314 | } |
| 315 | abs = filepath.Clean(abs) |
| 316 | tail := "" |
| 317 | cur := abs |
| 318 | for { |
| 319 | if real, err := filepath.EvalSymlinks(cur); err == nil { |
| 320 | return filepath.Join(real, tail), nil |
| 321 | } |
| 322 | parent := filepath.Dir(cur) |
| 323 | if parent == cur { |
| 324 | return abs, nil |
| 325 | } |
| 326 | // Reject intermediate symlink escapes when parent exists as a symlink |
| 327 | // that leaves the tree — EvalSymlinks failed on cur but may succeed on |
| 328 | // parent; loop continues. |
| 329 | info, err := os.Lstat(cur) |
| 330 | if err == nil && info.Mode()&os.ModeSymlink != 0 { |
| 331 | // Symlink that does not resolve — treat as escape risk. |
| 332 | return "", fmt.Errorf("cannot resolve symlink path %q", path) |
| 333 | } |
| 334 | tail = filepath.Join(filepath.Base(cur), tail) |
| 335 | cur = parent |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func pathWithinFold(root, path string) bool { |
| 340 | if root == "" || path == "" { |
| 341 | return false |
| 342 | } |
| 343 | if foldPaths() { |
| 344 | root = strings.ToLower(root) |
| 345 | path = strings.ToLower(path) |
| 346 | } |
| 347 | rel, err := filepath.Rel(root, path) |
| 348 | if err != nil { |
| 349 | return false |
| 350 | } |
| 351 | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) |
| 352 | } |
| 353 | |
| 354 | func foldPathKey(path string) string { |
| 355 | if foldPaths() { |
| 356 | return strings.ToLower(path) |
| 357 | } |
| 358 | return path |
| 359 | } |
| 360 | |
| 361 | func foldPaths() bool { |
| 362 | return runtime.GOOS == "windows" || runtime.GOOS == "darwin" |
| 363 | } |
| 364 |