diff --git a/coderd/aitasks.go b/coderd/aitasks.go index 9c5285fdbc..61c7df396f 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -1,19 +1,25 @@ package coderd import ( + "bytes" "context" "database/sql" + "encoding/json" "errors" "fmt" + "io" + "net" "net/http" + "net/url" + "path" "slices" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" @@ -590,3 +596,288 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) { // Delete build created successfully. rw.WriteHeader(http.StatusAccepted) } + +// taskSend submits task input to the tasks sidebar app by dialing the agent +// directly over the tailnet. We enforce ApplicationConnect RBAC on the +// workspace and validate the sidebar app health. +func (api *API) taskSend(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := chi.URLParam(r, "id") + taskID, err := uuid.Parse(idStr) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr), + }) + return + } + + var req codersdk.TaskSendRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + if req.Input == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Task input is required.", + }) + return + } + + if err = api.authAndDoWithTaskSidebarAppClient(r, taskID, func(ctx context.Context, client *http.Client, appURL *url.URL) error { + status, err := agentapiDoStatusRequest(ctx, client, appURL) + if err != nil { + return err + } + + if status != "stable" { + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Task app is not ready to accept input.", + Detail: fmt.Sprintf("Status: %s", status), + }) + } + + var reqBody struct { + Content string `json:"content"` + Type string `json:"type"` + } + reqBody.Content = req.Input + reqBody.Type = "user" + + req, err := agentapiNewRequest(ctx, http.MethodPost, appURL, "message", reqBody) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Failed to reach task app endpoint.", + Detail: err.Error(), + }) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 128)) + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Task app rejected the message.", + Detail: fmt.Sprintf("Upstream status: %d; Body: %s", resp.StatusCode, body), + }) + } + + // {"$schema":"http://localhost:3284/schemas/MessageResponseBody.json","ok":true} + // {"$schema":"http://localhost:3284/schemas/ErrorModel.json","title":"Unprocessable Entity","status":422,"detail":"validation failed","errors":[{"location":"body.type","value":"oof"}]} + var respBody map[string]any + if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Failed to decode task app response body.", + Detail: err.Error(), + }) + } + + if v, ok := respBody["status"].(string); !ok || v != "ok" { + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Task app rejected the message.", + Detail: fmt.Sprintf("Upstream response: %v", respBody), + }) + } + + return nil + }); err != nil { + httperror.WriteResponseError(ctx, rw, err) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + +// authAndDoWithTaskSidebarAppClient centralizes the shared logic to: +// +// - Fetch the task workspace +// - Authorize ApplicationConnect on the workspace +// - Validate the AI task and sidebar app health +// - Dial the agent and construct an HTTP client to the apps loopback URL +// +// The provided callback receives the context, an HTTP client that dials via the +// agent, and the base app URL (as a value URL) to perform any request. +func (api *API) authAndDoWithTaskSidebarAppClient( + r *http.Request, + taskID uuid.UUID, + do func(ctx context.Context, client *http.Client, appURL *url.URL) error, +) error { + ctx := r.Context() + + workspaceID := taskID + workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID) + if err != nil { + if httpapi.Is404Error(err) { + return httperror.ErrResourceNotFound + } + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }) + } + + // Connecting to applications requires ApplicationConnect on the workspace. + if !api.Authorize(r, policy.ActionApplicationConnect, workspace) { + return httperror.ErrResourceNotFound + } + + data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + } + if len(data.builds) == 0 || len(data.templates) == 0 { + return httperror.ErrResourceNotFound + } + build := data.builds[0] + if build.HasAITask == nil || !*build.HasAITask || build.AITaskSidebarAppID == nil || *build.AITaskSidebarAppID == uuid.Nil { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task is not configured with a sidebar app.", + }) + } + + // Find the sidebar app details to get the URL and validate app health. + sidebarAppID := *build.AITaskSidebarAppID + agentID, sidebarApp, ok := func() (uuid.UUID, codersdk.WorkspaceApp, bool) { + for _, res := range build.Resources { + for _, agent := range res.Agents { + for _, app := range agent.Apps { + if app.ID == sidebarAppID { + return agent.ID, app, true + } + } + } + } + return uuid.Nil, codersdk.WorkspaceApp{}, false + }() + if !ok { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Task sidebar app not found in latest build.", + }) + } + + // Return an informative error if the app isn't healthy rather than trying + // and failing. + switch sidebarApp.Health { + case codersdk.WorkspaceAppHealthDisabled: + // No health check, pass through. + case codersdk.WorkspaceAppHealthInitializing: + return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ + Message: "Task sidebar app is initializing. Try again shortly.", + }) + case codersdk.WorkspaceAppHealthUnhealthy: + return httperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ + Message: "Task sidebar app is unhealthy.", + }) + } + + // Build the direct app URL and dial the agent. + if sidebarApp.URL == "" { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Task sidebar app URL is not configured.", + }) + } + parsedURL, err := url.Parse(sidebarApp.URL) + if err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error parsing task app URL.", + Detail: err.Error(), + }) + } + if parsedURL.Scheme != "http" { + return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Only http scheme is supported for direct agent-dial.", + }) + } + + dialCtx, dialCancel := context.WithTimeout(ctx, time.Second*30) + defer dialCancel() + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agentID) + if err != nil { + return httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Failed to reach task app endpoint.", + Detail: err.Error(), + }) + } + defer release() + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return agentConn.DialContext(ctx, network, addr) + }, + }, + } + return do(ctx, client, parsedURL) +} + +func agentapiNewRequest(ctx context.Context, method string, appURL *url.URL, appURLPath string, body any) (*http.Request, error) { + u := *appURL + u.Path = path.Join(appURL.Path, appURLPath) + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Failed to marshal task app request body.", + Detail: err.Error(), + }) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader) + if err != nil { + return nil, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create task app request.", + Detail: err.Error(), + }) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + return req, nil +} + +func agentapiDoStatusRequest(ctx context.Context, client *http.Client, appURL *url.URL) (string, error) { + req, err := agentapiNewRequest(ctx, http.MethodGet, appURL, "status", nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) + if err != nil { + return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Failed to reach task app endpoint.", + Detail: err.Error(), + }) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Task app status returned an error.", + Detail: fmt.Sprintf("Status code: %d", resp.StatusCode), + }) + } + + // {"$schema":"http://localhost:3284/schemas/StatusResponseBody.json","status":"stable"} + var respBody struct { + Status string `json:"status"` + } + + if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { + return "", httperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ + Message: "Failed to decode task app status response body.", + Detail: err.Error(), + }) + } + + return respBody.Status, nil +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 4b2ae087b6..9b49aa731e 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -1,7 +1,10 @@ package coderd_test import ( + "fmt" + "io" "net/http" + "net/http/httptest" "testing" "time" @@ -9,10 +12,15 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/agent" + "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" @@ -147,9 +155,26 @@ func TestAITasksPrompts(t *testing.T) { func TestTasks(t *testing.T) { t.Parallel() - createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse) codersdk.Template { + type aiTemplateOpts struct { + appURL string + authToken string + } + + type aiTemplateOpt func(*aiTemplateOpts) + + withSidebarURL := func(url string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.appURL = url } } + withAgentToken := func(token string) aiTemplateOpt { return func(o *aiTemplateOpts) { o.authToken = token } } + + createAITemplate := func(t *testing.T, client *codersdk.Client, user codersdk.CreateFirstUserResponse, opts ...aiTemplateOpt) codersdk.Template { t.Helper() + opt := aiTemplateOpts{ + authToken: uuid.New().String(), + } + for _, o := range opts { + o(&opt) + } + // Create a template version that supports AI tasks with the AI Prompt parameter. taskAppID := uuid.New() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ @@ -176,11 +201,15 @@ func TestTasks(t *testing.T) { { Id: uuid.NewString(), Name: "example", + Auth: &proto.Agent_Token{ + Token: opt.authToken, + }, Apps: []*proto.App{ { Id: taskAppID.String(), Slug: "task-sidebar", DisplayName: "Task Sidebar", + Url: opt.appURL, }, }, }, @@ -384,6 +413,193 @@ func TestTasks(t *testing.T) { } }) }) + + t.Run("Send", func(t *testing.T) { + t.Parallel() + + t.Run("IntegrationOK", func(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + createStatusResponse := func(status string) string { + return ` + { + "$schema": "http://localhost:3284/schemas/StatusResponseBody.json", + "status": "` + status + `" + } + ` + } + statusResponse := createStatusResponse("stable") + + // Start a fake AgentAPI that accepts GET /status and POST /message. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/status" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, statusResponse) + return + } + if r.Method == http.MethodPost && r.URL.Path == "/message" { + w.Header().Set("Content-Type", "application/json") + + b, _ := io.ReadAll(r.Body) + assert.Equal(t, `{"content":"Hello, Agent!","type":"user"}`, string(b), "expected message content") + + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"status": "ok"}`) + return + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + // Create an AI-capable template whose sidebar app points to our fake AgentAPI. + authToken := uuid.NewString() + template := createAITemplate(t, client, owner, withSidebarURL(srv.URL), withAgentToken(authToken)) + + // Create a workspace (task) from the AI-capable template. + ws := coderdtest.CreateWorkspace(t, userClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: "send a message"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // Start a fake agent so the workspace agent is connected before sending the message. + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + o.Client = agentClient + }) + coderdtest.NewWorkspaceAgentWaiter(t, client, ws.ID).WaitFor(coderdtest.AgentsReady) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Lookup the sidebar app ID. + w, err := client.Workspace(ctx, ws.ID) + require.NoError(t, err) + var sidebarAppID uuid.UUID + for _, res := range w.LatestBuild.Resources { + for _, ag := range res.Agents { + for _, app := range ag.Apps { + if app.Slug == "task-sidebar" { + sidebarAppID = app.ID + } + } + } + } + require.NotEqual(t, uuid.Nil, sidebarAppID) + + // Make the sidebar app unhealthy initially. + err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: sidebarAppID, + Health: database.WorkspaceAppHealthUnhealthy, + }) + require.NoError(t, err) + + exp := codersdk.NewExperimentalClient(userClient) + err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + Input: "Hello, Agent!", + }) + require.Error(t, err, "wanted error due to unhealthy sidebar app") + + // Make the sidebar app healthy. + err = api.Database.UpdateWorkspaceAppHealthByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceAppHealthByIDParams{ + ID: sidebarAppID, + Health: database.WorkspaceAppHealthHealthy, + }) + require.NoError(t, err) + + statusResponse = createStatusResponse("bad") + + err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + Input: "Hello, Agent!", + }) + require.Error(t, err, "wanted error due to bad status") + + statusResponse = createStatusResponse("stable") + + // Send task input to the tasks sidebar app and expect 204.e + err = exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + Input: "Hello, Agent!", + }) + require.NoError(t, err, "wanted no error due to healthy sidebar app and stable status") + }) + + t.Run("MissingContent", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + template := createAITemplate(t, client, user) + + // Create a workspace (task). + ws := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) { + req.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + {Name: codersdk.AITaskPromptParameterName, Value: "do work"}, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + exp := codersdk.NewExperimentalClient(client) + err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + Input: "", + }) + + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("TaskNotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + exp := codersdk.NewExperimentalClient(client) + err := exp.TaskSend(ctx, "me", uuid.New(), codersdk.TaskSendRequest{ + Input: "hi", + }) + + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotATask", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + // Create a template without AI tasks. + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ws := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + exp := codersdk.NewExperimentalClient(client) + err := exp.TaskSend(ctx, "me", ws.ID, codersdk.TaskSendRequest{ + Input: "hello", + }) + + var sdkErr *codersdk.Error + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + }) } func TestTasksCreate(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index d35a22569e..fe7e8bc0f4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1012,6 +1012,7 @@ func New(options *Options) *API { r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize)) r.Get("/{id}", api.taskGet) r.Delete("/{id}", api.taskDelete) + r.Post("/{id}/send", api.taskSend) r.Post("/", api.tasksCreate) }) }) diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index e50448946c..f11b5a7d1b 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -206,3 +206,21 @@ func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uui } return nil } + +// TaskSendRequest is used to send task input to the tasks sidebar app. +type TaskSendRequest struct { + Input string `json:"input"` +} + +// TaskSend submits task input to the tasks sidebar app. +func (c *ExperimentalClient) TaskSend(ctx context.Context, user string, id uuid.UUID, req TaskSendRequest) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/send", user, id.String()), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3a6449d5aa..8cbc3a20e6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2883,6 +2883,11 @@ export interface Task { readonly updated_at: string; } +// From codersdk/aitasks.go +export interface TaskSendRequest { + readonly input: string; +} + // From codersdk/aitasks.go export type TaskState = "completed" | "failed" | "idle" | "working";