mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
agentssh's CommandEnv, sftpHandler, and agentproc each resolved the session working directory on their own and had drifted: sftpHandler used the configured directory without checking it exists and bypassed the injected EnvInfoer, while the others stat-checked and fell back to home. Home and shell lookups could also skip the EnvInfoer seam through the exported usershell.HomeDir and Get. Resolve through a single usershell.ResolveWorkingDirectory and confine host home and shell lookups to usershell, so SSH sessions, the process API, and tests can no longer diverge and the injected environment is always honored. This also fixes SFTP landing in a configured directory that no longer exists. Refs coder/coder#26099
291 lines
8.0 KiB
Go
291 lines
8.0 KiB
Go
package agentproc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/spf13/afero"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/agent/agentchat"
|
|
"github.com/coder/coder/v2/agent/agentexec"
|
|
"github.com/coder/coder/v2/agent/agentgit"
|
|
"github.com/coder/coder/v2/agent/usershell"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
const (
|
|
// maxWaitDuration is the maximum time a blocking
|
|
// process output request can wait, regardless of
|
|
// what the client requests.
|
|
maxWaitDuration = 5 * time.Minute
|
|
)
|
|
|
|
// API exposes process-related operations through the agent.
|
|
type API struct {
|
|
logger slog.Logger
|
|
manager *manager
|
|
pathStore *agentgit.PathStore
|
|
}
|
|
|
|
// NewAPI creates a new process API handler.
|
|
func NewAPI(logger slog.Logger, execer agentexec.Execer, fs afero.Fs, pathStore *agentgit.PathStore, envInfo usershell.EnvInfoer, updateEnv func(current []string) (updated []string, err error), workingDir func() string) *API {
|
|
return &API{
|
|
logger: logger,
|
|
manager: newManager(logger, execer, fs, envInfo, updateEnv, workingDir),
|
|
pathStore: pathStore,
|
|
}
|
|
}
|
|
|
|
// Close shuts down the process manager, killing all running
|
|
// processes.
|
|
func (api *API) Close() error {
|
|
return api.manager.Close()
|
|
}
|
|
|
|
// Routes returns the HTTP handler for process-related routes.
|
|
func (api *API) Routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Post("/start", api.handleStartProcess)
|
|
r.Get("/list", api.handleListProcesses)
|
|
r.Get("/{id}/output", api.handleProcessOutput)
|
|
r.Post("/{id}/signal", api.handleSignalProcess)
|
|
return r
|
|
}
|
|
|
|
// handleStartProcess starts a new process.
|
|
func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var req workspacesdk.StartProcessRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Request body must be valid JSON.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Command == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Command is required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
var chatID string
|
|
if chatContext, ok := agentchat.FromContext(ctx); ok {
|
|
chatID = chatContext.ID.String()
|
|
}
|
|
|
|
proc, err := api.manager.start(req, chatID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to start process.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Notify git watchers after the process finishes so that
|
|
// file changes made by the command are visible in the scan.
|
|
// If a workdir is provided, track it as a path as well.
|
|
if api.pathStore != nil {
|
|
if chatContext, ok := agentchat.FromContext(ctx); ok {
|
|
allIDs := append([]uuid.UUID{chatContext.ID}, chatContext.AncestorIDs...)
|
|
go func() {
|
|
<-proc.done
|
|
if req.WorkDir != "" {
|
|
api.pathStore.AddPaths(allIDs, []string{req.WorkDir})
|
|
} else {
|
|
api.pathStore.Notify(allIDs)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{
|
|
ID: proc.id,
|
|
Started: true,
|
|
})
|
|
}
|
|
|
|
// handleListProcesses lists all tracked processes.
|
|
func (api *API) handleListProcesses(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var chatID string
|
|
if chatContext, ok := agentchat.FromContext(ctx); ok {
|
|
chatID = chatContext.ID.String()
|
|
}
|
|
|
|
infos := api.manager.list(chatID)
|
|
|
|
// Sort by running state (running first), then by started_at
|
|
// descending so the most recent processes appear first.
|
|
sort.Slice(infos, func(i, j int) bool {
|
|
if infos[i].Running != infos[j].Running {
|
|
return infos[i].Running
|
|
}
|
|
return infos[i].StartedAt > infos[j].StartedAt
|
|
})
|
|
|
|
// Cap the response to avoid bloating LLM context.
|
|
const maxListProcesses = 10
|
|
if len(infos) > maxListProcesses {
|
|
infos = infos[:maxListProcesses]
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ListProcessesResponse{
|
|
Processes: infos,
|
|
})
|
|
}
|
|
|
|
// handleProcessOutput returns the output of a process.
|
|
func (api *API) handleProcessOutput(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
logger := api.logger.With(agentchat.Fields(ctx)...)
|
|
|
|
id := chi.URLParam(r, "id")
|
|
proc, ok := api.manager.get(id)
|
|
if !ok {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Process %q not found.", id),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Enforce chat ID isolation. If the request carries
|
|
// a chat context, only allow access to processes
|
|
// belonging to that chat.
|
|
if chatContext, ok := agentchat.FromContext(ctx); ok {
|
|
if proc.chatID != "" && proc.chatID != chatContext.ID.String() {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Process %q not found.", id),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for blocking mode via query params.
|
|
waitStr := r.URL.Query().Get("wait")
|
|
wantWait := waitStr == "true"
|
|
|
|
if wantWait {
|
|
// Extend the write deadline so the HTTP server's
|
|
// WriteTimeout does not kill the connection while
|
|
// we block.
|
|
rc := http.NewResponseController(rw)
|
|
// Add headroom beyond the wait timeout so there's time to
|
|
// write the response after the blocking wait completes.
|
|
if err := rc.SetWriteDeadline(time.Now().Add(maxWaitDuration + 30*time.Second)); err != nil {
|
|
logger.Error(ctx, "extend write deadline for blocking process output",
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
|
|
// Cap the wait at maxWaitDuration regardless of
|
|
// client-supplied timeout.
|
|
waitCtx, waitCancel := context.WithTimeout(ctx, maxWaitDuration)
|
|
defer waitCancel()
|
|
|
|
_ = proc.waitForOutput(waitCtx)
|
|
// Fall through to read snapshot below.
|
|
}
|
|
|
|
// Read info before output to avoid a TOCTOU race. The exit
|
|
// goroutine completes all buffer writes (cmd.Wait) before
|
|
// setting running=false, so if info reports the process as
|
|
// exited, the subsequent output read is guaranteed to reflect
|
|
// the final buffer state.
|
|
info := proc.info()
|
|
output, truncated := proc.output()
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.ProcessOutputResponse{
|
|
Output: output,
|
|
Truncated: truncated,
|
|
Running: info.Running,
|
|
ExitCode: info.ExitCode,
|
|
})
|
|
}
|
|
|
|
// handleSignalProcess sends a signal to a running process.
|
|
func (api *API) handleSignalProcess(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
id := chi.URLParam(r, "id")
|
|
|
|
// Enforce chat ID isolation.
|
|
if chatContext, ok := agentchat.FromContext(ctx); ok {
|
|
proc, procOK := api.manager.get(id)
|
|
if procOK && proc.chatID != "" && proc.chatID != chatContext.ID.String() {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Process %q not found.", id),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
var req workspacesdk.SignalProcessRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Request body must be valid JSON.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Signal == "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Signal is required.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Signal != "kill" && req.Signal != "terminate" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf(
|
|
"Unsupported signal %q. Use \"kill\" or \"terminate\".",
|
|
req.Signal,
|
|
),
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := api.manager.signal(id, req.Signal); err != nil {
|
|
switch {
|
|
case errors.Is(err, errProcessNotFound):
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Process %q not found.", id),
|
|
})
|
|
case errors.Is(err, errProcessNotRunning):
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf(
|
|
"Process %q is not running.", id,
|
|
),
|
|
})
|
|
default:
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to signal process.",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: fmt.Sprintf(
|
|
"Signal %q sent to process %q.", req.Signal, id,
|
|
),
|
|
})
|
|
}
|