mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(agent): default process working directory to agent dir or $HOME (#23224)
Processes started via the agent process API inherited the agent's own working directory (/tmp/coder.xxx) when no WorkDir was specified. SSH sessions already use a fallback chain: configured agent directory > $HOME. This wires the same manifest directory closure into the process manager so the priority is now: explicit req.WorkDir > agent configured dir > $HOME The resolved directory is recorded on the process struct so ProcessInfo.WorkDir and pathStore notifications reflect where the process actually ran.
This commit is contained in:
+6
-1
@@ -385,7 +385,12 @@ func (a *agent) init() {
|
|||||||
|
|
||||||
pathStore := agentgit.NewPathStore()
|
pathStore := agentgit.NewPathStore()
|
||||||
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore)
|
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem, pathStore)
|
||||||
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore)
|
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv, pathStore, func() string {
|
||||||
|
if m := a.manifest.Load(); m != nil {
|
||||||
|
return m.Directory
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...)
|
gitOpts := append([]agentgit.Option{agentgit.WithClock(a.clock)}, a.gitAPIOptions...)
|
||||||
a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...)
|
a.gitAPI = agentgit.NewAPI(a.logger.Named("git"), pathStore, gitOpts...)
|
||||||
desktop := agentdesktop.NewPortableDesktop(
|
desktop := agentdesktop.NewPortableDesktop(
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ type API struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI creates a new process API handler.
|
// NewAPI creates a new process API handler.
|
||||||
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore) *API {
|
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), pathStore *agentgit.PathStore, workingDir func() string) *API {
|
||||||
return &API{
|
return &API{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
manager: newManager(logger, execer, updateEnv),
|
manager: newManager(logger, execer, updateEnv, workingDir),
|
||||||
pathStore: pathStore,
|
pathStore: pathStore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -97,18 +98,25 @@ func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.
|
|||||||
// execer, returning the handler and API.
|
// execer, returning the handler and API.
|
||||||
func newTestAPI(t *testing.T) http.Handler {
|
func newTestAPI(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return newTestAPIWithUpdateEnv(t, nil)
|
return newTestAPIWithOptions(t, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestAPIWithUpdateEnv creates a new API with an optional
|
// newTestAPIWithUpdateEnv creates a new API with an optional
|
||||||
// updateEnv hook for testing environment injection.
|
// updateEnv hook for testing environment injection.
|
||||||
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
|
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
return newTestAPIWithOptions(t, updateEnv, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestAPIWithOptions creates a new API with optional
|
||||||
|
// updateEnv and workingDir hooks.
|
||||||
|
func newTestAPIWithOptions(t *testing.T, updateEnv func([]string) ([]string, error), workingDir func() string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
logger := slogtest.Make(t, &slogtest.Options{
|
logger := slogtest.Make(t, &slogtest.Options{
|
||||||
IgnoreErrors: true,
|
IgnoreErrors: true,
|
||||||
}).Leveled(slog.LevelDebug)
|
}).Leveled(slog.LevelDebug)
|
||||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil)
|
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv, nil, workingDir)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = api.Close()
|
_ = api.Close()
|
||||||
})
|
})
|
||||||
@@ -253,6 +261,100 @@ func TestStartProcess(t *testing.T) {
|
|||||||
require.Contains(t, resp.Output, "marker.txt")
|
require.Contains(t, resp.Output, "marker.txt")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultWorkDirIsHome", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// No working directory closure, so the process
|
||||||
|
// should fall back to $HOME. We verify through
|
||||||
|
// the process list API which reports the resolved
|
||||||
|
// working directory using native OS paths,
|
||||||
|
// avoiding shell path format mismatches on
|
||||||
|
// Windows (Git Bash returns POSIX paths).
|
||||||
|
handler := newTestAPI(t)
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||||
|
Command: "echo ok",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := waitForExit(t, handler, id)
|
||||||
|
require.NotNil(t, resp.ExitCode)
|
||||||
|
require.Equal(t, 0, *resp.ExitCode)
|
||||||
|
|
||||||
|
w := getList(t, handler)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var listResp workspacesdk.ListProcessesResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||||
|
var proc *workspacesdk.ProcessInfo
|
||||||
|
for i := range listResp.Processes {
|
||||||
|
if listResp.Processes[i].ID == id {
|
||||||
|
proc = &listResp.Processes[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, proc, "process not found in list")
|
||||||
|
require.Equal(t, homeDir, proc.WorkDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultWorkDirFromClosure", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The closure provides a valid directory, so the
|
||||||
|
// process should start there. Use the marker file
|
||||||
|
// pattern to avoid path format mismatches on
|
||||||
|
// Windows.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||||
|
return tmpDir
|
||||||
|
})
|
||||||
|
|
||||||
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||||
|
Command: "touch marker.txt && ls marker.txt",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := waitForExit(t, handler, id)
|
||||||
|
require.NotNil(t, resp.ExitCode)
|
||||||
|
require.Equal(t, 0, *resp.ExitCode)
|
||||||
|
require.Contains(t, resp.Output, "marker.txt")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DefaultWorkDirClosureNonExistentFallsBackToHome", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The closure returns a path that doesn't exist,
|
||||||
|
// so the process should fall back to $HOME.
|
||||||
|
handler := newTestAPIWithOptions(t, nil, func() string {
|
||||||
|
return "/tmp/nonexistent-dir-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
})
|
||||||
|
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
|
||||||
|
Command: "echo ok",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := waitForExit(t, handler, id)
|
||||||
|
require.NotNil(t, resp.ExitCode)
|
||||||
|
require.Equal(t, 0, *resp.ExitCode)
|
||||||
|
|
||||||
|
w := getList(t, handler)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var listResp workspacesdk.ListProcessesResponse
|
||||||
|
require.NoError(t, json.NewDecoder(w.Body).Decode(&listResp))
|
||||||
|
var proc *workspacesdk.ProcessInfo
|
||||||
|
for i := range listResp.Processes {
|
||||||
|
if listResp.Processes[i].ID == id {
|
||||||
|
proc = &listResp.Processes[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, proc, "process not found in list")
|
||||||
|
require.Equal(t, homeDir, proc.WorkDir)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("CustomEnv", func(t *testing.T) {
|
t.Run("CustomEnv", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -781,7 +883,7 @@ func TestHandleStartProcess_ChatHeaders_EmptyWorkDir_StillNotifies(t *testing.T)
|
|||||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||||
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) {
|
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, func(current []string) ([]string, error) {
|
||||||
return current, nil
|
return current, nil
|
||||||
}, pathStore)
|
}, pathStore, nil)
|
||||||
defer api.Close()
|
defer api.Close()
|
||||||
|
|
||||||
routes := api.Routes()
|
routes := api.Routes()
|
||||||
|
|||||||
+38
-17
@@ -70,23 +70,25 @@ func (p *process) output() (string, *workspacesdk.ProcessTruncation) {
|
|||||||
|
|
||||||
// manager tracks processes spawned by the agent.
|
// manager tracks processes spawned by the agent.
|
||||||
type manager struct {
|
type manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
logger slog.Logger
|
logger slog.Logger
|
||||||
execer agentexec.Execer
|
execer agentexec.Execer
|
||||||
clock quartz.Clock
|
clock quartz.Clock
|
||||||
procs map[string]*process
|
procs map[string]*process
|
||||||
closed bool
|
closed bool
|
||||||
updateEnv func(current []string) (updated []string, err error)
|
updateEnv func(current []string) (updated []string, err error)
|
||||||
|
workingDir func() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// newManager creates a new process manager.
|
// newManager creates a new process manager.
|
||||||
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *manager {
|
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error), workingDir func() string) *manager {
|
||||||
return &manager{
|
return &manager{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
execer: execer,
|
execer: execer,
|
||||||
clock: quartz.NewReal(),
|
clock: quartz.NewReal(),
|
||||||
procs: make(map[string]*process),
|
procs: make(map[string]*process),
|
||||||
updateEnv: updateEnv,
|
updateEnv: updateEnv,
|
||||||
|
workingDir: workingDir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +111,7 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
|||||||
// the process is not tied to any HTTP request.
|
// the process is not tied to any HTTP request.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cmd := m.execer.CommandContext(ctx, "sh", "-c", req.Command)
|
cmd := m.execer.CommandContext(ctx, "sh", "-c", req.Command)
|
||||||
if req.WorkDir != "" {
|
cmd.Dir = m.resolveWorkDir(req.WorkDir)
|
||||||
cmd.Dir = req.WorkDir
|
|
||||||
}
|
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
cmd.SysProcAttr = procSysProcAttr()
|
cmd.SysProcAttr = procSysProcAttr()
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ func (m *manager) start(req workspacesdk.StartProcessRequest, chatID string) (*p
|
|||||||
proc := &process{
|
proc := &process{
|
||||||
id: id,
|
id: id,
|
||||||
command: req.Command,
|
command: req.Command,
|
||||||
workDir: req.WorkDir,
|
workDir: cmd.Dir,
|
||||||
background: req.Background,
|
background: req.Background,
|
||||||
chatID: chatID,
|
chatID: chatID,
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
@@ -319,3 +319,24 @@ func (m *manager) Close() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveWorkDir returns the directory a process should start in.
|
||||||
|
// Priority: explicit request dir > agent configured dir > $HOME.
|
||||||
|
// Falls through when a candidate is empty or does not exist on
|
||||||
|
// disk, matching the behavior of SSH sessions.
|
||||||
|
func (m *manager) resolveWorkDir(requested string) string {
|
||||||
|
if requested != "" {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
if m.workingDir != nil {
|
||||||
|
if dir := m.workingDir(); dir != "" {
|
||||||
|
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user