From 55efa133cf9c184aac02e5cefd208286ec8fdf9d Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 12:20:53 -0400 Subject: [PATCH 1/2] fix: return 400 instead of 500 for user-caused process start failures Bad commands (not found, bad path, permission denied) were returning 500, polluting backend alerts and analytics. Map these to 400 since they are client input errors, not server failures. Made-with: Cursor --- server/cmd/api/api/process.go | 15 +++++++++++++++ server/cmd/api/api/process_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/server/cmd/api/api/process.go b/server/cmd/api/api/process.go index 8367637f..c9df0296 100644 --- a/server/cmd/api/api/process.go +++ b/server/cmd/api/api/process.go @@ -64,6 +64,12 @@ func (h *processHandle) setExited(code int) { h.mu.Unlock() } +func isUserCmdError(err error) bool { + return errors.Is(err, exec.ErrNotFound) || + errors.Is(err, os.ErrNotExist) || + errors.Is(err, os.ErrPermission) +} + func buildCmd(body *oapi.ProcessExecRequest) (*exec.Cmd, error) { if body == nil || body.Command == "" { return nil, errors.New("command required") @@ -177,6 +183,9 @@ func (s *ApiService) ProcessExec(ctx context.Context, request oapi.ProcessExecRe defer cancel() } if err := cmd.Start(); err != nil { + if isUserCmdError(err) { + return oapi.ProcessExec400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } log.Error("failed to start process", "err", err) return oapi.ProcessExec500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil } @@ -263,6 +272,9 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn var errStart error ptyFile, errStart = pty.Start(cmd) if errStart != nil { + if isUserCmdError(errStart) { + return oapi.ProcessSpawn400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: errStart.Error()}}, nil + } log.Error("failed to start PTY process", "err", errStart) return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil } @@ -294,6 +306,9 @@ func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawn return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to open stdin"}}, nil } if err := cmd.Start(); err != nil { + if isUserCmdError(err) { + return oapi.ProcessSpawn400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } log.Error("failed to start process", "err", err) return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil } diff --git a/server/cmd/api/api/process_test.go b/server/cmd/api/api/process_test.go index 844a8aff..ca1f9bdd 100644 --- a/server/cmd/api/api/process_test.go +++ b/server/cmd/api/api/process_test.go @@ -203,6 +203,30 @@ func TestProcessNotFoundRoutes(t *testing.T) { } } +func TestProcessExec_CommandNotFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + body := &oapi.ProcessExecRequest{Command: "nonexistent_binary_that_does_not_exist"} + resp, err := svc.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: body}) + require.NoError(t, err) + _, ok := resp.(oapi.ProcessExec400JSONResponse) + require.True(t, ok, "expected 400 for nonexistent command, got %T", resp) +} + +func TestProcessSpawn_CommandNotFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle), stz: scaletozero.NewNoopController()} + + body := &oapi.ProcessSpawnRequest{Command: "nonexistent_binary_that_does_not_exist"} + resp, err := svc.ProcessSpawn(ctx, oapi.ProcessSpawnRequestObject{Body: body}) + require.NoError(t, err) + _, ok := resp.(oapi.ProcessSpawn400JSONResponse) + require.True(t, ok, "expected 400 for nonexistent command, got %T", resp) +} + func TestBuildCmd_AsRootSetsCredential(t *testing.T) { t.Parallel() asRoot := true From 30d1881d77c0409b5ee36af7e4a87eeba7b509aa Mon Sep 17 00:00:00 2001 From: hiroTamada Date: Wed, 1 Apr 2026 14:51:39 -0400 Subject: [PATCH 2/2] broaden isUserCmdError per review feedback Add exec.ErrDot, syscall.EISDIR, syscall.ENOEXEC, syscall.ENOTDIR. Made-with: Cursor --- server/cmd/api/api/process.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/cmd/api/api/process.go b/server/cmd/api/api/process.go index c9df0296..a59d8e81 100644 --- a/server/cmd/api/api/process.go +++ b/server/cmd/api/api/process.go @@ -66,8 +66,12 @@ func (h *processHandle) setExited(code int) { func isUserCmdError(err error) bool { return errors.Is(err, exec.ErrNotFound) || + errors.Is(err, exec.ErrDot) || errors.Is(err, os.ErrNotExist) || - errors.Is(err, os.ErrPermission) + errors.Is(err, os.ErrPermission) || + errors.Is(err, syscall.EISDIR) || + errors.Is(err, syscall.ENOEXEC) || + errors.Is(err, syscall.ENOTDIR) } func buildCmd(body *oapi.ProcessExecRequest) (*exec.Cmd, error) {