mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
494 lines
19 KiB
Go
494 lines
19 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// localDirectoryResourceType is the project_resource discriminator the daemon
|
|
// looks for when deciding whether a task should run against an existing
|
|
// user directory rather than a fresh git worktree. Mirrors the server-side
|
|
// constant — keep in sync if the type string is ever renamed.
|
|
const localDirectoryResourceType = "local_directory"
|
|
|
|
// localDirectoryRef mirrors the server-side ref shape for local_directory
|
|
// project resources. Defined locally so the daemon does not have to import
|
|
// the server handler package.
|
|
type localDirectoryRef struct {
|
|
LocalPath string `json:"local_path"`
|
|
DaemonID string `json:"daemon_id"`
|
|
Label string `json:"label,omitempty"`
|
|
}
|
|
|
|
// localDirectoryAssignment is the resolved view of a task's local_directory
|
|
// resource: the absolute path the daemon will use as the agent's workdir,
|
|
// plus the underlying ref for callers that still need the raw label / daemon
|
|
// id (validation log messages, mostly). RealPath is the symlink-resolved
|
|
// absolute path; the path mutex keys on it so two different routes to the
|
|
// same directory are serialised.
|
|
type localDirectoryAssignment struct {
|
|
Ref localDirectoryRef
|
|
AbsPath string // user-provided path, cleaned but not symlink-resolved
|
|
RealPath string // canonical key for the path mutex
|
|
}
|
|
|
|
// localDirectoryAssignmentForTask returns the local_directory assignment a task
|
|
// should execute inside. Squad-leader tasks are coordinators: they may create
|
|
// child issues or comments, but should not bind to the user's repo worktree or
|
|
// hold the path mutex while downstream workers are ready to write.
|
|
func localDirectoryAssignmentForTask(task Task, daemonID string) (*localDirectoryAssignment, error) {
|
|
if task.IsLeaderTask {
|
|
return nil, nil
|
|
}
|
|
return findLocalDirectoryAssignment(task.ProjectResources, daemonID)
|
|
}
|
|
|
|
// findLocalDirectoryAssignment scans the task's project resources for one of
|
|
// type local_directory whose daemon_id matches this daemon. Returns nil
|
|
// (without error) when no such resource exists — the task takes the regular
|
|
// github_repo / worktree code path. Returns an error only when the matching
|
|
// resource is structurally broken (bad JSON, missing fields) OR when more
|
|
// than one resource is pinned to this daemon — that's a server-side
|
|
// invariant violation, and silently picking the first match would let the
|
|
// agent write into an arbitrary directory the user didn't intend.
|
|
//
|
|
// Server-side `findLocalDirectoryConflict` enforces a single local_directory
|
|
// per (project, daemon), so two matches here means either the constraint
|
|
// was bypassed (older API client) or the data was corrupted. Either way,
|
|
// fail fast rather than guess.
|
|
func findLocalDirectoryAssignment(resources []ProjectResourceData, daemonID string) (*localDirectoryAssignment, error) {
|
|
var match *localDirectoryAssignment
|
|
for _, r := range resources {
|
|
if r.ResourceType != localDirectoryResourceType {
|
|
continue
|
|
}
|
|
var ref localDirectoryRef
|
|
if err := json.Unmarshal(r.ResourceRef, &ref); err != nil {
|
|
return nil, fmt.Errorf("local_directory: parse resource_ref: %w", err)
|
|
}
|
|
ref.DaemonID = strings.TrimSpace(ref.DaemonID)
|
|
if ref.DaemonID == "" {
|
|
return nil, errors.New("local_directory: resource_ref missing daemon_id")
|
|
}
|
|
if ref.DaemonID != daemonID {
|
|
// A different daemon owns this resource. Skip silently; the
|
|
// project may have multiple local_directory resources, one
|
|
// per daemon, and other daemons will resolve their own row.
|
|
continue
|
|
}
|
|
if match != nil {
|
|
// Server-side invariant: at most one local_directory per
|
|
// (project, daemon). Two matches here means the constraint
|
|
// was bypassed by an older API client or by direct DB writes.
|
|
// Either way, refuse to guess which directory the user meant.
|
|
return nil, fmt.Errorf(
|
|
"local_directory: project has multiple local_directory resources for this daemon (%q and %q); remove the extra in project settings",
|
|
match.AbsPath,
|
|
strings.TrimSpace(ref.LocalPath),
|
|
)
|
|
}
|
|
absPath, err := normalizeLocalPath(ref.LocalPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
realPath, err := resolveRealPath(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
match = &localDirectoryAssignment{
|
|
Ref: ref,
|
|
AbsPath: absPath,
|
|
RealPath: realPath,
|
|
}
|
|
}
|
|
return match, nil
|
|
}
|
|
|
|
// normalizeLocalPath strips whitespace and resolves the path to an absolute
|
|
// cleaned form. It does NOT touch the filesystem (no symlink resolution, no
|
|
// existence check) — callers do that separately via validateLocalPath.
|
|
func normalizeLocalPath(p string) (string, error) {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed == "" {
|
|
return "", errors.New("local_directory: local_path is empty")
|
|
}
|
|
if !filepath.IsAbs(trimmed) {
|
|
return "", fmt.Errorf("local_directory: local_path must be absolute, got %q", trimmed)
|
|
}
|
|
return filepath.Clean(trimmed), nil
|
|
}
|
|
|
|
// resolveRealPath returns the symlink-resolved absolute form of path. The
|
|
// path mutex keys on this value so a task on `/Users/u/proj` and another on
|
|
// `/private/var/folders/.../proj-symlink → /Users/u/proj` collapse to one
|
|
// lock. When EvalSymlinks fails (path is missing or not yet a real link),
|
|
// fall back to the cleaned absolute form so callers can still proceed to
|
|
// the existence-check stage which surfaces a clearer error.
|
|
func resolveRealPath(absPath string) (string, error) {
|
|
real, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
// validateLocalPath will surface the underlying error with better
|
|
// context; for the mutex key the cleaned absolute path is a safe
|
|
// fallback (it just slightly weakens the dedup on broken symlinks).
|
|
return absPath, nil
|
|
}
|
|
return real, nil
|
|
}
|
|
|
|
// validateLocalPath enforces the daemon-side preconditions for running an
|
|
// agent against a user-supplied directory:
|
|
//
|
|
// - the path is absolute and not in the system blacklist (root, $HOME,
|
|
// /Users, /home, the current user's $HOME — picking one of those would
|
|
// scope the agent to the entire account, which is never what the user
|
|
// intended);
|
|
// - the symlink-resolved target is ALSO not in the blacklist — without
|
|
// this a symlink like /Users/me/proj/home -> /Users/me would slip the
|
|
// literal-equality check above while still routing every daemon write
|
|
// into $HOME;
|
|
// - the path exists, is a directory (not a regular file or device);
|
|
// - the daemon process can read and write inside it (the agent will need
|
|
// both — read for context discovery, write for the issue's edits).
|
|
//
|
|
// Each failure returns a typed error message so the daemon can forward it
|
|
// onto the task's fail comment verbatim.
|
|
func validateLocalPath(absPath string) error {
|
|
if absPath == "" {
|
|
return errors.New("local_directory: local_path is empty")
|
|
}
|
|
if !filepath.IsAbs(absPath) {
|
|
return fmt.Errorf("local_directory: local_path must be absolute, got %q", absPath)
|
|
}
|
|
if reason, blocked := isBlacklistedLocalPath(absPath); blocked {
|
|
return fmt.Errorf("local_directory: %s (%q)", reason, absPath)
|
|
}
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("local_directory: path does not exist: %q", absPath)
|
|
}
|
|
return fmt.Errorf("local_directory: stat %q: %w", absPath, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("local_directory: path is not a directory: %q", absPath)
|
|
}
|
|
// Re-check the blacklist after resolving symlinks. Two ways the
|
|
// literal check can be bypassed even when absPath itself is clean:
|
|
//
|
|
// 1. A user-created symlink (or a parent component) routes writes
|
|
// into a banned target. Example: ~/proj/home-link -> /Users/me.
|
|
// 2. The user directly selects a canonical OS path that aliases a
|
|
// banned root via an OS-level symlink. Example on macOS: typing
|
|
// /private/tmp slips past the /tmp entry because the literal
|
|
// strings don't match, and EvalSymlinks is a no-op since the
|
|
// input is already canonical. This must be checked
|
|
// unconditionally — not gated on realPath != absPath — or the
|
|
// direct-canonical case is silently allowed.
|
|
//
|
|
// EvalSymlinks walks intermediate components too, so a non-symlink
|
|
// absPath whose parent is a symlink also fails closed.
|
|
realPath, err := filepath.EvalSymlinks(absPath)
|
|
if err != nil {
|
|
return fmt.Errorf("local_directory: resolve symlinks for %q: %w", absPath, err)
|
|
}
|
|
realPath = filepath.Clean(realPath)
|
|
if reason, blocked := isBlacklistedRealPath(realPath); blocked {
|
|
if realPath != filepath.Clean(absPath) {
|
|
return fmt.Errorf("local_directory: %s (symlink target of %q is %q)", reason, absPath, realPath)
|
|
}
|
|
return fmt.Errorf("local_directory: %s (canonical path %q)", reason, absPath)
|
|
}
|
|
if err := checkDirReadWrite(absPath); err != nil {
|
|
return fmt.Errorf("local_directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isBlacklistedLocalPath rejects paths that map to the whole machine or an
|
|
// entire user profile. The intent is to keep the daemon from accidentally
|
|
// stamping context files (.agent_context/, .claude/skills/, .multica/) at
|
|
// the root of a user's account or the OS — a misconfiguration on the UI
|
|
// side should fail fast rather than litter the user's home.
|
|
//
|
|
// The check is by literal equality after Clean(), not prefix containment:
|
|
// a legitimate project under /Users/<user>/code/proj should pass.
|
|
func isBlacklistedLocalPath(absPath string) (reason string, blocked bool) {
|
|
cleaned := filepath.Clean(absPath)
|
|
if isDriveRoot(cleaned) {
|
|
return fmt.Sprintf("path is a drive root %q", cleaned), true
|
|
}
|
|
for _, banned := range systemRootBlacklist() {
|
|
if cleaned == banned {
|
|
return fmt.Sprintf("path is a protected system root %q", banned), true
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if cleaned == filepath.Clean(home) {
|
|
return "path is the user's home directory", true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isBlacklistedRealPath is the canonical-aware variant of
|
|
// isBlacklistedLocalPath. It compares the symlink-resolved realPath against
|
|
// the symlink-resolved form of each blacklist entry so OS-level redirects
|
|
// (notably macOS's /etc -> /private/etc, /tmp -> /private/tmp, /var ->
|
|
// /private/var) cannot be used to slip a candidate past the literal
|
|
// blacklist — whether the redirect is reached via a user-created symlink
|
|
// (~/proj/home-link -> /Users/me) or by directly typing the canonical form
|
|
// (/private/tmp), which is identical to the OS view of /tmp.
|
|
func isBlacklistedRealPath(realPath string) (reason string, blocked bool) {
|
|
realClean := filepath.Clean(realPath)
|
|
if isDriveRoot(realClean) {
|
|
return fmt.Sprintf("path is a drive root %q", realClean), true
|
|
}
|
|
for _, banned := range systemRootBlacklist() {
|
|
bannedClean := filepath.Clean(banned)
|
|
if realClean == bannedClean {
|
|
return fmt.Sprintf("path is a protected system root %q", banned), true
|
|
}
|
|
if r, err := filepath.EvalSymlinks(banned); err == nil {
|
|
if filepath.Clean(r) == realClean {
|
|
return fmt.Sprintf("path is a protected system root %q", banned), true
|
|
}
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
homeClean := filepath.Clean(home)
|
|
if realClean == homeClean {
|
|
return "path is the user's home directory", true
|
|
}
|
|
if r, err := filepath.EvalSymlinks(home); err == nil {
|
|
if filepath.Clean(r) == realClean {
|
|
return "path is the user's home directory", true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isDriveRoot reports whether absPath is the root of a Windows volume — any
|
|
// of `C:\`, `D:\`, ..., `Z:\`, plus less common cases like `\\server\share`
|
|
// (filepath.VolumeName treats UNC roots as volumes too). On non-Windows
|
|
// this is always false because POSIX has no concept of drive letters and
|
|
// `/` is covered by systemRootBlacklist.
|
|
//
|
|
// We rely on filepath.VolumeName rather than enumerating drive letters
|
|
// statically: removable / network drives can be mounted at any letter
|
|
// (`G:\`, `H:\`, ...), and Windows installs are increasingly happy to put
|
|
// the user profile on a non-C drive. A static list (C..F) would miss them
|
|
// all.
|
|
func isDriveRoot(absPath string) bool {
|
|
if runtime.GOOS != "windows" {
|
|
return false
|
|
}
|
|
vol := filepath.VolumeName(absPath)
|
|
if vol == "" {
|
|
return false
|
|
}
|
|
// VolumeName returns the volume without trailing separator (`C:` or
|
|
// `\\srv\share`). A drive root is volume + one separator (or, after
|
|
// filepath.Clean, just the volume on bare-volume input).
|
|
rest := absPath[len(vol):]
|
|
return rest == "" || rest == `\` || rest == "/"
|
|
}
|
|
|
|
// systemRootBlacklist returns the per-OS list of paths the daemon never
|
|
// allows as a local_directory root. POSIX systems get `/`, `/Users`, `/home`
|
|
// (and macOS's `/Users/Shared` for good measure); Windows gets the
|
|
// well-known account / shared trees under C:. Drive roots themselves are
|
|
// handled by isDriveRoot so we don't have to enumerate G:\, H:\, etc.
|
|
// The list is intentionally conservative — it errs on the side of
|
|
// rejecting more, since the desktop UI is expected to surface a friendly
|
|
// picker that never produces these values.
|
|
func systemRootBlacklist() []string {
|
|
if runtime.GOOS == "windows" {
|
|
return []string{`C:\Users`, `C:\ProgramData`, `C:\Program Files`, `C:\Program Files (x86)`, `C:\Windows`}
|
|
}
|
|
return []string{"/", "/Users", "/Users/Shared", "/home", "/root", "/var", "/etc", "/tmp", "/usr", "/opt"}
|
|
}
|
|
|
|
// checkDirReadWrite verifies the daemon process can both read directory
|
|
// contents and create/remove a probe file inside dir. The probe filename is
|
|
// long, hidden, and unlikely to clash with user files; we delete it
|
|
// immediately and ignore the delete error (best-effort cleanup is fine —
|
|
// the worst case is leaving a 0-byte file the user can ignore).
|
|
func checkDirReadWrite(dir string) error {
|
|
if _, err := os.ReadDir(dir); err != nil {
|
|
return fmt.Errorf("read %q: %w", dir, err)
|
|
}
|
|
probe, err := os.CreateTemp(dir, ".multica-rwcheck-*")
|
|
if err != nil {
|
|
return fmt.Errorf("write %q: %w", dir, err)
|
|
}
|
|
probePath := probe.Name()
|
|
_ = probe.Close()
|
|
_ = os.Remove(probePath)
|
|
return nil
|
|
}
|
|
|
|
// isGitWorkTree reports whether path is the working tree of a git repo. The
|
|
// daemon uses this to skip branch / worktree machinery when the user has
|
|
// already pointed the project at their own clone — the agent operates on
|
|
// the current branch in place. Returns false on any error (git not on PATH,
|
|
// path not in a repo, exec failure) so the caller can treat "not a git
|
|
// tree" and "can't tell" the same way: skip the git-specific path.
|
|
func isGitWorkTree(ctx context.Context, path string) bool {
|
|
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--is-inside-work-tree")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(string(out)) == "true"
|
|
}
|
|
|
|
// LocalPathLocker serialises agent tasks that share the same on-disk path.
|
|
// The lock is owned for the entire lifetime of a task (claim → context
|
|
// write → agent execution → result report), not just the agent execution
|
|
// window, because the context files and skill scratch directories the
|
|
// daemon writes at task-prepare time can race with a sibling task on the
|
|
// same path.
|
|
//
|
|
// Implementation: per-key sync.Mutex inside a map guarded by mu. When a
|
|
// task can't take the lock immediately, the waiter blocks on the per-key
|
|
// Mutex itself — that gives FIFO-ish behaviour from the Go scheduler
|
|
// (sufficient for our load; the issue body asks for a wait queue, not a
|
|
// strict-priority queue). Holder bookkeeping (current holder task id) is
|
|
// surfaced via Holder so callers can build a UI-friendly wait_reason.
|
|
type LocalPathLocker struct {
|
|
mu sync.Mutex
|
|
locks map[string]*pathLockEntry
|
|
}
|
|
|
|
type pathLockEntry struct {
|
|
mu sync.Mutex // serialises holders for this key
|
|
mu2 sync.Mutex // guards holderID under contention
|
|
holderID string // current owner, for UI hints; empty when free
|
|
}
|
|
|
|
// NewLocalPathLocker returns an empty locker. Safe for concurrent use.
|
|
func NewLocalPathLocker() *LocalPathLocker {
|
|
return &LocalPathLocker{locks: make(map[string]*pathLockEntry)}
|
|
}
|
|
|
|
// Holder returns the task id currently holding the lock for realPath, or
|
|
// "" if no task holds it. Used to populate the wait_reason hint the daemon
|
|
// posts to the server when it parks a task — the UI then shows "waiting for
|
|
// <path> (held by task <short id>)".
|
|
func (l *LocalPathLocker) Holder(realPath string) string {
|
|
l.mu.Lock()
|
|
entry, ok := l.locks[realPath]
|
|
l.mu.Unlock()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
entry.mu2.Lock()
|
|
defer entry.mu2.Unlock()
|
|
return entry.holderID
|
|
}
|
|
|
|
// Acquire takes the lock for realPath on behalf of taskID. If the lock is
|
|
// already held, onWait is invoked (synchronously, before this goroutine
|
|
// blocks) with the current holder id so callers can flip the task into the
|
|
// server-side waiting_local_directory state. onWait may be nil for callers
|
|
// that don't need the side effect.
|
|
//
|
|
// Returns a release func that the caller must invoke (typically deferred)
|
|
// to free the lock. The release is idempotent.
|
|
//
|
|
// Acquire is cancellable via ctx. When ctx is cancelled while the goroutine
|
|
// is blocked on the lock, Acquire returns ctx.Err() and the lock is NOT
|
|
// taken. This is the same contract as sync.Mutex.Lock paired with
|
|
// context-aware cancellation — a daemon shutdown won't wedge inside the
|
|
// per-path wait queue.
|
|
func (l *LocalPathLocker) Acquire(ctx context.Context, realPath, taskID string, onWait func(holder string)) (func(), error) {
|
|
if realPath == "" {
|
|
return nil, errors.New("local_directory: realpath required for lock")
|
|
}
|
|
if taskID == "" {
|
|
return nil, errors.New("local_directory: taskID required for lock")
|
|
}
|
|
|
|
l.mu.Lock()
|
|
entry, ok := l.locks[realPath]
|
|
if !ok {
|
|
entry = &pathLockEntry{}
|
|
l.locks[realPath] = entry
|
|
}
|
|
l.mu.Unlock()
|
|
|
|
// Try the fast path first — no allocation, no waiter goroutine.
|
|
if entry.mu.TryLock() {
|
|
entry.mu2.Lock()
|
|
entry.holderID = taskID
|
|
entry.mu2.Unlock()
|
|
return l.releaser(realPath, entry), nil
|
|
}
|
|
|
|
// Slow path: somebody else holds the lock. Fire onWait once with the
|
|
// current holder so the daemon can stamp the server-side wait state,
|
|
// then block until either we win the lock or ctx is cancelled.
|
|
if onWait != nil {
|
|
entry.mu2.Lock()
|
|
holder := entry.holderID
|
|
entry.mu2.Unlock()
|
|
onWait(holder)
|
|
}
|
|
|
|
acquired := make(chan struct{})
|
|
go func() {
|
|
entry.mu.Lock()
|
|
close(acquired)
|
|
}()
|
|
|
|
select {
|
|
case <-acquired:
|
|
entry.mu2.Lock()
|
|
entry.holderID = taskID
|
|
entry.mu2.Unlock()
|
|
return l.releaser(realPath, entry), nil
|
|
case <-ctx.Done():
|
|
// We lost the wait — the goroutine above will still complete and
|
|
// take the lock. Spin off a clean-up goroutine that releases it
|
|
// the moment the acquire returns so a future caller isn't stuck
|
|
// behind a phantom holder. The bookkeeping is best-effort: no
|
|
// holder id is set, since this task never owned the lock.
|
|
go func() {
|
|
<-acquired
|
|
entry.mu.Unlock()
|
|
}()
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// releaser returns the unlock callback. Idempotent via a once flag so a
|
|
// deferred release is safe even when the caller has already explicitly
|
|
// released after task completion.
|
|
func (l *LocalPathLocker) releaser(realPath string, entry *pathLockEntry) func() {
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
entry.mu2.Lock()
|
|
entry.holderID = ""
|
|
entry.mu2.Unlock()
|
|
entry.mu.Unlock()
|
|
// We deliberately keep the entry in the map even when nothing
|
|
// is queued. The cost is one *pathLockEntry per distinct path
|
|
// the daemon has ever served, which is bounded by the number
|
|
// of local_directory project resources a workspace has — tiny
|
|
// in practice. Pruning would race with a sibling caller that
|
|
// just looked up the same entry and is about to TryLock.
|
|
_ = realPath
|
|
})
|
|
}
|
|
}
|