-
Notifications
You must be signed in to change notification settings - Fork 406
fix(board): store paths home-relative so shared state works in sandboxes #3510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,6 @@ import ( | |
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/docker/docker-agent/pkg/paths" | ||
| "github.com/docker/docker-agent/pkg/userconfig" | ||
| ) | ||
|
|
||
|
|
@@ -146,7 +145,7 @@ func (a *App) Projects() []Project { | |
| } | ||
| projects := make([]Project, 0, len(a.config.Board.Projects)) | ||
| for _, p := range a.config.Board.Projects { | ||
| projects = append(projects, Project{Name: p.Name, Path: p.Path, Agent: p.Agent}) | ||
| projects = append(projects, Project{Name: p.Name, Path: expandHome(p.Path), Agent: p.Agent}) | ||
| } | ||
| return projects | ||
| } | ||
|
|
@@ -178,8 +177,10 @@ func (a *App) AddProject(p Project) error { | |
| } | ||
| } | ||
| a.config.Board.Projects = append(a.config.Board.Projects, userconfig.BoardProject{ | ||
| Name: p.Name, | ||
| Path: p.Path, | ||
| Name: p.Name, | ||
| // Stored ~-contracted so the shared config works across | ||
| // environments whose home differs (host vs. docker sandbox). | ||
| Path: contractHome(p.Path), | ||
| Agent: p.Agent, | ||
| }) | ||
| return a.saveConfigLocked() | ||
|
|
@@ -193,10 +194,7 @@ func normalizeProjectPath(path string) (string, error) { | |
| if path == "" { | ||
| return "", errors.New("project path is required") | ||
| } | ||
| if path == "~" || strings.HasPrefix(path, "~/") { | ||
| path = filepath.Join(paths.GetHomeDir(), strings.TrimPrefix(path[1:], "/")) | ||
| } | ||
| abs, err := filepath.Abs(path) | ||
| abs, err := filepath.Abs(expandHome(path)) | ||
| if err != nil { | ||
| return "", fmt.Errorf("resolve project path: %w", err) | ||
| } | ||
|
|
@@ -237,6 +235,13 @@ func (a *App) CreateCard(project Project, prompt string) (card *Card, err error) | |
| agent = DefaultAgent | ||
| } | ||
|
|
||
| // Checked before launch because tmux silently falls back to $HOME when | ||
| // its start directory is missing, which would surface as a confusing | ||
| // worktree error from the agent instead of this one. | ||
| if !isGitRepo(a.ctx, project.Path) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] Context cancellation or missing
…even though the real cause is an execution failure, not an invalid path. This message guides the user to remove and re-add the project, which won't help. In practice |
||
| return nil, fmt.Errorf("project path %s is not a git repository in this environment; re-add the project here", project.Path) | ||
| } | ||
|
|
||
| worktreeName := newWorktreeName() | ||
| branch := worktreeBranch(worktreeName) | ||
| wtPath := worktreeDir(worktreeName) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package board | ||
|
|
||
| import ( | ||
| "path/filepath" | ||
| "strings" | ||
|
|
||
| "github.com/docker/docker-agent/pkg/paths" | ||
| ) | ||
|
|
||
| // The board's project list and card state live in files shared across | ||
| // environments with different home directories — e.g. a host and a docker | ||
| // sandbox that mirrors the host's home-relative mounts (~/src, ~/.cagent). | ||
| // Paths under home are therefore persisted as "~/…" and expanded against | ||
| // the current home on load, so the same state works in both. | ||
|
|
||
| // contractHome replaces a current-home prefix with "~", using forward | ||
| // slashes so the stored form is platform-neutral. | ||
| func contractHome(path string) string { | ||
| home := paths.GetHomeDir() | ||
| if home == "" || path == "" { | ||
| return path | ||
| } | ||
| if path == home { | ||
| return "~" | ||
| } | ||
| if rel, ok := strings.CutPrefix(path, home+string(filepath.Separator)); ok { | ||
| return "~/" + filepath.ToSlash(rel) | ||
| } | ||
| return path | ||
| } | ||
|
|
||
| // expandHome resolves a leading "~" against the current home directory. | ||
| // Other paths — and "~" paths when the home cannot be determined — are | ||
| // returned unchanged, so a failed lookup can never turn a stored path into | ||
| // a relative one (which a later save would persist, corrupting the shared | ||
| // state). | ||
| func expandHome(path string) string { | ||
| if path == "~" || strings.HasPrefix(path, "~/") { | ||
| home := paths.GetHomeDir() | ||
| if home == "" { | ||
| return path | ||
| } | ||
| return filepath.Join(home, strings.TrimPrefix(path[1:], "/")) | ||
| } | ||
| return path | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package board | ||
|
|
||
| import ( | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| "github.com/docker/docker-agent/pkg/paths" | ||
| ) | ||
|
|
||
| func TestContractHome(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| home := paths.GetHomeDir() | ||
|
|
||
| assert.Equal(t, "~/src/repo", contractHome(filepath.Join(home, "src", "repo"))) | ||
| assert.Equal(t, "~", contractHome(home)) | ||
| assert.Empty(t, contractHome("")) | ||
| // A path outside home is stored as-is. | ||
| assert.Equal(t, filepath.FromSlash("/opt/repo"), contractHome(filepath.FromSlash("/opt/repo"))) | ||
| // A sibling of home must not lose its prefix. | ||
| assert.Equal(t, home+"-other", contractHome(home+"-other")) | ||
| } | ||
|
|
||
| func TestExpandHome(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| home := paths.GetHomeDir() | ||
|
|
||
| assert.Equal(t, filepath.Join(home, "src", "repo"), expandHome("~/src/repo")) | ||
| assert.Equal(t, home, expandHome("~")) | ||
| assert.Empty(t, expandHome("")) | ||
| assert.Equal(t, filepath.FromSlash("/opt/repo"), expandHome(filepath.FromSlash("/opt/repo"))) | ||
| // Only a leading ~/ is expanded, not ~ elsewhere or ~user. | ||
| assert.Equal(t, "~user/src", expandHome("~user/src")) | ||
| } | ||
|
|
||
| // TestExpandHomeNoHome proves a failed home lookup leaves "~" paths | ||
| // untouched instead of turning them into relative paths that a later save | ||
| // would persist. | ||
| func TestExpandHomeNoHome(t *testing.T) { | ||
| t.Setenv("HOME", "") | ||
| t.Setenv("USERPROFILE", "") | ||
|
|
||
| assert.Equal(t, "~/src/repo", expandHome("~/src/repo")) | ||
| assert.Equal(t, "~", expandHome("~")) | ||
| } | ||
|
|
||
| func TestContractExpandRoundTrip(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| p := filepath.Join(paths.GetHomeDir(), ".cagent", "worktrees", "board-abc") | ||
| assert.Equal(t, p, expandHome(contractHome(p))) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MEDIUM]
isGitReposilently passes whenproject.Pathis empty, running git in the process's cwdCreateCardcallsisGitRepo(a.ctx, project.Path)without first validating thatproject.Pathis non-empty. In Go'sos/exec, settingcmd.Dir = ""causes the subprocess to inherit the parent's working directory — so if the board process happens to be running inside a git repository,isGitRepo("")returnstrue, card creation succeeds, and a card with no valid path is committed to state.An empty
project.Pathcan arise ifexpandHomeis called on a""stored value (it returns""unchanged), or if a caller constructs aProjectstruct without settingPath.The companion
AddProjectalready guards against this vianormalizeProjectPath, which rejects empty paths explicitly.CreateCardneeds a matching guard:This guard should be placed before the
isGitRepocall (line 241).