diff --git a/internal/cli/run.go b/internal/cli/run.go index a8d8e6b855..2265d80659 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -314,26 +314,10 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str // 8. Make project code available (copy repo root into a named subdirectory). copyStart := time.Now() printer.StepStart("Copying project code into sandbox") - mkRepoCmd := fmt.Sprintf("mkdir -p %s", repoDir) - if _, _, _, err := sandbox.Exec(sandboxName, mkRepoCmd, 10*time.Second); err != nil { - return fmt.Errorf("creating repo dir in sandbox: %w", err) - } - if err := sandbox.Upload(sandboxName, repoSrc+"/.", repoDir+"/"); err != nil { + if err := sandbox.UploadDir(sandboxName, repoSrc, repoDir); err != nil { printer.StepFail("Failed to copy project code") return fmt.Errorf("copying project code: %w", err) } - - // openshell upload applies .gitignore filtering by default via - // git ls-files, which never lists .git/. Upload it separately so the - // agent has full git history, refs, and branch state. Uploading .git/ - // directly bypasses the filter because .git/ itself is not a working - // tree, causing openshell to fall back to an unfiltered tar upload. - gitDir := filepath.Join(repoSrc, ".git") - if fi, statErr := os.Stat(gitDir); statErr == nil && fi.IsDir() { - if err := sandbox.Upload(sandboxName, gitDir, filepath.Join(repoDir, ".git")); err != nil { - printer.StepWarn("Could not upload .git directory: " + err.Error()) - } - } printer.StepDone(fmt.Sprintf("Project code copied to %s/ (%.1fs)", repoName, time.Since(copyStart).Seconds())) // 8a. Inject org-level AGENTS.md if the target repo does not have one. diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index a81e747143..4046a2db54 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -269,6 +269,39 @@ func Upload(sandboxName, localPath, remotePath string) error { return nil } +// UploadDir uploads a local directory into a sandbox, preserving symlinks. +// openshell sandbox upload dereferences symlinks; this builds a local tarball +// with --no-dereference, uploads it, and extracts it in the sandbox. +func UploadDir(sandboxName, localPath, remotePath string) error { + tmp, err := os.CreateTemp("", "openshell-upload-*.tar.gz") + if err != nil { + return fmt.Errorf("creating temp tarball: %w", err) + } + tmpPath := tmp.Name() + tmp.Close() + defer os.Remove(tmpPath) + + tarCmd := exec.Command("tar", "-czf", tmpPath, "-C", localPath, ".") + if out, tarErr := tarCmd.CombinedOutput(); tarErr != nil { + return fmt.Errorf("creating tarball of %q: %s: %w", localPath, string(out), tarErr) + } + + remoteTar := fmt.Sprintf("/tmp/fs-upload-%s.tar.gz", sandboxName) + if err := Upload(sandboxName, tmpPath, remoteTar); err != nil { + return err + } + + extractCmd := fmt.Sprintf("mkdir -p %s && tar -xzf %s -C %s && rm %s", remotePath, remoteTar, remotePath, remoteTar) + _, stderr, exitCode, err := Exec(sandboxName, extractCmd, transferTimeout) + if err != nil { + return fmt.Errorf("extracting tarball in sandbox %q: %w", sandboxName, err) + } + if exitCode != 0 { + return fmt.Errorf("extracting tarball in sandbox %q: exit %d: %s", sandboxName, exitCode, stderr) + } + return nil +} + // Download copies a file or directory from a sandbox to the local machine. // The localPath is always treated as a directory by openshell — for single-file // downloads use DownloadFile instead. diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index e62db23768..17e15c716e 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -244,3 +244,11 @@ func TestSanitizeDownload_EmptyDir(t *testing.T) { err := sanitizeDownload(dir) assert.NoError(t, err) } + +func TestUploadDir_OpenshellNotInPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", "") + + err := UploadDir("test-sandbox", dir, "/tmp/workspace/repo") + assert.Error(t, err) +}