From 90a1a530150e42bfe95707ef0f1354284e3671c8 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Tue, 7 Jul 2026 12:14:14 -0400 Subject: [PATCH 1/2] fix(runner): preserve target repo directory inode during extraction Replace os.RemoveAll(hostRepositoryDir) with clearDirContents(), which removes all entries inside the directory without removing the directory itself. This preserves the directory inode so that shells whose CWD points at the target repo continue to work after extraction. Previously, the delete-then-recreate cycle would orphan any shell sitting in the directory, causing subsequent commands to fail with "getwd: no such file or directory". Also adds a StepInfo log line showing the full absolute path being cleared, making it obvious what happened if something goes wrong. Relates-to: #2075 Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/run.go | 24 +++++++++++++++++++++++- internal/cli/run_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/cli/run.go b/internal/cli/run.go index 2331699cf..98092fbf2 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1117,7 +1117,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() @@ -2806,3 +2812,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 08b98283d..df83d4295 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3493,3 +3493,28 @@ func TestRunAgent_StatusNotifierSetup(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "openshell") } + +func TestClearDirContents(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) +} From be97fb1821e5f53973eaf0e24d67557b07475418 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 9 Jul 2026 15:59:51 -0400 Subject: [PATCH 2/2] test(runner): add edge-case sub-tests for clearDirContents Add sub-tests for empty directory (no-op) and non-existent directory (returns error) to document the function's contract. Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/run_test.go | 50 ++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 29d590075..00b24cb1e 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3495,28 +3495,44 @@ func TestRunAgent_StatusNotifierSetup(t *testing.T) { } func TestClearDirContents(t *testing.T) { - dir := t.TempDir() + 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)) + // 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) + // Grab inode before clearing. + infoBefore, err := os.Stat(dir) + require.NoError(t, err) - require.NoError(t, clearDirContents(dir)) + 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") + // 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) + // 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) {