Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions cmd/internal/agentworkspace/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,31 @@ func (cmd *DeleteCmd) Run(ctx context.Context) error {
}
}

// delete workspace folder
removeWorkspaceFolders(workspaceInfo)

return nil
}

// removeWorkspaceFolders deletes the workspace's config folder and cached
// content folder, logging rather than failing on either error since deletion
// should proceed best-effort once the container/daemon are already gone.
func removeWorkspaceFolders(workspaceInfo *provider2.AgentWorkspaceInfo) {
if err := forceRemoveAll(workspaceInfo.Origin); err != nil {
log.Errorf("remove workspace folder: %v", err)
}
return nil
if err := removeContentFolder(workspaceInfo); err != nil {
log.Errorf("remove workspace content folder: %v", err)
}
}

// removeContentFolder deletes the workspace's cached content folder, unless
// it's the user's own local folder mounted directly rather than a devsy copy.
func removeContentFolder(workspaceInfo *provider2.AgentWorkspaceInfo) error {
if workspaceInfo.ContentFolder == "" ||
workspaceInfo.ContentFolder == workspaceInfo.Workspace.Source.LocalFolder {
return nil
}
return forceRemoveAll(workspaceInfo.ContentFolder)
Comment on lines +113 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Trace all assignments and consumers of ContentFolder.
rg -n -C 5 --glob '*.go' 'ContentFolder\s*:|\.ContentFolder\b|func\s+WorkspaceInfo\b' .

# Find cache-root construction and path normalization logic.
rg -n -i -C 5 --glob '*.go' 'content.*(cache|folder)|cache.*content|EvalSymlinks|SameFile|filepath\.Abs' cmd pkg

Repository: devsy-org/devsy

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^cmd/internal/agentworkspace/(delete|.*workspace).*\.go$|^pkg/provider/workspace\.go$|^cmd/internal/agentworkspace/delete_test\.go$)' || true

echo
echo "== delete.go outline/source =="
ast-grep outline cmd/internal/agentworkspace/delete.go --view compact || true
sed -n '1,180p' cmd/internal/agentworkspace/delete.go | cat -n

echo
echo "== delete_test relevant source =="
sed -n '1,210p' cmd/internal/agentworkspace/delete_test.go | cat -n

echo
echo "== provider workspace relevant source =="
sed -n '130,210p' pkg/provider/workspace.go | cat -n

echo
echo "== agent resolveContentFolder full =="
sed -n '268,300p' pkg/agent/agent.go | cat -n

echo
echo "== content directory helpers =="
rg -n -C 4 --glob '*.go' 'func GetWorkspaceContentDir|GetAgentWorkspaceContentDir|AgentWorkspaceContent|contents.*workspace|workspace.*contents' pkg cmd/internal/agentworkspace

Repository: devsy-org/devsy

Length of output: 20287


Resolve filesystem ownership before deleting content folders.

removeContentFolder removes any non-empty ContentFolder that is not byte-for-byte equal to Source.LocalFolder, so equivalent paths such as /src/project/../project or symlink aliases still bypass the local-folder guard. Resolve both ContentFolder and Source.LocalFolder filesystem identity before skipping, and add a boundary check before recursive deletion so a wrong metadata value cannot delete a directory outside the Devsy content area.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/internal/agentworkspace/delete.go` around lines 113 - 118, Update
removeContentFolder to resolve the filesystem identity of both ContentFolder and
Source.LocalFolder before comparing them, treating equivalent paths and symlink
aliases as the same local folder. Before calling forceRemoveAll, validate that
the resolved content path remains within the allowed Devsy content area, and
skip or reject deletion when it falls outside that boundary.

}

func removeContainer(
Expand Down
47 changes: 47 additions & 0 deletions cmd/internal/agentworkspace/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"runtime"
"testing"

provider2 "github.com/devsy-org/devsy/pkg/provider"
)

func skipIfPermissionsNotEnforced(t *testing.T) {
Expand Down Expand Up @@ -120,3 +122,48 @@ func TestForceRemoveAll_EmptyString(t *testing.T) {
t.Fatalf("forceRemoveAll with empty string should not error: %v", err)
}
}

func TestRemoveContentFolder_RemovesDevsyManagedContent(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "contents", "my-workspace")
if err := os.MkdirAll(content, 0o750); err != nil {
t.Fatal(err)
}

workspaceInfo := &provider2.AgentWorkspaceInfo{
ContentFolder: content,
Workspace: &provider2.Workspace{},
}
if err := removeContentFolder(workspaceInfo); err != nil {
t.Fatalf("removeContentFolder failed: %v", err)
}
assertRemoved(t, content)
}

func TestRemoveContentFolder_SkipsUsersOwnLocalFolder(t *testing.T) {
dir := t.TempDir()
local := filepath.Join(dir, "my-project")
if err := os.MkdirAll(local, 0o750); err != nil {
t.Fatal(err)
}

workspaceInfo := &provider2.AgentWorkspaceInfo{
ContentFolder: local,
Workspace: &provider2.Workspace{
Source: provider2.WorkspaceSource{LocalFolder: local},
},
}
if err := removeContentFolder(workspaceInfo); err != nil {
t.Fatalf("removeContentFolder failed: %v", err)
}
if _, err := os.Stat(local); err != nil {
t.Fatalf("expected local folder to survive, got stat err: %v", err)
}
}

func TestRemoveContentFolder_EmptyContentFolder(t *testing.T) {
workspaceInfo := &provider2.AgentWorkspaceInfo{Workspace: &provider2.Workspace{}}
if err := removeContentFolder(workspaceInfo); err != nil {
t.Fatalf("removeContentFolder with empty ContentFolder should not error: %v", err)
}
}
Loading