diff --git a/backend/internal/adapters/workspace/clone/commands.go b/backend/internal/adapters/workspace/clone/commands.go new file mode 100644 index 00000000..b06eef54 --- /dev/null +++ b/backend/internal/adapters/workspace/clone/commands.go @@ -0,0 +1,55 @@ +package clone + +// remoteGetURLArgs returns the args to read the configured origin URL of a +// repository at dir. Used by Create to discover what URL to clone from and by +// Restore to verify an existing clone points at the expected source. +func remoteGetURLArgs(dir string) []string { + return []string{"-C", dir, "remote", "get-url", "origin"} +} + +// cloneArgs returns the args to perform a full clone of source into dest. +// Plain `git clone` (no --reference, no --shared) is used deliberately: each +// destination gets an independent object database, so concurrent clones from +// the same source touch only the source's read-only pack files and never +// contend on .git/index.lock or alternates. See package doc for the full +// concurrency model. +func cloneArgs(source, dest string) []string { + return []string{"clone", source, dest} +} + +// checkoutNewBranchArgs creates a new local branch starting from HEAD. +func checkoutNewBranchArgs(dir, branch string) []string { + return []string{"-C", dir, "checkout", "-b", branch} +} + +// checkoutBranchArgs checks out an existing branch (used as fallback when +// checkoutNewBranchArgs reports the branch already exists). +func checkoutBranchArgs(dir, branch string) []string { + return []string{"-C", dir, "checkout", branch} +} + +// showCurrentBranchArgs returns the short name of the currently checked-out +// branch (empty for detached HEAD). +func showCurrentBranchArgs(dir string) []string { + return []string{"-C", dir, "branch", "--show-current"} +} + +// statusPorcelainArgs returns the args used by Destroy to detect uncommitted +// changes. Any non-empty output means the working tree has work the agent has +// not committed; Destroy refuses to delete it. +// +// This is the clone-adapter analogue of gitworktree's intentional omission of +// `--force` from `git worktree remove`: we never blindly delete a session +// directory that may contain unsaved agent work (review item RA on PR #23). +// There is no Force escape hatch on the port or on this adapter — the +// safety check is unconditional. +func statusPorcelainArgs(dir string) []string { + return []string{"-C", dir, "status", "--porcelain"} +} + +// revParseInsideWorkTreeArgs returns the args used to confirm a directory is +// a valid git working tree (used by List to skip corrupt clones and by +// Restore to validate a recovered directory before reusing it). +func revParseInsideWorkTreeArgs(dir string) []string { + return []string{"-C", dir, "rev-parse", "--is-inside-work-tree"} +} diff --git a/backend/internal/adapters/workspace/clone/workspace.go b/backend/internal/adapters/workspace/clone/workspace.go new file mode 100644 index 00000000..865b7052 --- /dev/null +++ b/backend/internal/adapters/workspace/clone/workspace.go @@ -0,0 +1,548 @@ +// Package clone implements the ports.Workspace contract via full `git clone` +// per session, as an alternative to the gitworktree adapter. +// +// # Semantics vs. gitworktree +// +// - Create: git clone , then git checkout -b +// (falls back to `git checkout ` if the branch already exists). +// - Restore: if the destination directory exists, verify it is a valid +// git working tree whose `origin` matches the configured source repo, and +// reuse it. If the directory does not exist, perform a fresh clone. +// - Destroy: validate the path is inside the managed root, refuse to +// delete if `git status --porcelain` reports uncommitted changes, then +// `os.RemoveAll` the directory. No `--force` flag is ever passed to git +// and there is no escape hatch — mirroring the RA fix on gitworktree, the +// adapter never silently throws away unpushed agent work. +// - List: enumerates `//*` and reports each +// entry whose current branch is readable via `git branch --show-current`. +// Corrupt clones are skipped. +// +// # Bare vs. non-bare source +// +// `git clone` works against either a bare repository or a non-bare working +// directory transparently — the source is read-only from git's point of view +// and we never invoke `git -C ` for mutating commands. Operators may +// point RepoResolver at either kind. +// +// # Concurrency model +// +// Two Create calls for different (project, session) pairs that share a +// source repo do not contend on any lock file: plain `git clone` reads the +// source's pack files (read-shared on POSIX) and writes only into its own +// destination, so the per-clone `.git/index.lock` is destination-scoped. We +// deliberately do NOT pass `--reference ` or `--shared`, which would +// alias the destination's object DB to the source's and create coupled +// failure modes if the source were ever pruned or repacked while a clone +// existed. +// +// The adapter itself is otherwise stateless: no in-memory map of sessions, +// no shared filesystem locks. Concurrent Destroy on the same path is the +// caller's responsibility (the Session Manager already serialises lifecycle +// transitions per session, so this is not an issue in practice). +// +// # Path-escape protection +// +// project / session IDs are rejected if they contain a path separator or +// equal `.` / `..` — see validatePathComponent. Beyond that, every path the +// adapter touches is resolved against the managed root via +// validateManagedPath, which fully expands symlinks (physicalAbs) before +// checking containment. This mirrors the RB fix on PR #23. +package clone + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const defaultGitBinary = "git" + +var ( + // ErrUnsafePath is returned when a path argument escapes the managed root + // or an id contains path-traversal components. + ErrUnsafePath = errors.New("clone: unsafe workspace path") + + // ErrDirtyWorkspace is returned by Destroy when the working tree has + // uncommitted changes. The adapter never deletes dirty workspaces. + ErrDirtyWorkspace = errors.New("clone: refusing to destroy: workspace has uncommitted changes") + + // ErrOriginMismatch is returned by Restore when an existing directory's + // origin URL does not match the configured source repository. + ErrOriginMismatch = errors.New("clone: existing workspace has mismatched origin") +) + +// RepoResolver maps a ProjectID to the source repository path/URL passed to +// `git clone`. +type RepoResolver interface { + RepoPath(projectID domain.ProjectID) (string, error) +} + +// StaticRepoResolver is a simple map-backed RepoResolver suitable for tests +// and single-project configurations. +type StaticRepoResolver map[domain.ProjectID]string + +func (r StaticRepoResolver) RepoPath(projectID domain.ProjectID) (string, error) { + path := r[projectID] + if path == "" { + return "", fmt.Errorf("clone: no repo configured for project %q", projectID) + } + return path, nil +} + +// Options configures a clone Workspace. ManagedRoot and RepoResolver are +// required; Binary defaults to "git". +type Options struct { + Binary string + ManagedRoot string + RepoResolver RepoResolver +} + +// Workspace implements ports.Workspace by cloning the source repo into a +// per-session directory under the managed root. +type Workspace struct { + binary string + managedRoot string + repos RepoResolver + run commandRunner +} + +type commandRunner func(ctx context.Context, binary string, args ...string) ([]byte, error) + +var _ ports.Workspace = (*Workspace)(nil) + +// New constructs a clone Workspace and resolves the managed root through +// symlinks so subsequent containment checks compare physical paths. +func New(opts Options) (*Workspace, error) { + binary := opts.Binary + if binary == "" { + binary = defaultGitBinary + } + if opts.ManagedRoot == "" { + return nil, errors.New("clone: ManagedRoot is required") + } + if opts.RepoResolver == nil { + return nil, errors.New("clone: RepoResolver is required") + } + root, err := physicalAbs(opts.ManagedRoot) + if err != nil { + return nil, fmt.Errorf("clone: managed root: %w", err) + } + return &Workspace{ + binary: binary, + managedRoot: filepath.Clean(root), + repos: opts.RepoResolver, + run: runCommand, + }, nil +} + +func (w *Workspace) Create(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if exists, err := pathExists(path); err != nil { + return ports.WorkspaceInfo{}, err + } else if exists { + return ports.WorkspaceInfo{}, fmt.Errorf("clone: refusing to create %q: path already exists", path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return ports.WorkspaceInfo{}, fmt.Errorf("clone: create parent dir: %w", err) + } + if err := w.doClone(ctx, repo, path); err != nil { + return ports.WorkspaceInfo{}, err + } + if err := w.doCheckout(ctx, path, cfg.Branch); err != nil { + // Cleanup partial clone so the next Create can retry from a clean slate. + _ = os.RemoveAll(path) + return ports.WorkspaceInfo{}, err + } + return ports.WorkspaceInfo{Path: path, Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) Destroy(ctx context.Context, info ports.WorkspaceInfo) error { + if info.ProjectID == "" { + return errors.New("clone: project id is required") + } + if info.Path == "" { + return fmt.Errorf("%w: empty path", ErrUnsafePath) + } + path, err := w.validateManagedPath(info.Path) + if err != nil { + return err + } + exists, err := pathExists(path) + if err != nil { + return err + } + if !exists { + return nil + } + dirty, err := w.isDirty(ctx, path) + if err != nil { + return err + } + if dirty { + return fmt.Errorf("%w: %q", ErrDirtyWorkspace, path) + } + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("clone: remove %q: %w", path, err) + } + return nil +} + +func (w *Workspace) List(ctx context.Context, project domain.ProjectID) ([]ports.WorkspaceInfo, error) { + if project == "" { + return nil, errors.New("clone: project id is required") + } + if err := validatePathComponent("project id", string(project)); err != nil { + return nil, err + } + projectRoot, err := w.projectRoot(project) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(projectRoot) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("clone: read project dir: %w", err) + } + out := make([]ports.WorkspaceInfo, 0, len(entries)) + for _, e := range entries { + if !isDirEntry(e) { + continue + } + name := e.Name() + if name == "." || name == ".." { + continue + } + clonePath := filepath.Join(projectRoot, name) + branch, err := w.currentBranch(ctx, clonePath) + if err != nil { + // Corrupt clone — skip it. Mirrors the upstream JS behavior of + // surfacing only valid sessions in List(). + continue + } + out = append(out, ports.WorkspaceInfo{ + Path: clonePath, + Branch: branch, + SessionID: domain.SessionID(name), + ProjectID: project, + }) + } + return out, nil +} + +func (w *Workspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) { + if err := validateConfig(cfg); err != nil { + return ports.WorkspaceInfo{}, err + } + repo, err := w.repoPath(cfg.ProjectID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + path, err := w.managedPath(cfg.ProjectID, cfg.SessionID) + if err != nil { + return ports.WorkspaceInfo{}, err + } + exists, err := pathExists(path) + if err != nil { + return ports.WorkspaceInfo{}, err + } + if !exists { + // Nothing on disk: fall through to a fresh clone, same as Create. + return w.Create(ctx, cfg) + } + // Path exists: must be a valid git working tree with the expected origin. + if _, err := w.run(ctx, w.binary, revParseInsideWorkTreeArgs(path)...); err != nil { + return ports.WorkspaceInfo{}, fmt.Errorf("clone: refusing to restore %q: not a valid git working tree: %w", path, err) + } + expected, err := w.originURL(ctx, repo) + if err != nil { + return ports.WorkspaceInfo{}, err + } + got, err := w.originURL(ctx, path) + if err != nil { + return ports.WorkspaceInfo{}, fmt.Errorf("clone: read origin of existing workspace: %w", err) + } + if !originsEqual(expected, got) { + return ports.WorkspaceInfo{}, fmt.Errorf("%w: %q origin=%q, expected %q", ErrOriginMismatch, path, got, expected) + } + branch, err := w.currentBranch(ctx, path) + if err != nil { + return ports.WorkspaceInfo{}, fmt.Errorf("clone: read current branch of %q: %w", path, err) + } + if branch == "" { + branch = cfg.Branch + } + return ports.WorkspaceInfo{Path: path, Branch: branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil +} + +func (w *Workspace) doClone(ctx context.Context, source, dest string) error { + if _, err := w.run(ctx, w.binary, cloneArgs(source, dest)...); err != nil { + // Clean up any partial dest left behind by git. + _ = os.RemoveAll(dest) + return fmt.Errorf("clone: git clone %q -> %q: %w", source, dest, err) + } + return nil +} + +func (w *Workspace) doCheckout(ctx context.Context, dir, branch string) error { + if _, err := w.run(ctx, w.binary, checkoutNewBranchArgs(dir, branch)...); err == nil { + return nil + } + // Branch may already exist (e.g. it was fetched as a remote-tracking ref); + // fall back to a plain checkout so Create is idempotent in that case. + if _, err := w.run(ctx, w.binary, checkoutBranchArgs(dir, branch)...); err != nil { + return fmt.Errorf("clone: checkout branch %q in %q: %w", branch, dir, err) + } + return nil +} + +func (w *Workspace) isDirty(ctx context.Context, dir string) (bool, error) { + out, err := w.run(ctx, w.binary, statusPorcelainArgs(dir)...) + if err != nil { + return false, fmt.Errorf("clone: git status %q: %w", dir, err) + } + return len(bytes.TrimSpace(out)) > 0, nil +} + +func (w *Workspace) originURL(ctx context.Context, dir string) (string, error) { + out, err := w.run(ctx, w.binary, remoteGetURLArgs(dir)...) + if err != nil { + return "", fmt.Errorf("clone: git remote get-url origin in %q: %w", dir, err) + } + return strings.TrimSpace(string(out)), nil +} + +func (w *Workspace) currentBranch(ctx context.Context, dir string) (string, error) { + out, err := w.run(ctx, w.binary, showCurrentBranchArgs(dir)...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func (w *Workspace) repoPath(project domain.ProjectID) (string, error) { + repo, err := w.repos.RepoPath(project) + if err != nil { + return "", err + } + if repo == "" { + return "", fmt.Errorf("clone: no repo configured for project %q", project) + } + // Local paths are resolved through symlinks for predictable origin + // comparisons in Restore. URLs (e.g. https://, git@) fall through to + // physicalAbs's last branch, which returns the input unchanged. + abs, err := physicalAbs(repo) + if err != nil { + return "", fmt.Errorf("clone: repo path: %w", err) + } + return abs, nil +} + +func (w *Workspace) managedPath(project domain.ProjectID, session domain.SessionID) (string, error) { + path := filepath.Join(w.managedRoot, string(project), string(session)) + return w.validateManagedPath(path) +} + +func (w *Workspace) projectRoot(project domain.ProjectID) (string, error) { + path := filepath.Join(w.managedRoot, string(project)) + // The project root itself sits one level below managed root and is + // allowed to be returned as-is; validateManagedPath rejects paths equal + // to managedRoot but accepts strict descendants like this one. + return w.validateManagedPath(path) +} + +// validateManagedPath ensures path is an absolute, clean, symlink-resolved +// descendant of managedRoot. Mirrors gitworktree's RB fix on PR #23. +func (w *Workspace) validateManagedPath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("%w: empty path", ErrUnsafePath) + } + if !filepath.IsAbs(path) { + return "", fmt.Errorf("%w: %q is not absolute", ErrUnsafePath, path) + } + clean := filepath.Clean(path) + if clean != path { + return "", fmt.Errorf("%w: %q is not clean", ErrUnsafePath, path) + } + physical, err := physicalAbs(clean) + if err != nil { + return "", fmt.Errorf("clone: resolve path %q: %w", path, err) + } + clean = physical + inside, err := pathWithin(w.managedRoot, clean) + if err != nil { + return "", err + } + if !inside || clean == w.managedRoot { + return "", fmt.Errorf("%w: %q is outside managed root %q", ErrUnsafePath, clean, w.managedRoot) + } + return clean, nil +} + +// validateConfig rejects empty fields and ids that could escape the managed +// root once joined into a path. filepath.Join cleans `..` before +// validateManagedPath runs, so a session id of "../other" would otherwise +// resolve back inside managedRoot while breaking per-project isolation — +// same root cause as gitworktree's RB fix on PR #23. +func validateConfig(cfg ports.WorkspaceConfig) error { + if cfg.ProjectID == "" { + return errors.New("clone: project id is required") + } + if err := validatePathComponent("project id", string(cfg.ProjectID)); err != nil { + return err + } + if cfg.SessionID == "" { + return errors.New("clone: session id is required") + } + if err := validatePathComponent("session id", string(cfg.SessionID)); err != nil { + return err + } + if cfg.Branch == "" { + return errors.New("clone: branch is required") + } + return nil +} + +func validatePathComponent(name, value string) error { + if strings.ContainsAny(value, `/\`) { + return fmt.Errorf("%w: %s %q must not contain path separators", ErrUnsafePath, name, value) + } + if value == "." || value == ".." { + return fmt.Errorf("%w: %s %q must not be a path-traversal component", ErrUnsafePath, name, value) + } + return nil +} + +func physicalAbs(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + abs = filepath.Clean(abs) + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(resolved), nil + } + parent := filepath.Dir(abs) + base := filepath.Base(abs) + for parent != "." && parent != string(os.PathSeparator) { + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + base = filepath.Join(filepath.Base(parent), base) + parent = filepath.Dir(parent) + } + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, base), nil + } + return abs, nil +} + +func pathWithin(root, path string) (bool, error) { + rel, err := filepath.Rel(root, path) + if err != nil { + return false, fmt.Errorf("clone: compare paths: %w", err) + } + return rel == "." || (rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))), nil +} + +func pathExists(path string) (bool, error) { + _, err := os.Lstat(path) + if err == nil { + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("clone: stat %q: %w", path, err) +} + +// originsEqual compares two `git remote get-url` outputs. Local paths are +// resolved through symlinks so a managed-root path stored as the origin of +// a clone still matches the same path when the watcher passes it back as a +// fresh string. Scheme-qualified URLs (http://, https://, ssh://, git://) +// bypass path resolution; SCP-style remotes (`user@host:path`) and POSIX +// paths that happen to contain `@` fall through to physicalAbs, where Clean +// preserves the embedded `:` segment so they can't silently collide with a +// distinct local path. +func originsEqual(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == b { + return true + } + if isSchemeURL(a) || isSchemeURL(b) { + return false + } + abs, errA := physicalAbs(a) + bbs, errB := physicalAbs(b) + if errA != nil || errB != nil { + return false + } + return abs == bbs +} + +// isSchemeURL is intentionally narrower than "contains @" — a POSIX path +// like `/home/user@corp/repo` should still resolve through symlinks for +// equality. SCP-style remotes (`git@host:p`) hit the string-equality branch +// above when both sides match and are correctly classified as non-equal +// against any local path via physicalAbs (which can't produce `git@...:p`). +func isSchemeURL(s string) bool { + return strings.Contains(s, "://") +} + +// isDirEntry reports whether a DirEntry refers to a directory, resolving +// symlinks before deciding (a symlinked directory shows up as non-dir from +// the DirEntry but as dir once its Info is read). +func isDirEntry(e os.DirEntry) bool { + if e.IsDir() { + return true + } + info, err := e.Info() + if err != nil { + return false + } + return info.IsDir() +} + +func runCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return out, commandError{args: append([]string{binary}, args...), output: string(out), err: err} + } + return out, nil +} + +type commandError struct { + args []string + output string + err error +} + +func (e commandError) Error() string { + if strings.TrimSpace(e.output) == "" { + return fmt.Sprintf("%s: %v", strings.Join(e.args, " "), e.err) + } + return fmt.Sprintf("%s: %v: %s", strings.Join(e.args, " "), e.err, strings.TrimSpace(e.output)) +} + +func (e commandError) Unwrap() error { return e.err } diff --git a/backend/internal/adapters/workspace/clone/workspace_test.go b/backend/internal/adapters/workspace/clone/workspace_test.go new file mode 100644 index 00000000..302b16b1 --- /dev/null +++ b/backend/internal/adapters/workspace/clone/workspace_test.go @@ -0,0 +1,600 @@ +package clone + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// TestCommandArgs is the clone analogue of gitworktree's same-named test: +// pin the exact argv each git invocation produces so future refactors of +// workspace.go can't silently change the wire format. +func TestCommandArgs(t *testing.T) { + dir := "/managed/proj/sess" + cases := []struct { + name string + got []string + want []string + }{ + {"remote get-url", remoteGetURLArgs("/repo"), []string{"-C", "/repo", "remote", "get-url", "origin"}}, + {"clone", cloneArgs("/repo", dir), []string{"clone", "/repo", dir}}, + {"checkout new branch", checkoutNewBranchArgs(dir, "feature/x"), []string{"-C", dir, "checkout", "-b", "feature/x"}}, + {"checkout existing branch", checkoutBranchArgs(dir, "feature/x"), []string{"-C", dir, "checkout", "feature/x"}}, + {"show current branch", showCurrentBranchArgs(dir), []string{"-C", dir, "branch", "--show-current"}}, + {"status porcelain", statusPorcelainArgs(dir), []string{"-C", dir, "status", "--porcelain"}}, + {"rev-parse is-inside-work-tree", revParseInsideWorkTreeArgs(dir), []string{"-C", dir, "rev-parse", "--is-inside-work-tree"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Fatalf("args = %#v, want %#v", tc.got, tc.want) + } + }) + } +} + +// TestCommandArgsNeverUseForce is a belt-and-braces check that no arg +// builder in this package ever emits a `--force` or `-f` flag. The clone +// adapter's safety story rests on never asking git to destroy uncommitted +// work — see Destroy / statusPorcelainArgs doc comment. This catches a +// regression where a future builder might add one. +func TestCommandArgsNeverUseForce(t *testing.T) { + all := [][]string{ + remoteGetURLArgs("/r"), + cloneArgs("/r", "/d"), + checkoutNewBranchArgs("/d", "b"), + checkoutBranchArgs("/d", "b"), + showCurrentBranchArgs("/d"), + statusPorcelainArgs("/d"), + revParseInsideWorkTreeArgs("/d"), + } + for _, args := range all { + for _, a := range args { + if a == "--force" || a == "-f" { + t.Fatalf("arg list %v contains forbidden flag %q", args, a) + } + } + } +} + +// TestValidateConfigRejectsPathEscapingIDs is the clone analogue of +// gitworktree's RB-fix test on PR #23. filepath.Join cleans `..` before +// validateManagedPath sees the result, so ids carrying separators or +// `.`/`..` must be rejected at the source. +func TestValidateConfigRejectsPathEscapingIDs(t *testing.T) { + root := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + cases := []struct { + name string + cfg ports.WorkspaceConfig + }{ + {"session contains slash escapes project root", ports.WorkspaceConfig{ProjectID: "proj", SessionID: "../other", Branch: "main"}}, + {"session is ..", ports.WorkspaceConfig{ProjectID: "proj", SessionID: "..", Branch: "main"}}, + {"session is .", ports.WorkspaceConfig{ProjectID: "proj", SessionID: ".", Branch: "main"}}, + {"session contains backslash", ports.WorkspaceConfig{ProjectID: "proj", SessionID: `evil\sess`, Branch: "main"}}, + {"project contains slash escapes managed root", ports.WorkspaceConfig{ProjectID: "../proj", SessionID: "sess", Branch: "main"}}, + {"project is ..", ports.WorkspaceConfig{ProjectID: "..", SessionID: "sess", Branch: "main"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := ws.Create(context.Background(), tc.cfg); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("Create err = %v, want ErrUnsafePath", err) + } + if _, err := ws.Restore(context.Background(), tc.cfg); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("Restore err = %v, want ErrUnsafePath", err) + } + }) + } +} + +func TestValidateConfigAcceptsBenignIDs(t *testing.T) { + cases := []ports.WorkspaceConfig{ + {ProjectID: "proj-1", SessionID: "sess_2", Branch: "main"}, + {ProjectID: "foo.bar", SessionID: "abc-42", Branch: "main"}, + {ProjectID: "p", SessionID: "..hidden", Branch: "main"}, // leading dots != ".." + } + for i, cfg := range cases { + if err := validateConfig(cfg); err != nil { + t.Errorf("case %d %+v: unexpected error: %v", i, cfg, err) + } + } +} + +func TestManagedPathSafety(t *testing.T) { + root := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + path, err := ws.managedPath("proj", "sess") + if err != nil { + t.Fatalf("managed path: %v", err) + } + if want := filepath.Join(ws.managedRoot, "proj", "sess"); path != want { + t.Fatalf("path = %q, want %q", path, want) + } + if _, err := ws.validateManagedPath(filepath.Join(root, "..", "outside")); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("outside error = %v, want ErrUnsafePath", err) + } + if _, err := ws.validateManagedPath("relative/path"); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("relative error = %v, want ErrUnsafePath", err) + } +} + +// scriptedRunner records every git invocation and dispatches to a handler +// keyed by the joined argv. Tests build one of these per case to assert on +// the exact sequence of git calls. Mirrors the inline `ws.run = func(...)` +// pattern in gitworktree, but factored so each test case is data-only. +type scriptedRunner struct { + t *testing.T + handlers []scriptedCase + calls [][]string +} + +type scriptedCase struct { + match string // substring of "binary args..."; first matching handler wins + stdout string + err error +} + +func (s *scriptedRunner) run(_ context.Context, binary string, args ...string) ([]byte, error) { + joined := binary + " " + strings.Join(args, " ") + s.calls = append(s.calls, append([]string{binary}, args...)) + for _, h := range s.handlers { + if strings.Contains(joined, h.match) { + return []byte(h.stdout), h.err + } + } + s.t.Fatalf("scriptedRunner: no handler for %q", joined) + return nil, nil +} + +func (s *scriptedRunner) callsMatching(substr string) int { + n := 0 + for _, c := range s.calls { + if strings.Contains(strings.Join(c, " "), substr) { + n++ + } + } + return n +} + +// resolved is a test helper that runs filepath.EvalSymlinks so test +// assertions can match the canonical paths the adapter sees. On macOS +// t.TempDir() returns paths under /var/folders/... which physicalAbs +// resolves to /private/var/folders/...; without this helper match strings +// would never align. +func resolved(t *testing.T, p string) string { + t.Helper() + got, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatalf("eval symlinks %q: %v", p, err) + } + return got +} + +// TestCreate covers both the happy path and the branch-already-exists +// fallback, in table form. Programmable runner only — no real git. +func TestCreate(t *testing.T) { + cases := []struct { + name string + branch string + handlers []scriptedCase + wantBranch string + wantErr string + }{ + { + name: "fresh branch via checkout -b", + branch: "feat/new", + handlers: []scriptedCase{ + {match: "clone ", stdout: "", err: nil}, + {match: "checkout -b feat/new", stdout: "", err: nil}, + }, + wantBranch: "feat/new", + }, + { + name: "existing branch falls back to plain checkout", + branch: "feat/existing", + handlers: []scriptedCase{ + {match: "clone ", stdout: "", err: nil}, + {match: "checkout -b feat/existing", stdout: "fatal: A branch named 'feat/existing' already exists", err: errors.New("exit 128")}, + {match: "checkout feat/existing", stdout: "", err: nil}, + }, + wantBranch: "feat/existing", + }, + { + name: "clone failure surfaces error and cleans dest", + branch: "feat/x", + handlers: []scriptedCase{ + {match: "clone ", stdout: "fatal: not a repo", err: errors.New("exit 128")}, + }, + wantErr: "git clone", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + runner := &scriptedRunner{t: t, handlers: tc.handlers} + ws.run = runner.run + + info, err := ws.Create(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: tc.branch}) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tc.wantErr) + } + // Partial-clone cleanup must run: no surviving directory. + if _, statErr := os.Stat(filepath.Join(ws.managedRoot, "proj", "sess")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("dest not cleaned up after clone failure: %v", statErr) + } + return + } + if err != nil { + t.Fatalf("create: %v", err) + } + wantPath := filepath.Join(ws.managedRoot, "proj", "sess") + if info.Path != wantPath || info.Branch != tc.wantBranch || info.SessionID != "sess" || info.ProjectID != "proj" { + t.Fatalf("info = %#v, wantPath=%q wantBranch=%q", info, wantPath, tc.wantBranch) + } + if runner.callsMatching("clone "+resolved(t, repo)) == 0 { + t.Fatalf("expected clone call against repo %q, calls=%v", resolved(t, repo), runner.calls) + } + }) + } +} + +func TestCreateRefusesExistingDest(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + dest := filepath.Join(ws.managedRoot, "proj", "sess") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("seed dest: %v", err) + } + ws.run = func(context.Context, string, ...string) ([]byte, error) { + t.Fatalf("git should not be invoked when dest exists") + return nil, nil + } + if _, err := ws.Create(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "main"}); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("create err = %v, want already-exists refusal", err) + } +} + +// TestRestoreReusesValidClone seeds an on-disk directory and lets the +// scripted runner agree that origin matches the configured repo. Expected +// behavior: Restore returns the existing path with the live current branch. +func TestRestoreReusesValidClone(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + dest := filepath.Join(ws.managedRoot, "proj", "sess") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("seed: %v", err) + } + originURL := "https://github.com/test/repo.git" + repoResolved := resolved(t, repo) + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "rev-parse --is-inside-work-tree"): + return []byte("true\n"), nil + case strings.Contains(joined, "-C "+repoResolved+" remote get-url origin"): + return []byte(originURL + "\n"), nil + case strings.Contains(joined, "-C "+dest+" remote get-url origin"): + return []byte(originURL + "\n"), nil + case strings.Contains(joined, "branch --show-current"): + return []byte("feature/restored\n"), nil + } + t.Fatalf("unexpected git call: %s", joined) + return nil, nil + } + info, err := ws.Restore(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "feature/wanted"}) + if err != nil { + t.Fatalf("restore: %v", err) + } + if info.Path != dest || info.Branch != "feature/restored" || info.SessionID != "sess" || info.ProjectID != "proj" { + t.Fatalf("info = %#v", info) + } +} + +// TestRestoreRejectsMismatchedOrigin guards against silently reusing a +// directory that contains a clone of some other repository — the Session +// Manager would otherwise hand the agent a workspace pointing at the wrong +// upstream, and a push would land in a different repo than expected. +func TestRestoreRejectsMismatchedOrigin(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + dest := filepath.Join(ws.managedRoot, "proj", "sess") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatalf("seed: %v", err) + } + repoResolved := resolved(t, repo) + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "rev-parse --is-inside-work-tree"): + return []byte("true\n"), nil + case strings.Contains(joined, "-C "+repoResolved+" remote get-url origin"): + return []byte("https://github.com/expected/repo.git\n"), nil + case strings.Contains(joined, "-C "+dest+" remote get-url origin"): + return []byte("https://github.com/wrong/repo.git\n"), nil + } + t.Fatalf("unexpected git call: %s", joined) + return nil, nil + } + _, err = ws.Restore(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "main"}) + if !errors.Is(err, ErrOriginMismatch) { + t.Fatalf("restore err = %v, want ErrOriginMismatch", err) + } +} + +// TestRestoreReclonesMissingPath: if nothing is on disk we delegate to +// Create. We assert the canonical Create call chain runs (clone + checkout). +func TestRestoreReclonesMissingPath(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + runner := &scriptedRunner{t: t, handlers: []scriptedCase{ + {match: "clone ", stdout: "", err: nil}, + {match: "checkout -b main", stdout: "", err: nil}, + }} + ws.run = runner.run + info, err := ws.Restore(context.Background(), ports.WorkspaceConfig{ProjectID: "proj", SessionID: "sess", Branch: "main"}) + if err != nil { + t.Fatalf("restore: %v", err) + } + if info.Branch != "main" { + t.Fatalf("info.Branch = %q, want main", info.Branch) + } + if runner.callsMatching("clone") == 0 || runner.callsMatching("checkout -b main") == 0 { + t.Fatalf("expected clone + checkout calls, got %v", runner.calls) + } +} + +// TestDestroySucceedsForCleanRepo: managed path, clean status, directory +// goes away. This is the happy-path inverse of the dirty-refusal test below. +func TestDestroySucceedsForCleanRepo(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + dest := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(dest, "keep.txt"); err != nil { + t.Fatalf("seed: %v", err) + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "status --porcelain") { + return []byte(""), nil + } + t.Fatalf("unexpected git call: %s", joined) + return nil, nil + } + if err := ws.Destroy(context.Background(), ports.WorkspaceInfo{ProjectID: "proj", SessionID: "sess", Path: dest, Branch: "main"}); err != nil { + t.Fatalf("destroy: %v", err) + } + if _, statErr := os.Stat(dest); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("destroy left path on disk: %v", statErr) + } +} + +// TestDestroyRefusesDirtyRepo: with uncommitted changes Destroy must +// preserve the directory. This is the clone analogue of gitworktree's +// RA-fix test on PR #23. +func TestDestroyRefusesDirtyRepo(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + dest := filepath.Join(ws.managedRoot, "proj", "sess") + if err := mkdirFile(dest, "keep.txt"); err != nil { + t.Fatalf("seed: %v", err) + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + if strings.Contains(joined, "status --porcelain") { + // Simulate a dirty working tree. + return []byte(" M src/foo.go\n?? new.txt\n"), nil + } + t.Fatalf("unexpected git call (Destroy must never call git beyond status): %s", joined) + return nil, nil + } + err = ws.Destroy(context.Background(), ports.WorkspaceInfo{ProjectID: "proj", SessionID: "sess", Path: dest, Branch: "main"}) + if !errors.Is(err, ErrDirtyWorkspace) { + t.Fatalf("destroy err = %v, want ErrDirtyWorkspace", err) + } + if _, statErr := os.Stat(filepath.Join(dest, "keep.txt")); statErr != nil { + t.Fatalf("destroy deleted dirty workspace: %v", statErr) + } +} + +// TestDestroyRejectsPathEscape covers all three known shapes of path-escape +// attempt in a single table: relative `..` segments, absolute paths outside +// the managed root, and a symlink-ladder that points outside the root. +func TestDestroyRejectsPathEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": root}}) + if err != nil { + t.Fatalf("new: %v", err) + } + // Build a symlink under managedRoot that points to a sibling outside. + symlinkParent := filepath.Join(ws.managedRoot, "proj") + if err := os.MkdirAll(symlinkParent, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + symlinkPath := filepath.Join(symlinkParent, "ladder") + if err := os.Symlink(outside, symlinkPath); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + cases := []struct { + name string + path string + }{ + {"unclean dotdot segment", filepath.Join(ws.managedRoot, "..", "outside")}, + {"absolute path outside root", outside}, + {"symlink ladder out of root", symlinkPath}, + {"empty path", ""}, + {"relative path", "relative/sess"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ws.run = func(context.Context, string, ...string) ([]byte, error) { + t.Fatalf("git should never be invoked for path-escape attempt") + return nil, nil + } + err := ws.Destroy(context.Background(), ports.WorkspaceInfo{ProjectID: "proj", SessionID: "sess", Path: tc.path, Branch: "main"}) + if !errors.Is(err, ErrUnsafePath) { + t.Fatalf("destroy %q err = %v, want ErrUnsafePath", tc.path, err) + } + }) + } +} + +// TestDestroyMissingPathIsNoop: Restore can call Destroy as part of +// recovery; an absent directory should not be an error. +func TestDestroyMissingPathIsNoop(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + ws.run = func(context.Context, string, ...string) ([]byte, error) { + t.Fatalf("git should not be invoked for a non-existent path") + return nil, nil + } + dest := filepath.Join(ws.managedRoot, "proj", "missing") + if err := ws.Destroy(context.Background(), ports.WorkspaceInfo{ProjectID: "proj", SessionID: "missing", Path: dest, Branch: "main"}); err != nil { + t.Fatalf("destroy missing: %v", err) + } +} + +// TestList exercises the two non-trivial branches of List: missing project +// directory returns empty, and a directory full of mixed entries gets +// filtered to valid git clones with their reported branches. +func TestList(t *testing.T) { + root := t.TempDir() + repo := t.TempDir() + ws, err := New(Options{ManagedRoot: root, RepoResolver: StaticRepoResolver{"proj": repo}}) + if err != nil { + t.Fatalf("new: %v", err) + } + + t.Run("missing project dir is empty", func(t *testing.T) { + ws.run = func(context.Context, string, ...string) ([]byte, error) { return nil, nil } + got, err := ws.List(context.Background(), "proj") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 0 { + t.Fatalf("got = %#v, want empty", got) + } + }) + + t.Run("valid clones with branches", func(t *testing.T) { + projDir := filepath.Join(ws.managedRoot, "proj") + if err := os.MkdirAll(filepath.Join(projDir, "s1"), 0o755); err != nil { + t.Fatalf("seed s1: %v", err) + } + if err := os.MkdirAll(filepath.Join(projDir, "s2"), 0o755); err != nil { + t.Fatalf("seed s2: %v", err) + } + if err := os.WriteFile(filepath.Join(projDir, "note.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + // s3 is a directory whose git command fails — corrupt clone, must be skipped. + if err := os.MkdirAll(filepath.Join(projDir, "s3-corrupt"), 0o755); err != nil { + t.Fatalf("seed s3: %v", err) + } + ws.run = func(_ context.Context, _ string, args ...string) ([]byte, error) { + joined := strings.Join(args, " ") + switch { + case strings.Contains(joined, "-C "+filepath.Join(projDir, "s1")+" branch --show-current"): + return []byte("feature/one\n"), nil + case strings.Contains(joined, "-C "+filepath.Join(projDir, "s2")+" branch --show-current"): + return []byte("feature/two\n"), nil + case strings.Contains(joined, "-C "+filepath.Join(projDir, "s3-corrupt")+" branch --show-current"): + return nil, errors.New("fatal: not a git repository") + } + t.Fatalf("unexpected git call: %s", joined) + return nil, nil + } + got, err := ws.List(context.Background(), "proj") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2: %#v", len(got), got) + } + bySession := map[domain.SessionID]ports.WorkspaceInfo{} + for _, e := range got { + bySession[e.SessionID] = e + } + s1, ok := bySession["s1"] + if !ok || s1.Branch != "feature/one" || s1.Path != filepath.Join(projDir, "s1") { + t.Fatalf("s1 = %#v", s1) + } + s2, ok := bySession["s2"] + if !ok || s2.Branch != "feature/two" || s2.Path != filepath.Join(projDir, "s2") { + t.Fatalf("s2 = %#v", s2) + } + }) +} + +func TestOriginsEqual(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"https://github.com/x/y.git", "https://github.com/x/y.git", true}, + {"https://github.com/x/y.git", "https://github.com/other/y.git", false}, + {"git@github.com:x/y.git", "git@github.com:x/y.git", true}, + {"git@github.com:x/y.git", "https://github.com/x/y.git", false}, + } + for _, tc := range cases { + if got := originsEqual(tc.a, tc.b); got != tc.want { + t.Errorf("originsEqual(%q,%q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +// helpers ------------------------------------------------------------------ + +func mkdirFile(dir, name string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, name), []byte("data"), 0o644) +}