|
1 | 1 | package coderd
|
2 | 2 |
|
3 | 3 | import (
|
| 4 | +"bytes" |
4 | 5 | "context"
|
5 | 6 | "database/sql"
|
| 7 | +"encoding/json" |
6 | 8 | "errors"
|
7 | 9 | "fmt"
|
| 10 | +"io" |
| 11 | +"net" |
8 | 12 | "net/http"
|
| 13 | +"net/url" |
| 14 | +"path" |
| 15 | +"time" |
| 16 | + |
9 | 17 | "slices"
|
10 | 18 | "strings"
|
11 | 19 |
|
@@ -590,3 +598,207 @@ func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
|
590 | 598 | // Delete build created successfully.
|
591 | 599 | rw.WriteHeader(http.StatusAccepted)
|
592 | 600 | }
|
| 601 | + |
| 602 | +// taskSend submits task input to the tasks sidebar app by dialing the agent |
| 603 | +// directly over the tailnet. We enforce ApplicationConnect RBAC on the |
| 604 | +// workspace and validate the sidebar app health. |
| 605 | +func (api*API)taskSend(rw http.ResponseWriter,r*http.Request) { |
| 606 | +ctx:=r.Context() |
| 607 | + |
| 608 | +idStr:=chi.URLParam(r,"id") |
| 609 | +taskID,err:=uuid.Parse(idStr) |
| 610 | +iferr!=nil { |
| 611 | +httperror.WriteResponseError(ctx,rw,httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ |
| 612 | +Message:fmt.Sprintf("Invalid UUID %q for task ID.",idStr), |
| 613 | +})) |
| 614 | +return |
| 615 | +} |
| 616 | + |
| 617 | +varreq codersdk.TaskSendRequest |
| 618 | +if!httpapi.Read(ctx,rw,r,&req) { |
| 619 | +return |
| 620 | +} |
| 621 | +ifreq.Input=="" { |
| 622 | +httperror.WriteResponseError(ctx,rw,httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ |
| 623 | +Message:"Task input is required.", |
| 624 | +})) |
| 625 | +return |
| 626 | +} |
| 627 | + |
| 628 | +typemessagePayloadstruct { |
| 629 | +Contentstring`json:"content"` |
| 630 | +Typestring`json:"type"` |
| 631 | +} |
| 632 | +payload,err:=json.Marshal(messagePayload{Content:req.Input,Type:"user"}) |
| 633 | +iferr!=nil { |
| 634 | +httperror.WriteResponseError(ctx,rw,httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 635 | +Message:"Internal error encoding request payload.", |
| 636 | +Detail:err.Error(), |
| 637 | +})) |
| 638 | +return |
| 639 | +} |
| 640 | + |
| 641 | +iferr=api.authAndDoWithTaskSidebarAppClient(r,taskID,func(ctx context.Context,client*http.Client,appURL*url.URL)error { |
| 642 | +// Build the request to the agent local app (we expect agentapi on the other side). |
| 643 | +appReqURL:=appURL |
| 644 | +appReqURL.Path=path.Join(appURL.Path,"message") |
| 645 | + |
| 646 | +newReq,err:=http.NewRequestWithContext(ctx,http.MethodPost,appReqURL.String(),bytes.NewReader(payload)) |
| 647 | +iferr!=nil { |
| 648 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 649 | +Message:"Internal error creating request to task app.", |
| 650 | +Detail:err.Error(), |
| 651 | +}) |
| 652 | +} |
| 653 | +newReq.Header.Set("Content-Type","application/json") |
| 654 | + |
| 655 | +resp,err:=client.Do(newReq) |
| 656 | +iferr!=nil { |
| 657 | +returnhttperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ |
| 658 | +Message:"Failed to reach task app endpoint.", |
| 659 | +Detail:err.Error(), |
| 660 | +}) |
| 661 | +} |
| 662 | +deferresp.Body.Close() |
| 663 | + |
| 664 | +ifresp.StatusCode!=http.StatusOK { |
| 665 | +body,_:=io.ReadAll(io.LimitReader(resp.Body,128)) |
| 666 | +returnhttperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ |
| 667 | +Message:"Task app rejected the message.", |
| 668 | +Detail:fmt.Sprintf("Upstream status: %d; Body: %s",resp.StatusCode,body), |
| 669 | +}) |
| 670 | +} |
| 671 | +returnnil |
| 672 | +});err!=nil { |
| 673 | +httperror.WriteResponseError(ctx,rw,err) |
| 674 | +} |
| 675 | + |
| 676 | +rw.WriteHeader(http.StatusNoContent) |
| 677 | +} |
| 678 | + |
| 679 | +// authAndDoWithTaskSidebarAppClient centralizes the shared logic to: |
| 680 | +// |
| 681 | +// - Fetch the task workspace |
| 682 | +// - Authorize ApplicationConnect on the workspace |
| 683 | +// - Validate the AI task and sidebar app health |
| 684 | +// - Dial the agent and construct an HTTP client to the apps loopback URL |
| 685 | +// |
| 686 | +// The provided callback receives the context, an HTTP client that dials via the |
| 687 | +// agent, and the base app URL (as a value URL) to perform any request. |
| 688 | +func (api*API)authAndDoWithTaskSidebarAppClient( |
| 689 | +r*http.Request, |
| 690 | +taskID uuid.UUID, |
| 691 | +dofunc(ctx context.Context,client*http.Client,appURL*url.URL)error, |
| 692 | +)error { |
| 693 | +ctx:=r.Context() |
| 694 | + |
| 695 | +workspaceID:=taskID |
| 696 | +workspace,err:=api.Database.GetWorkspaceByID(ctx,workspaceID) |
| 697 | +iferr!=nil { |
| 698 | +ifhttpapi.Is404Error(err) { |
| 699 | +returnhttperror.ErrResourceNotFound |
| 700 | +} |
| 701 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 702 | +Message:"Internal error fetching workspace.", |
| 703 | +Detail:err.Error(), |
| 704 | +}) |
| 705 | +} |
| 706 | + |
| 707 | +// Connecting to applications requires ApplicationConnect on the workspace. |
| 708 | +if!api.Authorize(r,policy.ActionApplicationConnect,workspace) { |
| 709 | +returnhttperror.ErrResourceNotFound |
| 710 | +} |
| 711 | + |
| 712 | +data,err:=api.workspaceData(ctx, []database.Workspace{workspace}) |
| 713 | +iferr!=nil { |
| 714 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 715 | +Message:"Internal error fetching workspace resources.", |
| 716 | +Detail:err.Error(), |
| 717 | +}) |
| 718 | +} |
| 719 | +iflen(data.builds)==0||len(data.templates)==0 { |
| 720 | +returnhttperror.ErrResourceNotFound |
| 721 | +} |
| 722 | +build:=data.builds[0] |
| 723 | +ifbuild.HasAITask==nil||!*build.HasAITask||build.AITaskSidebarAppID==nil||!(*build.AITaskSidebarAppID!=uuid.Nil) { |
| 724 | +returnhttperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ |
| 725 | +Message:"Task is not configured with a sidebar app.", |
| 726 | +}) |
| 727 | +} |
| 728 | +sidebarAppID:=*build.AITaskSidebarAppID |
| 729 | + |
| 730 | +// Find the sidebar app details to get the URL and validate app health. |
| 731 | +agentIDs:=make([]uuid.UUID,0,len(build.Resources)) |
| 732 | +for_,res:=rangebuild.Resources { |
| 733 | +for_,agent:=rangeres.Agents { |
| 734 | +agentIDs=append(agentIDs,agent.ID) |
| 735 | +} |
| 736 | +} |
| 737 | +apps,err:=api.Database.GetWorkspaceAppsByAgentIDs(ctx,agentIDs) |
| 738 | +iferr!=nil&&!errors.Is(err,sql.ErrNoRows) { |
| 739 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 740 | +Message:"Internal error fetching workspace apps.", |
| 741 | +Detail:err.Error(), |
| 742 | +}) |
| 743 | +} |
| 744 | +sidebarApp,ok:=slice.Find(apps,func(app database.WorkspaceApp)bool {returnapp.ID==sidebarAppID }) |
| 745 | +if!ok { |
| 746 | +returnhttperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ |
| 747 | +Message:"Task sidebar app not found in latest build.", |
| 748 | +}) |
| 749 | +} |
| 750 | + |
| 751 | +// Return an informative error if the app isn't healthy rather than trying |
| 752 | +// and failing. |
| 753 | +switchsidebarApp.Health { |
| 754 | +casedatabase.WorkspaceAppHealthDisabled: |
| 755 | +// No health check, pass through. |
| 756 | +casedatabase.WorkspaceAppHealthInitializing: |
| 757 | +returnhttperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ |
| 758 | +Message:"Task sidebar app is initializing. Try again shortly.", |
| 759 | +}) |
| 760 | +casedatabase.WorkspaceAppHealthUnhealthy: |
| 761 | +returnhttperror.NewResponseError(http.StatusServiceUnavailable, codersdk.Response{ |
| 762 | +Message:"Task sidebar app is unhealthy.", |
| 763 | +}) |
| 764 | +} |
| 765 | + |
| 766 | +// Build the direct app URL and dial the agent. |
| 767 | +if!sidebarApp.Url.Valid||sidebarApp.Url.String=="" { |
| 768 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 769 | +Message:"Task sidebar app URL is not configured.", |
| 770 | +}) |
| 771 | +} |
| 772 | +parsedURL,err:=url.Parse(sidebarApp.Url.String) |
| 773 | +iferr!=nil { |
| 774 | +returnhttperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ |
| 775 | +Message:"Internal error parsing task app URL.", |
| 776 | +Detail:err.Error(), |
| 777 | +}) |
| 778 | +} |
| 779 | +ifparsedURL.Scheme!="http" { |
| 780 | +returnhttperror.NewResponseError(http.StatusBadRequest, codersdk.Response{ |
| 781 | +Message:"Only http scheme is supported for direct agent-dial.", |
| 782 | +}) |
| 783 | +} |
| 784 | + |
| 785 | +dialCtx,dialCancel:=context.WithTimeout(ctx,time.Second*30) |
| 786 | +deferdialCancel() |
| 787 | +agentConn,release,err:=api.agentProvider.AgentConn(dialCtx,sidebarApp.AgentID) |
| 788 | +iferr!=nil { |
| 789 | +returnhttperror.NewResponseError(http.StatusBadGateway, codersdk.Response{ |
| 790 | +Message:"Failed to reach task app endpoint.", |
| 791 | +Detail:err.Error(), |
| 792 | +}) |
| 793 | +} |
| 794 | +deferrelease() |
| 795 | + |
| 796 | +client:=&http.Client{ |
| 797 | +Transport:&http.Transport{ |
| 798 | +DialContext:func(c context.Context,network,addrstring) (net.Conn,error) { |
| 799 | +returnagentConn.DialContext(c,network,addr) |
| 800 | +}, |
| 801 | +}, |
| 802 | +} |
| 803 | +returndo(ctx,client,parsedURL) |
| 804 | +} |