Skip to content
Closed
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
59 changes: 55 additions & 4 deletions agent/agentproc/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,40 @@ func (api *API) Routes() http.Handler {
r := chi.NewRouter()
r.Post("/start", api.handleStartProcess)
r.Get("/list", api.handleListProcesses)
r.Get("/tokens", api.handleProcessByToken)
r.Get("/{id}/output", api.handleProcessOutput)
r.Post("/{id}/signal", api.handleSignalProcess)
return r
}

// handleProcessByToken reports whether an idempotency token has a
// process attached. It always answers 200 for a known route, so an
// HTTP 404 unambiguously identifies an agent that predates the
// endpoint. The token travels as a query parameter because it is
// an opaque string that can contain any byte: query decoding is a
// single well-defined unescape, while a path segment's decoding
// depends on how the router matched it.
func (api *API) handleProcessByToken(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()

token := r.URL.Query().Get("token")
var chatID string
if chatContext, chatOK := agentchat.FromContext(ctx); chatOK {
chatID = chatContext.ID.String()
}
proc, pending, ok := api.manager.byToken(token, chatID)

resp := workspacesdk.ProcessByTokenResponse{
Found: ok,
Pending: pending,
TokenIndexAgeMS: api.manager.tokenIndexAge().Milliseconds(),
}
if ok {
resp.ProcessID = proc.id
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}

// handleStartProcess starts a new process.
func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
Expand All @@ -87,8 +116,26 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
chatID = chatContext.ID.String()
}

proc, err := api.manager.start(req, chatID)
proc, attached, err := api.manager.start(ctx, req, chatID)
if err != nil {
if errors.Is(err, errClientTokenMismatch) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Client token was already used to start a process with different parameters.",
Detail: err.Error(),
})
return
}
if errors.Is(err, errTokenWaitAborted) {
// The reservation owner may still publish a process
// under this token, so the outcome is unresolved. 409
// tells callers to keep the dispatch recoverable
// instead of treating it as failed-before-spawn.
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Timed out waiting for the concurrent start that owns this client token.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to start process.",
Detail: err.Error(),
Expand All @@ -99,7 +146,9 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
// 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 {
// Attaching returns a process whose watcher was already
// registered by the request that started it.
if api.pathStore != nil && !attached {
if chatContext, ok := agentchat.FromContext(ctx); ok {
allIDs := append([]uuid.UUID{chatContext.ID}, chatContext.AncestorIDs...)
go func() {
Expand All @@ -114,8 +163,10 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
}

httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{
ID: proc.id,
Started: true,
ID: proc.id,
Started: !attached,
ClientToken: req.ClientToken,
Attached: attached,
})
}

Expand Down
285 changes: 285 additions & 0 deletions agent/agentproc/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -533,6 +534,290 @@ func TestStartProcess(t *testing.T) {
})
}

func TestStartProcessClientToken(t *testing.T) {
t.Parallel()

t.Run("SameTokenAttaches", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
}

w := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w.Code)
var first workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&first))
require.True(t, first.Started)
require.False(t, first.Attached)
require.Equal(t, "tok-1", first.ClientToken)

w2 := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w2.Code)
var second workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w2.Body).Decode(&second))
require.Equal(t, first.ID, second.ID)
require.False(t, second.Started)
require.True(t, second.Attached)
require.Equal(t, "tok-1", second.ClientToken)
})

t.Run("SameTokenDifferentCommandConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo goodbye",
ClientToken: "tok-1",
})
require.Equal(t, http.StatusConflict, w2.Code)
})

t.Run("SameTokenWaiterTimeoutConflicts", func(t *testing.T) {
t.Parallel()

release := make(chan struct{})
releaseOnce := sync.OnceFunc(func() { close(release) })
t.Cleanup(releaseOnce)
ownerBlocked := make(chan struct{})
ownerBlockedOnce := sync.OnceFunc(func() { close(ownerBlocked) })
// Block the owning start after it reserves the token;
// updateEnv runs only for spawning starts, so it also
// signals that the reservation is held.
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
ownerBlockedOnce()
<-release
return current, nil
})
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-wait",
}

ownerDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
body, err := json.Marshal(req)
assert.NoError(t, err)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/start", bytes.NewReader(body))
handler.ServeHTTP(w, r)
ownerDone <- w
}()
select {
case <-ownerBlocked:
case <-time.After(testutil.WaitShort):
t.Fatal("owner start never reserved the token")
}

// The waiter's request context expires while the owner is
// blocked. The result must be 409, not 500: the owner may
// still publish a process under this token, so callers
// must keep the dispatch recoverable.
body, err := json.Marshal(req)
require.NoError(t, err)
waitCtx, cancelWait := context.WithCancel(context.Background())
cancelWait()
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(waitCtx, http.MethodPost, "/start", bytes.NewReader(body))
handler.ServeHTTP(w, r)
require.Equal(t, http.StatusConflict, w.Code)

releaseOnce()
select {
case w := <-ownerDone:
require.Equal(t, http.StatusOK, w.Code)
case <-time.After(testutil.WaitLong):
t.Fatal("owner request did not return")
}
})

t.Run("SameTokenDifferentBackgroundConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Background: true,
ClientToken: "tok-1",
})
require.Equal(t, http.StatusConflict, w2.Code)
})

t.Run("SameTokenDifferentEnvConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Env: map[string]string{"FOO": "bar"},
ClientToken: "tok-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Env: map[string]string{"FOO": "baz"},
ClientToken: "tok-1",
})
require.Equal(t, http.StatusConflict, w2.Code)
})

t.Run("SameTokenDifferentChatsConflicts", func(t *testing.T) {
t.Parallel()

// The token index is keyed by the bare token, so reuse
// from a different chat is a parameter mismatch rather
// than an isolated re-start.
handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
}
headerA := http.Header{}
headerA.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())
headerB := http.Header{}
headerB.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())

_ = startAndGetID(t, handler, req, headerA)

w := postStart(t, handler, req, headerB)
require.Equal(t, http.StatusConflict, w.Code)
})

t.Run("NoTokenAlwaysStartsNew", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
}

w := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w.Code)
var first workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&first))
require.True(t, first.Started)
require.False(t, first.Attached)
require.Empty(t, first.ClientToken)

w2 := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w2.Code)
var second workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w2.Body).Decode(&second))
require.True(t, second.Started)
require.NotEqual(t, first.ID, second.ID)
})
}

func TestProcessByToken(t *testing.T) {
t.Parallel()

probe := func(t *testing.T, handler http.Handler, token string, headers ...http.Header) workspacesdk.ProcessByTokenResponse {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(ctx, http.MethodGet, "/tokens?token="+url.QueryEscape(token), nil)
for _, h := range headers {
for k, vals := range h {
for _, v := range vals {
r.Header.Add(k, v)
}
}
}
handler.ServeHTTP(w, r)
require.Equal(t, http.StatusOK, w.Code)
var resp workspacesdk.ProcessByTokenResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
return resp
}

t.Run("Found", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
})

resp := probe(t, handler, "tok-1")
require.True(t, resp.Found)
require.Equal(t, id, resp.ProcessID)
})

t.Run("NotFound", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
resp := probe(t, handler, "tok-missing")
require.False(t, resp.Found)
require.Empty(t, resp.ProcessID)
// The index age lets coderd reject absent-token answers
// from freshly restarted agents.
require.GreaterOrEqual(t, resp.TokenIndexAgeMS, int64(0))
})

t.Run("EscapedToken", func(t *testing.T) {
t.Parallel()

// Tokens are opaque strings, so reserved bytes and literal
// percent escapes must round-trip through the probe URL
// without double-decoding.
for _, token := range []string{
"tok/with reserved?bytes",
"abc%2Fdef",
} {
handler := newTestAPI(t)
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: token,
})

resp := probe(t, handler, token)
require.True(t, resp.Found)
require.Equal(t, id, resp.ProcessID)
}
})

t.Run("ChatIsolation", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
headerA := http.Header{}
headerA.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())
headerB := http.Header{}
headerB.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())

id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
ClientToken: "tok-1",
}, headerA)

own := probe(t, handler, "tok-1", headerA)
require.True(t, own.Found)
require.Equal(t, id, own.ProcessID)

other := probe(t, handler, "tok-1", headerB)
require.False(t, other.Found)
require.Empty(t, other.ProcessID)
})
}

func TestListProcesses(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading