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:
Hugo Dutka
2026-03-13 19:49:34 +01:00
committed by GitHub
parent 67f5494665
commit 84527390c6
34 changed files with 4465 additions and 49 deletions
+108
View File
@@ -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()
+10
View File
@@ -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
)