mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: chat desktop backend (#23005)
Implement the backend for the desktop feature for agents. - Adds a new `/api/experimental/chats/$id/desktop` endpoint to coderd which exposes a VNC stream from a [portabledesktop](https://github.com/coder/portabledesktop) process running inside the workspace - Adds a new `spawn_computer_use_agent` tool to chatd, which spawns a subagent that has access to the `computer` tool which lets it interact with the `portabledesktop` process running inside the workspace - Adds the plumbing to make the above possible There's a follow up frontend PR here: https://github.com/coder/coder/pull/23006
This commit is contained in:
@@ -89,6 +89,8 @@ type AgentConn interface {
|
||||
Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error)
|
||||
WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error)
|
||||
WatchGit(ctx context.Context, logger slog.Logger, chatID uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], error)
|
||||
ConnectDesktopVNC(ctx context.Context) (net.Conn, error)
|
||||
ExecuteDesktopAction(ctx context.Context, action DesktopAction) (DesktopActionResponse, error)
|
||||
}
|
||||
|
||||
// AgentConn represents a connection to a workspace agent.
|
||||
@@ -530,6 +532,112 @@ func (c *agentConn) WatchGit(ctx context.Context, logger slog.Logger, chatID uui
|
||||
](conn, websocket.MessageText, websocket.MessageText, logger), nil
|
||||
}
|
||||
|
||||
// ConnectDesktopVNC opens a WebSocket to the agent's desktop endpoint and
|
||||
// returns a net.Conn carrying raw RFB (VNC) binary data.
|
||||
func (c *agentConn) ConnectDesktopVNC(ctx context.Context) (net.Conn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
|
||||
|
||||
dialOpts := &websocket.DialOptions{
|
||||
HTTPClient: c.apiClient(),
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
}
|
||||
c.headersMu.RLock()
|
||||
if len(c.extraHeaders) > 0 {
|
||||
dialOpts.HTTPHeader = c.extraHeaders.Clone()
|
||||
}
|
||||
c.headersMu.RUnlock()
|
||||
|
||||
url := fmt.Sprintf("http://%s/api/v0/desktop/vnc", host)
|
||||
conn, res, err := websocket.Dial(ctx, url, dialOpts)
|
||||
if err != nil {
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
if res != nil && res.Body != nil {
|
||||
defer res.Body.Close()
|
||||
}
|
||||
|
||||
// No read limit — RFB framebuffer updates can be large.
|
||||
conn.SetReadLimit(-1)
|
||||
|
||||
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
|
||||
}
|
||||
|
||||
// DesktopAction is the request body for the desktop action
|
||||
// endpoint.
|
||||
type DesktopAction struct {
|
||||
Action string `json:"action"`
|
||||
Coordinate *[2]int `json:"coordinate,omitempty"`
|
||||
StartCoordinate *[2]int `json:"start_coordinate,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
ScrollAmount *int `json:"scroll_amount,omitempty"`
|
||||
ScrollDirection *string `json:"scroll_direction,omitempty"`
|
||||
ScaledWidth *int `json:"scaled_width,omitempty"`
|
||||
ScaledHeight *int `json:"scaled_height,omitempty"`
|
||||
}
|
||||
|
||||
// DesktopActionResponse is the response from the desktop action
|
||||
// endpoint.
|
||||
type DesktopActionResponse struct {
|
||||
Output string `json:"output,omitempty"`
|
||||
ScreenshotData string `json:"screenshot_data,omitempty"`
|
||||
ScreenshotWidth int `json:"screenshot_width,omitempty"`
|
||||
ScreenshotHeight int `json:"screenshot_height,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteDesktopAction executes a mouse/keyboard/scroll action on the
|
||||
// agent's desktop.
|
||||
func (c *agentConn) ExecuteDesktopAction(ctx context.Context, action DesktopAction) (DesktopActionResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
host := net.JoinHostPort(
|
||||
c.agentAddress().String(),
|
||||
strconv.Itoa(AgentHTTPAPIServerPort),
|
||||
)
|
||||
|
||||
body, err := json.Marshal(action)
|
||||
if err != nil {
|
||||
return DesktopActionResponse{}, xerrors.Errorf("marshal action: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s/api/v0/desktop/action", host)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return DesktopActionResponse{}, xerrors.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.headersMu.RLock()
|
||||
if len(c.extraHeaders) > 0 {
|
||||
for k, v := range c.extraHeaders {
|
||||
req.Header[k] = v
|
||||
}
|
||||
}
|
||||
c.headersMu.RUnlock()
|
||||
|
||||
resp, err := c.apiClient().Do(req)
|
||||
if err != nil {
|
||||
return DesktopActionResponse{}, xerrors.Errorf("action request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return DesktopActionResponse{}, codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
|
||||
var result DesktopActionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return DesktopActionResponse{}, xerrors.Errorf("decode action response: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteDevcontainer deletes the provided devcontainer.
|
||||
// This is a blocking call and will wait for the container to be deleted.
|
||||
func (c *agentConn) DeleteDevcontainer(ctx context.Context, devcontainerID string) error {
|
||||
|
||||
@@ -83,6 +83,21 @@ func (mr *MockAgentConnMockRecorder) Close() *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockAgentConn)(nil).Close))
|
||||
}
|
||||
|
||||
// ConnectDesktopVNC mocks base method.
|
||||
func (m *MockAgentConn) ConnectDesktopVNC(ctx context.Context) (net.Conn, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ConnectDesktopVNC", ctx)
|
||||
ret0, _ := ret[0].(net.Conn)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ConnectDesktopVNC indicates an expected call of ConnectDesktopVNC.
|
||||
func (mr *MockAgentConnMockRecorder) ConnectDesktopVNC(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConnectDesktopVNC", reflect.TypeOf((*MockAgentConn)(nil).ConnectDesktopVNC), ctx)
|
||||
}
|
||||
|
||||
// DebugLogs mocks base method.
|
||||
func (m *MockAgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -171,6 +186,21 @@ func (mr *MockAgentConnMockRecorder) EditFiles(ctx, edits any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditFiles", reflect.TypeOf((*MockAgentConn)(nil).EditFiles), ctx, edits)
|
||||
}
|
||||
|
||||
// ExecuteDesktopAction mocks base method.
|
||||
func (m *MockAgentConn) ExecuteDesktopAction(ctx context.Context, action workspacesdk.DesktopAction) (workspacesdk.DesktopActionResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ExecuteDesktopAction", ctx, action)
|
||||
ret0, _ := ret[0].(workspacesdk.DesktopActionResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ExecuteDesktopAction indicates an expected call of ExecuteDesktopAction.
|
||||
func (mr *MockAgentConnMockRecorder) ExecuteDesktopAction(ctx, action any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteDesktopAction", reflect.TypeOf((*MockAgentConn)(nil).ExecuteDesktopAction), ctx, action)
|
||||
}
|
||||
|
||||
// GetPeerDiagnostics mocks base method.
|
||||
func (m *MockAgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package workspacesdk
|
||||
|
||||
const (
|
||||
// DesktopDisplayWidth is the default display width in pixels
|
||||
// used for computer-use desktop sessions.
|
||||
DesktopDisplayWidth = 1366
|
||||
// DesktopDisplayHeight is the default display height in pixels
|
||||
// used for computer-use desktop sessions.
|
||||
DesktopDisplayHeight = 768
|
||||
)
|
||||
Reference in New Issue
Block a user