diff --git a/internal/cli/run.go b/internal/cli/run.go index d5aff9178..da3679af7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1266,7 +1266,13 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep // 9d. Extract target repo back to host. SafeDownload removes dangerous // symlinks (absolute or repo-escaping) and .git/hooks/ to prevent sandbox escape. - if clearErr := os.RemoveAll(hostRepositoryDir); clearErr != nil { + // + // Clear the contents of the directory rather than removing the directory + // itself. Removing the directory would invalidate the inode, which breaks + // any shell whose CWD points at it (the next command in that shell would + // fail with "getwd: no such file or directory"). + printer.StepInfo(fmt.Sprintf("Clearing %s before extraction", hostRepositoryDir)) + if clearErr := clearDirContents(hostRepositoryDir); clearErr != nil { return fmt.Errorf("clearing local repo %s before extraction: %w", hostRepositoryDir, clearErr) } repoExtractStart := time.Now() @@ -2972,3 +2978,19 @@ func containedLocalPath(baseDir, source string) (string, error) { } return real, nil } + +// clearDirContents removes all entries inside dir without removing dir itself. +// This preserves the directory inode so that shells whose CWD points at dir +// continue to work after the call. +func clearDirContents(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index f43fa1630..00b24cb1e 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3494,6 +3494,47 @@ func TestRunAgent_StatusNotifierSetup(t *testing.T) { assert.Contains(t, err.Error(), "openshell") } +func TestClearDirContents(t *testing.T) { + t.Run("populated directory", func(t *testing.T) { + dir := t.TempDir() + + // Create some files and a subdirectory. + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "sub"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b"), 0o644)) + + // Grab inode before clearing. + infoBefore, err := os.Stat(dir) + require.NoError(t, err) + + require.NoError(t, clearDirContents(dir)) + + // Directory itself still exists with the same inode. + infoAfter, err := os.Stat(dir) + require.NoError(t, err) + assert.True(t, os.SameFile(infoBefore, infoAfter), "directory inode should be preserved") + + // Contents are gone. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("empty directory", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, clearDirContents(dir)) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("non-existent directory", func(t *testing.T) { + err := clearDirContents(filepath.Join(t.TempDir(), "does-not-exist")) + require.Error(t, err) + }) +} + func TestResolveBackendFromConfigData_OrgConfig(t *testing.T) { t.Parallel()