mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
181e103201
## Problem Coderd can expose an MCP server at `/api/experimental/mcp/http` (we have this enabled on dogfood). Its workspace tools dialed agents through a per-call client-side tailnet stack. Every tool call re-created a WireGuard device, netstack, magicsock + UDP sockets, DERP connection, coordinator websocket, and their goroutines — in a process that already runs a long-lived shared tailnet. The duplicate stacks drove up resource usage under load. ## Fix Route this server's tool calls through the existing shared tailnet, so none of those transports are reconstructed per call. Closing an `AgentConn` now releases a tunnel reference instead of tearing down a transport. ## Potential follow-up `coder exp mcp server` still builds a fresh tailnet per call. It pays per-call latency and causes coordinator/DERP churn. A shared CLI tailnet is more involved — unlike coderd, the CLI has no existing shared tailnet to reuse, so it would need a new long-lived client-side tailnet with reconnect, sleep/wake, and idle-destination handling. There's less motivation to optimize this, given the client-side MCP does not compete for resources with coderd. Closes CODAGT-199 > Generated by mux, but reviewed by a human
1404 lines
48 KiB
Go
1404 lines
48 KiB
Go
package workspacesdk
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
neturl "net/url"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/hashicorp/go-multierror"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/xerrors"
|
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
|
"tailscale.com/ipn/ipnstate"
|
|
"tailscale.com/net/speedtest"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/healthsdk"
|
|
"github.com/coder/coder/v2/codersdk/wsjson"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
"github.com/coder/websocket"
|
|
)
|
|
|
|
// NewAgentConn creates a new WorkspaceAgentConn. `conn` may be unique
|
|
// to the WorkspaceAgentConn, or it may be shared in the case of coderd. If the
|
|
// conn is shared and closing it is undesirable, you may return ErrNoClose from
|
|
// opts.CloseFunc. This will ensure the underlying conn is not closed.
|
|
func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) AgentConn {
|
|
return &agentConn{
|
|
Conn: conn,
|
|
opts: opts,
|
|
}
|
|
}
|
|
|
|
// WrapAgentConn returns an AgentConn that delegates every operation to conn and
|
|
// applies closeFunc exactly once when the logical session is closed.
|
|
//
|
|
// If conn is nil, any provided closeFunc is invoked immediately so logical
|
|
// session cleanup is not silently dropped.
|
|
func WrapAgentConn(conn AgentConn, closeFunc func() error) AgentConn {
|
|
if conn == nil {
|
|
if closeFunc != nil {
|
|
_ = closeFunc()
|
|
}
|
|
return nil
|
|
}
|
|
if closeFunc == nil {
|
|
closeFunc = func() error { return nil }
|
|
}
|
|
return &wrappedAgentConn{AgentConn: conn, closeFunc: closeFunc}
|
|
}
|
|
|
|
type wrappedAgentConn struct {
|
|
AgentConn
|
|
closeFunc func() error
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
}
|
|
|
|
func (c *wrappedAgentConn) Close() error {
|
|
c.closeOnce.Do(func() {
|
|
// Close the underlying connection before releasing the logical session so
|
|
// the lease remains held until teardown is complete.
|
|
c.closeErr = errors.Join(c.AgentConn.Close(), c.closeFunc())
|
|
})
|
|
return c.closeErr
|
|
}
|
|
|
|
const (
|
|
// CoderChatIDHeader is the HTTP header containing the current
|
|
// chat ID. Set by coderd on agentconn requests originating
|
|
// from chatd.
|
|
CoderChatIDHeader = "Coder-Chat-Id"
|
|
// CoderAncestorChatIDsHeader is the HTTP header containing a
|
|
// JSON array of ancestor chat UUIDs.
|
|
CoderAncestorChatIDsHeader = "Coder-Ancestor-Chat-Ids"
|
|
)
|
|
|
|
// AgentConn represents a connection to a workspace agent.
|
|
// @typescript-ignore AgentConn
|
|
type AgentConn interface {
|
|
TailnetConn() *tailnet.Conn
|
|
SetExtraHeaders(h http.Header)
|
|
|
|
AwaitReachable(ctx context.Context) bool
|
|
CallMCPTool(ctx context.Context, req CallMCPToolRequest) (CallMCPToolResponse, error)
|
|
Close() error
|
|
ContextConfig(ctx context.Context) (ContextConfigResponse, error)
|
|
DebugLogs(ctx context.Context) ([]byte, error)
|
|
DebugMagicsock(ctx context.Context) ([]byte, error)
|
|
DebugManifest(ctx context.Context) ([]byte, error)
|
|
DialContext(ctx context.Context, network string, addr string) (net.Conn, error)
|
|
GetPeerDiagnostics() tailnet.PeerDiagnostics
|
|
ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
|
|
ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error)
|
|
ListProcesses(ctx context.Context) (ListProcessesResponse, error)
|
|
ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error)
|
|
Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error)
|
|
Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error)
|
|
ProcessOutput(ctx context.Context, id string, opts *ProcessOutputOptions) (ProcessOutputResponse, error)
|
|
PrometheusMetrics(ctx context.Context) ([]byte, error)
|
|
ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error)
|
|
DeleteDevcontainer(ctx context.Context, devcontainerID string) error
|
|
RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error)
|
|
SignalProcess(ctx context.Context, id string, signal string) error
|
|
StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error)
|
|
LS(ctx context.Context, path string, req LSRequest) (LSResponse, error)
|
|
ResolvePath(ctx context.Context, path string) (string, error)
|
|
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
|
|
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
|
|
WriteFile(ctx context.Context, path string, reader io.Reader) error
|
|
EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error)
|
|
SSH(ctx context.Context) (*gonet.TCPConn, error)
|
|
SSHClient(ctx context.Context) (*ssh.Client, error)
|
|
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
|
|
SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error)
|
|
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)
|
|
StartDesktopRecording(ctx context.Context, req StartDesktopRecordingRequest) error
|
|
StopDesktopRecording(ctx context.Context, req StopDesktopRecordingRequest) (StopDesktopRecordingResponse, error)
|
|
}
|
|
|
|
// AgentConn represents a connection to a workspace agent.
|
|
// @typescript-ignore AgentConn
|
|
type agentConn struct {
|
|
*tailnet.Conn
|
|
opts AgentConnOptions
|
|
headersMu sync.RWMutex
|
|
extraHeaders http.Header
|
|
}
|
|
|
|
func (c *agentConn) TailnetConn() *tailnet.Conn {
|
|
return c.Conn
|
|
}
|
|
|
|
func (c *agentConn) SetExtraHeaders(h http.Header) {
|
|
c.headersMu.Lock()
|
|
c.extraHeaders = h
|
|
c.headersMu.Unlock()
|
|
}
|
|
|
|
// @typescript-ignore AgentConnOptions
|
|
type AgentConnOptions struct {
|
|
AgentID uuid.UUID
|
|
CloseFunc func() error
|
|
}
|
|
|
|
func (c *agentConn) agentAddress() netip.Addr {
|
|
return tailnet.TailscaleServicePrefix.AddrFromUUID(c.opts.AgentID)
|
|
}
|
|
|
|
// AwaitReachable waits for the agent to be reachable.
|
|
func (c *agentConn) AwaitReachable(ctx context.Context) bool {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
return c.Conn.AwaitReachable(ctx, c.agentAddress())
|
|
}
|
|
|
|
// Ping pings the agent and returns the round-trip time.
|
|
// The bool returns true if the ping was made P2P.
|
|
func (c *agentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
return c.Conn.Ping(ctx, c.agentAddress())
|
|
}
|
|
|
|
// Close ends the connection to the workspace agent.
|
|
func (c *agentConn) Close() error {
|
|
var cerr error
|
|
if c.opts.CloseFunc != nil {
|
|
cerr = c.opts.CloseFunc()
|
|
if xerrors.Is(cerr, ErrSkipClose) {
|
|
return nil
|
|
}
|
|
}
|
|
if cerr != nil {
|
|
return multierror.Append(cerr, c.Conn.Close())
|
|
}
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
// AgentReconnectingPTYInit initializes a new reconnecting PTY session.
|
|
// @typescript-ignore AgentReconnectingPTYInit
|
|
type AgentReconnectingPTYInit struct {
|
|
ID uuid.UUID
|
|
Height uint16
|
|
Width uint16
|
|
Command string
|
|
// Container, if set, will attempt to exec into a running container visible to the agent.
|
|
// This should be a unique container ID (implementation-dependent).
|
|
Container string
|
|
// ContainerUser, if set, will set the target user when execing into a container.
|
|
// This can be a username or UID, depending on the underlying implementation.
|
|
// This is ignored if Container is not set.
|
|
ContainerUser string
|
|
|
|
BackendType string
|
|
}
|
|
|
|
// AgentReconnectingPTYInitOption is a functional option for AgentReconnectingPTYInit.
|
|
type AgentReconnectingPTYInitOption func(*AgentReconnectingPTYInit)
|
|
|
|
// AgentReconnectingPTYInitWithContainer sets the container and container user for the reconnecting PTY session.
|
|
func AgentReconnectingPTYInitWithContainer(container, containerUser string) AgentReconnectingPTYInitOption {
|
|
return func(init *AgentReconnectingPTYInit) {
|
|
init.Container = container
|
|
init.ContainerUser = containerUser
|
|
}
|
|
}
|
|
|
|
// ReconnectingPTYRequest is sent from the client to the server
|
|
// to pipe data to a PTY.
|
|
// @typescript-ignore ReconnectingPTYRequest
|
|
type ReconnectingPTYRequest struct {
|
|
Data string `json:"data,omitempty"`
|
|
Height uint16 `json:"height,omitempty"`
|
|
Width uint16 `json:"width,omitempty"`
|
|
}
|
|
|
|
// ReconnectingPTY spawns a new reconnecting terminal session.
|
|
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
|
|
// Raw terminal output will be read from the returned net.Conn.
|
|
func (c *agentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
if !c.AwaitReachable(ctx) {
|
|
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
|
}
|
|
|
|
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rptyInit := AgentReconnectingPTYInit{
|
|
ID: id,
|
|
Height: height,
|
|
Width: width,
|
|
Command: command,
|
|
}
|
|
for _, o := range initOpts {
|
|
o(&rptyInit)
|
|
}
|
|
data, err := json.Marshal(rptyInit)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
data = append(make([]byte, 2), data...)
|
|
// #nosec G115 - Safe conversion as the data length is expected to be within uint16 range for PTY initialization
|
|
binary.LittleEndian.PutUint16(data, uint16(len(data)-2))
|
|
|
|
_, err = conn.Write(data)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// SSH pipes the SSH protocol over the returned net.Conn.
|
|
// This connects to the built-in SSH server in the workspace agent.
|
|
func (c *agentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
|
return c.SSHOnPort(ctx, AgentSSHPort)
|
|
}
|
|
|
|
// SSHOnPort pipes the SSH protocol over the returned net.Conn.
|
|
// This connects to the built-in SSH server in the workspace agent on the specified port.
|
|
func (c *agentConn) SSHOnPort(ctx context.Context, port uint16) (*gonet.TCPConn, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
if !c.AwaitReachable(ctx) {
|
|
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
|
}
|
|
|
|
c.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationSSH)
|
|
return c.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), port))
|
|
}
|
|
|
|
// SSHClient calls SSH to create a client
|
|
func (c *agentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
|
return c.SSHClientOnPort(ctx, AgentSSHPort)
|
|
}
|
|
|
|
// SSHClientOnPort calls SSH to create a client on a specific port
|
|
func (c *agentConn) SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
netConn, err := c.SSHOnPort(ctx, port)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("ssh: %w", err)
|
|
}
|
|
|
|
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
|
|
// SSH host validation isn't helpful, because obtaining a peer
|
|
// connection already signifies user-intent to dial a workspace.
|
|
// #nosec
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("ssh conn: %w", err)
|
|
}
|
|
|
|
return ssh.NewClient(sshConn, channels, requests), nil
|
|
}
|
|
|
|
// Speedtest runs a speedtest against the workspace agent.
|
|
func (c *agentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
if !c.AwaitReachable(ctx) {
|
|
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
|
}
|
|
|
|
c.Conn.SendConnectedTelemetry(c.agentAddress(), tailnet.TelemetryApplicationSpeedtest)
|
|
speedConn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSpeedtestPort))
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("dial speedtest: %w", err)
|
|
}
|
|
|
|
results, err := speedtest.RunClientWithConn(direction, duration, speedConn)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("run speedtest: %w", err)
|
|
}
|
|
|
|
return results, err
|
|
}
|
|
|
|
// DialContext dials the address provided in the workspace agent.
|
|
// The network must be "tcp" or "udp".
|
|
func (c *agentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
if !c.AwaitReachable(ctx) {
|
|
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
|
}
|
|
|
|
_, rawPort, _ := net.SplitHostPort(addr)
|
|
port, _ := strconv.ParseUint(rawPort, 10, 16)
|
|
ipp := netip.AddrPortFrom(c.agentAddress(), uint16(port))
|
|
|
|
switch network {
|
|
case "tcp":
|
|
return c.Conn.DialContextTCP(ctx, ipp)
|
|
case "udp":
|
|
return c.Conn.DialContextUDP(ctx, ipp)
|
|
default:
|
|
return nil, xerrors.Errorf("unknown network %q", network)
|
|
}
|
|
}
|
|
|
|
// ListeningPorts lists the ports that are currently in use by the workspace.
|
|
func (c *agentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
|
|
if err != nil {
|
|
return codersdk.WorkspaceAgentListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.WorkspaceAgentListeningPortsResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var resp codersdk.WorkspaceAgentListeningPortsResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// Netcheck returns a network check report from the workspace agent.
|
|
func (c *agentConn) Netcheck(ctx context.Context) (healthsdk.AgentNetcheckReport, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/netcheck", nil)
|
|
if err != nil {
|
|
return healthsdk.AgentNetcheckReport{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return healthsdk.AgentNetcheckReport{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var resp healthsdk.AgentNetcheckReport
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// DebugMagicsock makes a request to the workspace agent's magicsock debug endpoint.
|
|
func (c *agentConn) DebugMagicsock(ctx context.Context) ([]byte, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/magicsock", nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
defer res.Body.Close()
|
|
bs, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("read response body: %w", err)
|
|
}
|
|
return bs, nil
|
|
}
|
|
|
|
// DebugManifest returns the agent's in-memory manifest. Unfortunately this must
|
|
// be returns as a []byte to avoid an import cycle.
|
|
func (c *agentConn) DebugManifest(ctx context.Context) ([]byte, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/manifest", nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
bs, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("read response body: %w", err)
|
|
}
|
|
return bs, nil
|
|
}
|
|
|
|
// DebugLogs returns up to the last 10MB of `/tmp/coder-agent.log`
|
|
func (c *agentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/logs", nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
bs, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("read response body: %w", err)
|
|
}
|
|
return bs, nil
|
|
}
|
|
|
|
// PrometheusMetrics returns a response from the agent's prometheus metrics endpoint
|
|
func (c *agentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/prometheus", nil)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
bs, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("read response body: %w", err)
|
|
}
|
|
return bs, nil
|
|
}
|
|
|
|
// ListContainers returns a response from the agent's containers endpoint
|
|
func (c *agentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/containers", nil)
|
|
if err != nil {
|
|
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.WorkspaceAgentListContainersResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp codersdk.WorkspaceAgentListContainersResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
func (c *agentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
|
|
url := fmt.Sprintf("http://%s%s", host, "/api/v0/containers/watch")
|
|
|
|
conn, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{
|
|
HTTPClient: c.apiClient(),
|
|
|
|
// We want `NoContextTakeover` compression to balance improving
|
|
// bandwidth cost/latency with minimal memory usage overhead.
|
|
CompressionMode: websocket.CompressionNoContextTakeover,
|
|
})
|
|
if err != nil {
|
|
if res == nil {
|
|
return nil, nil, err
|
|
}
|
|
return nil, nil, codersdk.ReadBodyAsError(res)
|
|
}
|
|
if res != nil && res.Body != nil {
|
|
defer res.Body.Close()
|
|
}
|
|
|
|
// When a workspace has a few devcontainers running, or a single devcontainer
|
|
// has a large amount of apps, then each payload can easily exceed 32KiB.
|
|
// We up the limit to 4MiB to give us plenty of headroom for workspaces that
|
|
// have lots of dev containers with lots of apps.
|
|
conn.SetReadLimit(1 << 22) // 4MiB
|
|
|
|
d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, logger)
|
|
return d.Chan(), d, nil
|
|
}
|
|
|
|
// WatchGit opens a bidirectional WebSocket to the agent's git watch
|
|
// endpoint and returns a stream for sending subscribe/refresh messages
|
|
// and receiving change notifications.
|
|
func (c *agentConn) WatchGit(ctx context.Context, logger slog.Logger, chatID uuid.UUID) (*wsjson.Stream[codersdk.WorkspaceAgentGitServerMessage, codersdk.WorkspaceAgentGitClientMessage], 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.CompressionNoContextTakeover,
|
|
}
|
|
c.headersMu.RLock()
|
|
if len(c.extraHeaders) > 0 {
|
|
dialOpts.HTTPHeader = c.extraHeaders.Clone()
|
|
}
|
|
c.headersMu.RUnlock()
|
|
|
|
url := fmt.Sprintf("http://%s%s", host, "/api/v0/git/watch")
|
|
if chatID != uuid.Nil {
|
|
url += "?chat_id=" + chatID.String()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
conn.SetReadLimit(1 << 22) // 4MiB
|
|
|
|
return wsjson.NewStream[
|
|
codersdk.WorkspaceAgentGitServerMessage,
|
|
codersdk.WorkspaceAgentGitClientMessage,
|
|
](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 and ScaledHeight carry the declared model-facing desktop
|
|
// geometry used for screenshot sizing and coordinate mapping.
|
|
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"`
|
|
}
|
|
|
|
// StartDesktopRecordingRequest is the request body for starting a
|
|
// desktop recording session.
|
|
type StartDesktopRecordingRequest struct {
|
|
RecordingID string `json:"recording_id"`
|
|
}
|
|
|
|
// StopDesktopRecordingRequest is the request body for stopping a
|
|
// desktop recording session.
|
|
type StopDesktopRecordingRequest struct {
|
|
RecordingID string `json:"recording_id"`
|
|
}
|
|
|
|
// StopDesktopRecordingResponse wraps the response from stopping a
|
|
// desktop recording. Body contains the recording data as a
|
|
// multipart/mixed stream. ContentType holds the Content-Type
|
|
// header (including boundary) so callers can parse the body.
|
|
type StopDesktopRecordingResponse struct {
|
|
Body io.ReadCloser
|
|
ContentType string
|
|
}
|
|
|
|
// MaxRecordingSize is the largest desktop recording (in bytes)
|
|
// that will be accepted. Used by both the agent-side stop handler
|
|
// and the server-side storage pipeline.
|
|
const MaxRecordingSize = 100 << 20 // 100 MB
|
|
|
|
// MaxThumbnailSize is the largest thumbnail (in bytes) that will
|
|
// be accepted. Applied both agent-side (before streaming) and
|
|
// server-side (when parsing multipart parts).
|
|
const MaxThumbnailSize = 10 << 20 // 10 MB
|
|
|
|
// 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
|
|
}
|
|
|
|
// StartDesktopRecording starts a desktop recording session on the
|
|
// agent with the given recording ID. The recording ID is
|
|
// caller-provided and must be unique. Idempotent — if the ID is
|
|
// already recording, returns success.
|
|
func (c *agentConn) StartDesktopRecording(ctx context.Context, req StartDesktopRecordingRequest) error {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/desktop/recording/start", req)
|
|
if err != nil {
|
|
return xerrors.Errorf("start recording request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StopDesktopRecording stops a desktop recording session on the
|
|
// agent and returns the recording as a StopDesktopRecordingResponse.
|
|
// The response body is a multipart/mixed stream containing the
|
|
// video (and optionally a JPEG thumbnail). The caller is
|
|
// responsible for closing the returned Body. Idempotent — safe
|
|
// to call on an already-stopped recording.
|
|
func (c *agentConn) StopDesktopRecording(ctx context.Context, req StopDesktopRecordingRequest) (StopDesktopRecordingResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/desktop/recording/stop", req)
|
|
if err != nil {
|
|
return StopDesktopRecordingResponse{}, xerrors.Errorf("stop recording request: %w", err)
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
defer res.Body.Close()
|
|
return StopDesktopRecordingResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
// Caller is responsible for closing res.Body.
|
|
return StopDesktopRecordingResponse{
|
|
Body: res.Body,
|
|
ContentType: res.Header.Get("Content-Type"),
|
|
}, 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 {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodDelete, "/api/v0/containers/devcontainers/"+devcontainerID, nil)
|
|
if err != nil {
|
|
return xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusNoContent {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecreateDevcontainer recreates a devcontainer with the given container.
|
|
// This is a blocking call and will wait for the container to be recreated.
|
|
func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/"+devcontainerID+"/recreate", nil)
|
|
if err != nil {
|
|
return codersdk.Response{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusAccepted {
|
|
return codersdk.Response{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var m codersdk.Response
|
|
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
|
return codersdk.Response{}, xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// StartProcessRequest is the request body for starting a
|
|
// process on the workspace agent.
|
|
type StartProcessRequest struct {
|
|
Command string `json:"command"`
|
|
WorkDir string `json:"workdir,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Background bool `json:"background,omitempty"`
|
|
}
|
|
|
|
// StartProcessResponse is returned when a process is started.
|
|
type StartProcessResponse struct {
|
|
ID string `json:"id"`
|
|
Started bool `json:"started"`
|
|
}
|
|
|
|
// ListProcessesResponse contains information about tracked
|
|
// processes on the workspace agent.
|
|
type ListProcessesResponse struct {
|
|
Processes []ProcessInfo `json:"processes"`
|
|
}
|
|
|
|
// ProcessInfo describes a tracked process on the agent.
|
|
type ProcessInfo struct {
|
|
ID string `json:"id"`
|
|
Command string `json:"command"`
|
|
WorkDir string `json:"workdir,omitempty"`
|
|
Background bool `json:"background"`
|
|
Running bool `json:"running"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
StartedAt int64 `json:"started_at_unix"`
|
|
ExitedAt *int64 `json:"exited_at_unix,omitempty"`
|
|
}
|
|
|
|
// ProcessOutputResponse contains the output of a process.
|
|
type ProcessOutputResponse struct {
|
|
Output string `json:"output"`
|
|
Truncated *ProcessTruncation `json:"truncated,omitempty"`
|
|
Running bool `json:"running"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
}
|
|
|
|
// ProcessOutputOptions configures blocking behavior for
|
|
// process output retrieval.
|
|
type ProcessOutputOptions struct {
|
|
// Wait enables blocking mode. When true, the request
|
|
// blocks until the process exits or the context expires.
|
|
Wait bool
|
|
}
|
|
|
|
// ProcessTruncation describes how process output was truncated.
|
|
type ProcessTruncation struct {
|
|
OriginalBytes int `json:"original_bytes"`
|
|
RetainedBytes int `json:"retained_bytes"`
|
|
OmittedBytes int `json:"omitted_bytes"`
|
|
Strategy string `json:"strategy"`
|
|
}
|
|
|
|
// SignalProcessRequest is the request body for signaling a
|
|
// process on the workspace agent.
|
|
type SignalProcessRequest struct {
|
|
Signal string `json:"signal"`
|
|
}
|
|
|
|
type LSRequest struct {
|
|
// e.g. [], ["repos", "coder"],
|
|
Path []string `json:"path"`
|
|
// Whether the supplied path is relative to the user's home directory,
|
|
// or the root directory.
|
|
Relativity LSRelativity `json:"relativity"`
|
|
}
|
|
|
|
type LSRelativity string
|
|
|
|
const (
|
|
LSRelativityRoot LSRelativity = "root"
|
|
LSRelativityHome LSRelativity = "home"
|
|
)
|
|
|
|
type LSResponse struct {
|
|
AbsolutePath []string `json:"absolute_path"`
|
|
// Returned so clients can display the full path to the user, and
|
|
// copy it to configure file sync
|
|
// e.g. Windows: "C:\\Users\\coder"
|
|
// Linux: "/home/coder"
|
|
AbsolutePathString string `json:"absolute_path_string"`
|
|
Contents []LSFile `json:"contents"`
|
|
}
|
|
|
|
type LSFile struct {
|
|
Name string `json:"name"`
|
|
// e.g. "C:\\Users\\coder\\hello.txt"
|
|
// "/home/coder/hello.txt"
|
|
AbsolutePathString string `json:"absolute_path_string"`
|
|
IsDir bool `json:"is_dir"`
|
|
}
|
|
|
|
// LS lists a directory.
|
|
func (c *agentConn) LS(ctx context.Context, path string, req LSRequest) (LSResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
res, err := c.apiRequest(ctx, http.MethodPost, agentAPIPath("/api/v0/list-directory", neturl.Values{
|
|
"path": []string{path},
|
|
}), req)
|
|
if err != nil {
|
|
return LSResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return LSResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var m LSResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
|
return LSResponse{}, xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ResolvePathResponse is the response from the agent's path-resolution endpoint.
|
|
type ResolvePathResponse struct {
|
|
ResolvedPath string `json:"resolved_path"`
|
|
}
|
|
|
|
// ResolvePath resolves the existing portion of an absolute path through any
|
|
// symlinks and preserves missing trailing components.
|
|
func (c *agentConn) ResolvePath(ctx context.Context, path string) (string, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/resolve-path", neturl.Values{
|
|
"path": []string{path},
|
|
}), nil)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return "", codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var m ResolvePathResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
|
return "", xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return m.ResolvedPath, nil
|
|
}
|
|
|
|
// ReadFileLines reads a file with line-based offset and limit, returning
|
|
// line-numbered content with safety limits.
|
|
func (c *agentConn) ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/read-file-lines", neturl.Values{
|
|
"path": []string{path},
|
|
"offset": []string{strconv.FormatInt(offset, 10)},
|
|
"limit": []string{strconv.FormatInt(limit, 10)},
|
|
"max_file_size": []string{strconv.FormatInt(limits.MaxFileSize, 10)},
|
|
"max_line_bytes": []string{strconv.Itoa(limits.MaxLineBytes)},
|
|
"max_response_lines": []string{strconv.Itoa(limits.MaxResponseLines)},
|
|
"max_response_bytes": []string{strconv.Itoa(limits.MaxResponseBytes)},
|
|
}), nil)
|
|
if err != nil {
|
|
return ReadFileLinesResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ReadFileLinesResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var resp ReadFileLinesResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return ReadFileLinesResponse{}, xerrors.Errorf("decode response: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// ReadFile reads from a file from the workspace, returning a file reader and
|
|
// the mime type.
|
|
func (c *agentConn) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
//nolint:bodyclose // we want to return the body so the caller can stream.
|
|
res, err := c.apiRequest(ctx, http.MethodGet, agentAPIPath("/api/v0/read-file", neturl.Values{
|
|
"path": []string{path},
|
|
"offset": []string{strconv.FormatInt(offset, 10)},
|
|
"limit": []string{strconv.FormatInt(limit, 10)},
|
|
}), nil)
|
|
if err != nil {
|
|
return nil, "", xerrors.Errorf("do request: %w", err)
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
// codersdk.ReadBodyAsError will close the body.
|
|
return nil, "", codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
mimeType := res.Header.Get("Content-Type")
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
|
|
return res.Body, mimeType, nil
|
|
}
|
|
|
|
// WriteFile writes to a file in the workspace.
|
|
func (c *agentConn) WriteFile(ctx context.Context, path string, reader io.Reader) error {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
res, err := c.apiRequest(ctx, http.MethodPost, agentAPIPath("/api/v0/write-file", neturl.Values{
|
|
"path": []string{path},
|
|
}), reader)
|
|
if err != nil {
|
|
return xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var m codersdk.Response
|
|
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
|
return xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadFileLinesResponse is the response from the line-based file reader.
|
|
type ReadFileLinesResponse struct {
|
|
Success bool `json:"success"`
|
|
FileSize int64 `json:"file_size,omitempty"`
|
|
TotalLines int `json:"total_lines,omitempty"`
|
|
LinesRead int `json:"lines_read,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// ReadFileLinesLimits contains configurable safety limits for the line-based
|
|
// file reader. These are sent as query parameters so callers can tune them
|
|
// without requiring an agent redeployment.
|
|
type ReadFileLinesLimits struct {
|
|
// MaxFileSize is the maximum file size (in bytes) that will be opened.
|
|
MaxFileSize int64
|
|
// MaxLineBytes is the per-line byte cap before truncation.
|
|
MaxLineBytes int
|
|
// MaxResponseLines is the maximum number of lines in a single response.
|
|
MaxResponseLines int
|
|
// MaxResponseBytes is the maximum total bytes of formatted output.
|
|
MaxResponseBytes int
|
|
}
|
|
|
|
const (
|
|
// DefaultMaxFileSize is the default maximum file size (1 MB).
|
|
DefaultMaxFileSize int64 = 1 << 20
|
|
// DefaultMaxLineBytes is the default per-line truncation threshold.
|
|
DefaultMaxLineBytes int64 = 1024
|
|
// DefaultMaxResponseLines is the default max lines per response.
|
|
DefaultMaxResponseLines int64 = 2000
|
|
// DefaultMaxResponseBytes is the default max response size (32 KB).
|
|
DefaultMaxResponseBytes int64 = 32768
|
|
)
|
|
|
|
// DefaultReadFileLinesLimits returns the default limits.
|
|
func DefaultReadFileLinesLimits() ReadFileLinesLimits {
|
|
return ReadFileLinesLimits{
|
|
MaxFileSize: DefaultMaxFileSize,
|
|
MaxLineBytes: int(DefaultMaxLineBytes),
|
|
MaxResponseLines: int(DefaultMaxResponseLines),
|
|
MaxResponseBytes: int(DefaultMaxResponseBytes),
|
|
}
|
|
}
|
|
|
|
type FileEdit struct {
|
|
Search string `json:"search"`
|
|
Replace string `json:"replace"`
|
|
ReplaceAll bool `json:"replace_all,omitempty"`
|
|
}
|
|
|
|
type FileEdits struct {
|
|
Path string `json:"path"`
|
|
Edits []FileEdit `json:"edits"`
|
|
}
|
|
|
|
type FileEditRequest struct {
|
|
Files []FileEdits `json:"files"`
|
|
// IncludeDiff asks the agent to compute a unified diff per file
|
|
// and return it in FileEditResponse.Files[i].Diff. When false
|
|
// (default) the agent skips diff computation and Files is nil.
|
|
IncludeDiff bool `json:"include_diff,omitempty"`
|
|
}
|
|
|
|
// FileEditResponse is the success response for the edit-files endpoint.
|
|
// When the request's IncludeDiff flag is set, Files contains one entry
|
|
// per edited file in request order. Each entry's Path matches the
|
|
// caller-supplied path (pre-symlink resolution).
|
|
//
|
|
// The slice is named Files (rather than Diffs) so future work can
|
|
// hang per-file errors or status off each element without a second
|
|
// wire break.
|
|
type FileEditResponse struct {
|
|
Files []FileEditResult `json:"files,omitempty"`
|
|
}
|
|
|
|
// FileEditResult carries the outcome of editing one file. Path is
|
|
// the original caller-supplied path, not any symlink-resolved
|
|
// target. Diff is the unified-diff string produced when the
|
|
// caller set FileEditRequest.IncludeDiff; it is empty for no-op
|
|
// edits or when diffs were not requested.
|
|
type FileEditResult struct {
|
|
Path string `json:"path"`
|
|
Diff string `json:"diff"`
|
|
}
|
|
|
|
// ListMCPToolsResponse is the response from the agent's
|
|
// MCP tool discovery endpoint.
|
|
type ListMCPToolsResponse struct {
|
|
Tools []MCPToolInfo `json:"tools"`
|
|
}
|
|
|
|
// MCPToolInfo describes a single tool discovered from an MCP
|
|
// server configured in the workspace's .mcp.json file.
|
|
type MCPToolInfo struct {
|
|
// ServerName is the key from .mcp.json (e.g. "github").
|
|
ServerName string `json:"server_name"`
|
|
// Name is the prefixed tool name: "serverName__toolName".
|
|
Name string `json:"name"`
|
|
// Description is the tool's human-readable description.
|
|
Description string `json:"description"`
|
|
// Schema is the JSON Schema for the tool's input parameters.
|
|
Schema map[string]any `json:"schema"`
|
|
// Required lists required parameter names.
|
|
Required []string `json:"required"`
|
|
}
|
|
|
|
// ContextConfigResponse is the response from the agent's context
|
|
// configuration endpoint. Contains pre-read instruction file
|
|
// contents and discovered skill metadata as chat message parts.
|
|
type ContextConfigResponse struct {
|
|
Parts []codersdk.ChatMessagePart `json:"parts"`
|
|
}
|
|
|
|
// CallMCPToolRequest is the request body for proxying an MCP
|
|
// tool call through the workspace agent.
|
|
type CallMCPToolRequest struct {
|
|
// ToolName is the prefixed tool name (e.g. "github__create_issue").
|
|
ToolName string `json:"tool_name"`
|
|
// Arguments is the tool input as key-value pairs.
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
|
|
// CallMCPToolResponse is the response from a proxied MCP tool call.
|
|
type CallMCPToolResponse struct {
|
|
Content []MCPToolContent `json:"content"`
|
|
IsError bool `json:"is_error"`
|
|
}
|
|
|
|
// MCPToolContent is a single content block in an MCP tool response.
|
|
type MCPToolContent struct {
|
|
Type string `json:"type"` // "text", "image", "audio", "resource"
|
|
Text string `json:"text,omitempty"`
|
|
Data string `json:"data,omitempty"` // base64 for binary
|
|
MediaType string `json:"media_type,omitempty"`
|
|
}
|
|
|
|
// StartProcess starts a new process on the workspace agent.
|
|
func (c *agentConn) StartProcess(ctx context.Context, req StartProcessRequest) (StartProcessResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/processes/start", req)
|
|
if err != nil {
|
|
return StartProcessResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return StartProcessResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp StartProcessResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// ListProcesses returns information about tracked processes on the agent.
|
|
func (c *agentConn) ListProcesses(ctx context.Context) (ListProcessesResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/processes/list", nil)
|
|
if err != nil {
|
|
return ListProcessesResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ListProcessesResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp ListProcessesResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// ListMCPTools returns tools discovered from MCP servers configured
|
|
// in the workspace.
|
|
func (c *agentConn) ListMCPTools(ctx context.Context) (ListMCPToolsResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/mcp/tools", nil)
|
|
if err != nil {
|
|
return ListMCPToolsResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ListMCPToolsResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp ListMCPToolsResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// ContextConfig returns the resolved context configuration from
|
|
// the workspace agent.
|
|
func (c *agentConn) ContextConfig(ctx context.Context) (ContextConfigResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/context-config", nil)
|
|
if err != nil {
|
|
return ContextConfigResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ContextConfigResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp ContextConfigResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// CallMCPTool proxies a tool call to an MCP server running in
|
|
// the workspace.
|
|
func (c *agentConn) CallMCPTool(ctx context.Context, req CallMCPToolRequest) (CallMCPToolResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/mcp/call-tool", req)
|
|
if err != nil {
|
|
return CallMCPToolResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return CallMCPToolResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp CallMCPToolResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// ProcessOutput returns the output of a tracked process on the agent.
|
|
func (c *agentConn) ProcessOutput(ctx context.Context, id string, opts *ProcessOutputOptions) (ProcessOutputResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
path := "/api/v0/processes/" + id + "/output"
|
|
if opts != nil && opts.Wait {
|
|
path += "?wait=true"
|
|
}
|
|
res, err := c.apiRequest(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return ProcessOutputResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return ProcessOutputResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
var resp ProcessOutputResponse
|
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
|
}
|
|
|
|
// SignalProcess sends a signal to a tracked process on the agent.
|
|
func (c *agentConn) SignalProcess(ctx context.Context, id string, signal string) error {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/processes/"+id+"/signal", SignalProcessRequest{Signal: signal})
|
|
if err != nil {
|
|
return xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return codersdk.ReadBodyAsError(res)
|
|
}
|
|
var m codersdk.Response
|
|
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
|
|
return xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EditFiles performs search and replace edits on one or more files.
|
|
// When edits.IncludeDiff is true, the returned FileEditResponse
|
|
// carries a unified diff per edited file.
|
|
func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) (FileEditResponse, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/edit-files", edits)
|
|
if err != nil {
|
|
return FileEditResponse{}, xerrors.Errorf("do request: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return FileEditResponse{}, codersdk.ReadBodyAsError(res)
|
|
}
|
|
|
|
var resp FileEditResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return FileEditResponse{}, xerrors.Errorf("decode response body: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func agentAPIPath(path string, query neturl.Values) string {
|
|
if len(query) == 0 {
|
|
return path
|
|
}
|
|
|
|
return path + "?" + query.Encode()
|
|
}
|
|
|
|
// apiRequest makes a request to the workspace agent's HTTP API server.
|
|
func (c *agentConn) apiRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
|
ctx, span := tracing.StartSpan(ctx)
|
|
defer span.End()
|
|
|
|
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
|
|
url := fmt.Sprintf("http://%s%s", host, path)
|
|
|
|
var r io.Reader
|
|
if body != nil {
|
|
switch data := body.(type) {
|
|
case io.Reader:
|
|
r = data
|
|
case []byte:
|
|
r = bytes.NewReader(data)
|
|
default:
|
|
// Assume JSON in all other cases.
|
|
buf := bytes.NewBuffer(nil)
|
|
enc := json.NewEncoder(buf)
|
|
enc.SetEscapeHTML(false)
|
|
err := enc.Encode(body)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("encode body: %w", err)
|
|
}
|
|
r = buf
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, url, r)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("new http api request to %q: %w", url, err)
|
|
}
|
|
|
|
c.headersMu.RLock()
|
|
extraHeaders := c.extraHeaders.Clone()
|
|
c.headersMu.RUnlock()
|
|
for key, values := range extraHeaders {
|
|
for _, value := range values {
|
|
req.Header.Add(key, value)
|
|
}
|
|
}
|
|
|
|
return c.apiClient().Do(req)
|
|
}
|
|
|
|
// apiClient returns an HTTP client that can be used to make
|
|
// requests to the workspace agent's HTTP API server.
|
|
func (c *agentConn) apiClient() *http.Client {
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
// Disable keep alives as we're usually only making a single
|
|
// request, and this triggers goleak in tests
|
|
DisableKeepAlives: true,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if network != "tcp" {
|
|
return nil, xerrors.Errorf("network must be tcp")
|
|
}
|
|
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("split host port %q: %w", addr, err)
|
|
}
|
|
|
|
// Verify that the port is TailnetStatisticsPort.
|
|
if port != strconv.Itoa(AgentHTTPAPIServerPort) {
|
|
return nil, xerrors.Errorf("request %q does not appear to be for http api", addr)
|
|
}
|
|
|
|
if !c.AwaitReachable(ctx) {
|
|
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
|
}
|
|
|
|
ipAddr, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse host addr: %w", err)
|
|
}
|
|
|
|
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, AgentHTTPAPIServerPort))
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("dial http api: %w", err)
|
|
}
|
|
|
|
return conn, nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *agentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
|
|
return c.Conn.GetPeerDiagnostics(c.opts.AgentID)
|
|
}
|