From 81577f120aa7f346c4ad17bfd3b6cd00dd211494 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 29 Apr 2022 17:30:10 -0500 Subject: [PATCH] feat: Add web terminal with reconnecting TTYs (#1186) * feat: Add web terminal with reconnecting TTYs This adds a web terminal that can reconnect to resume sessions! No more disconnects, and no more bad bufferring! * Add xstate service * Add the webpage for accessing a web terminal * Add terminal page tests * Use Ticker instead of Timer * Active Windows mode on Windows --- .vscode/settings.json | 4 + agent/agent.go | 269 ++++++++++++++++- agent/agent_test.go | 63 +++- agent/conn.go | 21 ++ cli/agent.go | 4 +- cli/configssh_test.go | 4 +- cli/ssh_test.go | 8 +- coderd/coderd.go | 1 + coderd/workspaceagents.go | 136 ++++++++- coderd/workspaceagents_test.go | 87 +++++- codersdk/workspaceagents.go | 31 ++ go.mod | 1 + go.sum | 2 + pty/pty_other.go | 4 +- site/jest.config.js | 3 + site/package.json | 9 + site/src/AppRouter.tsx | 14 + site/src/api/index.ts | 21 ++ site/src/api/types.ts | 25 +- .../TemplatePage/TemplatePage.tsx | 4 +- .../pages/TerminalPage/TerminalPage.test.tsx | 149 ++++++++++ site/src/pages/TerminalPage/TerminalPage.tsx | 249 ++++++++++++++++ site/src/testHelpers/entities.ts | 16 + site/src/testHelpers/handlers.ts | 11 + site/src/testHelpers/styleMock.ts | 1 + .../xServices/terminal/terminalXService.ts | 275 ++++++++++++++++++ site/webpack.dev.ts | 5 +- site/yarn.lock | 70 ++++- 28 files changed, 1448 insertions(+), 39 deletions(-) create mode 100644 site/src/pages/TerminalPage/TerminalPage.test.tsx create mode 100644 site/src/pages/TerminalPage/TerminalPage.tsx create mode 100644 site/src/testHelpers/styleMock.ts create mode 100644 site/src/xServices/terminal/terminalXService.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 495f16f083..6f7ea5c69f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "circbuf", "cliflag", "cliui", "coderd", @@ -47,6 +48,7 @@ "ptty", "ptytest", "retrier", + "rpty", "sdkproto", "Signup", "stretchr", @@ -60,8 +62,10 @@ "unconvert", "Untar", "VMID", + "weblinks", "webrtc", "xerrors", + "xstate", "yamux" ], "emeraldwalk.runonsave": { diff --git a/agent/agent.go b/agent/agent.go index eefebc8b82..678f78fbf6 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "encoding/json" "errors" "fmt" "io" @@ -12,10 +13,14 @@ import ( "os/exec" "os/user" "runtime" + "strconv" "strings" "sync" "time" + "github.com/armon/circbuf" + "github.com/google/uuid" + gsyslog "github.com/hashicorp/go-syslog" "go.uber.org/atomic" @@ -33,6 +38,11 @@ import ( "golang.org/x/xerrors" ) +type Options struct { + ReconnectingPTYTimeout time.Duration + Logger slog.Logger +} + type Metadata struct { OwnerEmail string `json:"owner_email"` OwnerUsername string `json:"owner_username"` @@ -42,13 +52,20 @@ type Metadata struct { type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker.Listener, error) -func New(dialer Dialer, logger slog.Logger) io.Closer { +func New(dialer Dialer, options *Options) io.Closer { + if options == nil { + options = &Options{} + } + if options.ReconnectingPTYTimeout == 0 { + options.ReconnectingPTYTimeout = 5 * time.Minute + } ctx, cancelFunc := context.WithCancel(context.Background()) server := &agent{ - dialer: dialer, - logger: logger, - closeCancel: cancelFunc, - closed: make(chan struct{}), + dialer: dialer, + reconnectingPTYTimeout: options.ReconnectingPTYTimeout, + logger: options.Logger, + closeCancel: cancelFunc, + closed: make(chan struct{}), } server.init(ctx) return server @@ -58,6 +75,9 @@ type agent struct { dialer Dialer logger slog.Logger + reconnectingPTYs sync.Map + reconnectingPTYTimeout time.Duration + connCloseWait sync.WaitGroup closeCancel context.CancelFunc closeMutex sync.Mutex @@ -196,6 +216,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { switch channel.Protocol() { case "ssh": go a.sshServer.HandleConn(channel.NetConn()) + case "reconnecting-pty": + go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn()) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -282,22 +304,25 @@ func (a *agent) init(ctx context.Context) { go a.run(ctx) } -func (a *agent) handleSSHSession(session ssh.Session) error { +// createCommand processes raw command input with OpenSSH-like behavior. +// If the rawCommand provided is empty, it will default to the users shell. +// This injects environment variables specified by the user at launch too. +func (a *agent) createCommand(ctx context.Context, rawCommand string, env []string) (*exec.Cmd, error) { currentUser, err := user.Current() if err != nil { - return xerrors.Errorf("get current user: %w", err) + return nil, xerrors.Errorf("get current user: %w", err) } username := currentUser.Username shell, err := usershell.Get(username) if err != nil { - return xerrors.Errorf("get user shell: %w", err) + return nil, xerrors.Errorf("get user shell: %w", err) } // gliderlabs/ssh returns a command slice of zero // when a shell is requested. - command := session.RawCommand() - if len(session.Command()) == 0 { + command := rawCommand + if len(command) == 0 { command = shell } @@ -307,11 +332,11 @@ func (a *agent) handleSSHSession(session ssh.Session) error { if runtime.GOOS == "windows" { caller = "/c" } - cmd := exec.CommandContext(session.Context(), shell, caller, command) - cmd.Env = append(os.Environ(), session.Environ()...) + cmd := exec.CommandContext(ctx, shell, caller, command) + cmd.Env = append(os.Environ(), env...) executablePath, err := os.Executable() if err != nil { - return xerrors.Errorf("getting os executable: %w", err) + return nil, xerrors.Errorf("getting os executable: %w", err) } // Git on Windows resolves with UNIX-style paths. // If using backslashes, it's unable to find the executable. @@ -332,6 +357,14 @@ func (a *agent) handleSSHSession(session ssh.Session) error { } } } + return cmd, nil +} + +func (a *agent) handleSSHSession(session ssh.Session) error { + cmd, err := a.createCommand(session.Context(), session.RawCommand(), session.Environ()) + if err != nil { + return err + } sshPty, windowSize, isPty := session.Pty() if isPty { @@ -381,6 +414,194 @@ func (a *agent) handleSSHSession(session ssh.Session) error { return cmd.Wait() } +func (a *agent) handleReconnectingPTY(ctx context.Context, rawID string, conn net.Conn) { + defer conn.Close() + + // The ID format is referenced in conn.go. + // :: + idParts := strings.Split(rawID, ":") + if len(idParts) != 3 { + a.logger.Warn(ctx, "client sent invalid id format", slog.F("raw-id", rawID)) + return + } + id := idParts[0] + // Enforce a consistent format for IDs. + _, err := uuid.Parse(id) + if err != nil { + a.logger.Warn(ctx, "client sent reconnection token that isn't a uuid", slog.F("id", id), slog.Error(err)) + return + } + // Parse the initial terminal dimensions. + height, err := strconv.Atoi(idParts[1]) + if err != nil { + a.logger.Warn(ctx, "client sent invalid height", slog.F("id", id), slog.F("height", idParts[1])) + return + } + width, err := strconv.Atoi(idParts[2]) + if err != nil { + a.logger.Warn(ctx, "client sent invalid width", slog.F("id", id), slog.F("width", idParts[2])) + return + } + + var rpty *reconnectingPTY + rawRPTY, ok := a.reconnectingPTYs.Load(id) + if ok { + rpty, ok = rawRPTY.(*reconnectingPTY) + if !ok { + a.logger.Warn(ctx, "found invalid type in reconnecting pty map", slog.F("id", id)) + } + } else { + // Empty command will default to the users shell! + cmd, err := a.createCommand(ctx, "", nil) + if err != nil { + a.logger.Warn(ctx, "create reconnecting pty command", slog.Error(err)) + return + } + cmd.Env = append(cmd.Env, "TERM=xterm-256color") + + ptty, process, err := pty.Start(cmd) + if err != nil { + a.logger.Warn(ctx, "start reconnecting pty command", slog.F("id", id)) + } + + // Default to buffer 64KB. + circularBuffer, err := circbuf.NewBuffer(64 * 1024) + if err != nil { + a.logger.Warn(ctx, "create circular buffer", slog.Error(err)) + return + } + + a.closeMutex.Lock() + a.connCloseWait.Add(1) + a.closeMutex.Unlock() + ctx, cancelFunc := context.WithCancel(ctx) + rpty = &reconnectingPTY{ + activeConns: make(map[string]net.Conn), + ptty: ptty, + // Timeouts created with an after func can be reset! + timeout: time.AfterFunc(a.reconnectingPTYTimeout, cancelFunc), + circularBuffer: circularBuffer, + } + a.reconnectingPTYs.Store(id, rpty) + go func() { + // CommandContext isn't respected for Windows PTYs right now, + // so we need to manually track the lifecycle. + // When the context has been completed either: + // 1. The timeout completed. + // 2. The parent context was canceled. + <-ctx.Done() + _ = process.Kill() + }() + go func() { + // If the process dies randomly, we should + // close the pty. + _, _ = process.Wait() + rpty.Close() + }() + go func() { + buffer := make([]byte, 1024) + for { + read, err := rpty.ptty.Output().Read(buffer) + if err != nil { + // When the PTY is closed, this is triggered. + break + } + part := buffer[:read] + _, err = rpty.circularBuffer.Write(part) + if err != nil { + a.logger.Error(ctx, "reconnecting pty write buffer", slog.Error(err), slog.F("id", id)) + break + } + rpty.activeConnsMutex.Lock() + for _, conn := range rpty.activeConns { + _, _ = conn.Write(part) + } + rpty.activeConnsMutex.Unlock() + } + + // Cleanup the process, PTY, and delete it's + // ID from memory. + _ = process.Kill() + rpty.Close() + a.reconnectingPTYs.Delete(id) + a.connCloseWait.Done() + }() + } + // Resize the PTY to initial height + width. + err = rpty.ptty.Resize(uint16(height), uint16(width)) + if err != nil { + // We can continue after this, it's not fatal! + a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err)) + } + // Write any previously stored data for the TTY. + _, err = conn.Write(rpty.circularBuffer.Bytes()) + if err != nil { + a.logger.Warn(ctx, "write reconnecting pty buffer", slog.F("id", id), slog.Error(err)) + return + } + connectionID := uuid.NewString() + // Multiple connections to the same TTY are permitted. + // This could easily be used for terminal sharing, but + // we do it because it's a nice user experience to + // copy/paste a terminal URL and have it _just work_. + rpty.activeConnsMutex.Lock() + rpty.activeConns[connectionID] = conn + rpty.activeConnsMutex.Unlock() + // Resetting this timeout prevents the PTY from exiting. + rpty.timeout.Reset(a.reconnectingPTYTimeout) + + ctx, cancelFunc := context.WithCancel(ctx) + defer cancelFunc() + heartbeat := time.NewTicker(a.reconnectingPTYTimeout / 2) + defer heartbeat.Stop() + go func() { + // Keep updating the activity while this + // connection is alive! + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + } + rpty.timeout.Reset(a.reconnectingPTYTimeout) + } + }() + defer func() { + // After this connection ends, remove it from + // the PTYs active connections. If it isn't + // removed, all PTY data will be sent to it. + rpty.activeConnsMutex.Lock() + delete(rpty.activeConns, connectionID) + rpty.activeConnsMutex.Unlock() + }() + decoder := json.NewDecoder(conn) + var req ReconnectingPTYRequest + for { + err = decoder.Decode(&req) + if xerrors.Is(err, io.EOF) { + return + } + if err != nil { + a.logger.Warn(ctx, "reconnecting pty buffer read error", slog.F("id", id), slog.Error(err)) + return + } + _, err = rpty.ptty.Input().Write([]byte(req.Data)) + if err != nil { + a.logger.Warn(ctx, "write to reconnecting pty", slog.F("id", id), slog.Error(err)) + return + } + // Check if a resize needs to happen! + if req.Height == 0 || req.Width == 0 { + continue + } + err = rpty.ptty.Resize(req.Height, req.Width) + if err != nil { + // We can continue after this, it's not fatal! + a.logger.Error(ctx, "resize reconnecting pty", slog.F("id", id), slog.Error(err)) + } + } +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { @@ -403,3 +624,25 @@ func (a *agent) Close() error { a.connCloseWait.Wait() return nil } + +type reconnectingPTY struct { + activeConnsMutex sync.Mutex + activeConns map[string]net.Conn + + circularBuffer *circbuf.Buffer + timeout *time.Timer + ptty pty.PTY +} + +// Close ends all connections to the reconnecting +// PTY and clear the circular buffer. +func (r *reconnectingPTY) Close() { + r.activeConnsMutex.Lock() + defer r.activeConnsMutex.Unlock() + for _, conn := range r.activeConns { + _ = conn.Close() + } + _ = r.ptty.Close() + r.circularBuffer.Reset() + r.timeout.Stop() +} diff --git a/agent/agent_test.go b/agent/agent_test.go index 2c3b53f8bd..bd26fae7f0 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -2,6 +2,7 @@ package agent_test import ( "context" + "encoding/json" "fmt" "io" "net" @@ -14,6 +15,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/pion/webrtc/v3" "github.com/pkg/sftp" "github.com/stretchr/testify/require" @@ -131,7 +133,7 @@ func TestAgent(t *testing.T) { t.Run("SFTP", func(t *testing.T) { t.Parallel() - sshClient, err := setupAgent(t, agent.Metadata{}).SSHClient() + sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient() require.NoError(t, err) client, err := sftp.NewClient(sshClient) require.NoError(t, err) @@ -168,7 +170,7 @@ func TestAgent(t *testing.T) { content := "somethingnice" setupAgent(t, agent.Metadata{ StartupScript: "echo " + content + " > " + tempPath, - }) + }, 0) var gotContent string require.Eventually(t, func() bool { content, err := os.ReadFile(tempPath) @@ -188,10 +190,54 @@ func TestAgent(t *testing.T) { }, 15*time.Second, 100*time.Millisecond) require.Equal(t, content, strings.TrimSpace(gotContent)) }) + + t.Run("ReconnectingPTY", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + // This might be our implementation, or ConPTY itself. + // It's difficult to find extensive tests for it, so + // it seems like it could be either. + t.Skip("ConPTY appears to be inconsistent on Windows.") + } + conn := setupAgent(t, agent.Metadata{}, 0) + id := uuid.NewString() + netConn, err := conn.ReconnectingPTY(id, 100, 100) + require.NoError(t, err) + + data, err := json.Marshal(agent.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = netConn.Write(data) + require.NoError(t, err) + + findEcho := func() { + for { + read, err := netConn.Read(data) + require.NoError(t, err) + if strings.Contains(string(data[:read]), "test") { + break + } + } + } + + // Once for typing the command... + findEcho() + // And another time for the actual output. + findEcho() + + _ = netConn.Close() + netConn, err = conn.ReconnectingPTY(id, 100, 100) + require.NoError(t, err) + + // Same output again! + findEcho() + findEcho() + }) } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { - agentConn := setupAgent(t, agent.Metadata{}) + agentConn := setupAgent(t, agent.Metadata{}, 0) listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) go func() { @@ -220,19 +266,22 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe } func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { - sshClient, err := setupAgent(t, options).SSHClient() + sshClient, err := setupAgent(t, options, 0).SSHClient() require.NoError(t, err) session, err := sshClient.NewSession() require.NoError(t, err) return session } -func setupAgent(t *testing.T, options agent.Metadata) *agent.Conn { +func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) { listener, err := peerbroker.Listen(server, nil) - return options, listener, err - }, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + return metadata, listener, err + }, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + ReconnectingPTYTimeout: ptyTimeout, + }) t.Cleanup(func() { _ = client.Close() _ = server.Close() diff --git a/agent/conn.go b/agent/conn.go index 8ec49843e3..81a6315af2 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -2,6 +2,7 @@ package agent import ( "context" + "fmt" "net" "golang.org/x/crypto/ssh" @@ -11,6 +12,14 @@ import ( "github.com/coder/coder/peerbroker/proto" ) +// ReconnectingPTYRequest is sent from the client to the server +// to pipe data to a PTY. +type ReconnectingPTYRequest struct { + Data string `json:"data"` + Height uint16 `json:"height"` + Width uint16 `json:"width"` +} + // Conn wraps a peer connection with helper functions to // communicate with the agent. type Conn struct { @@ -20,6 +29,18 @@ type Conn struct { *peer.Conn } +// ReconnectingPTY returns a connection serving a TTY that can +// be reconnected to via ID. +func (c *Conn) ReconnectingPTY(id string, height, width uint16) (net.Conn, error) { + channel, err := c.Dial(context.Background(), fmt.Sprintf("%s:%d:%d", id, height, width), &peer.ChannelOptions{ + Protocol: "reconnecting-pty", + }) + if err != nil { + return nil, xerrors.Errorf("pty: %w", err) + } + return channel.NetConn(), nil +} + // SSH dials the built-in SSH server. func (c *Conn) SSH() (net.Conn, error) { channel, err := c.Dial(context.Background(), "ssh", &peer.ChannelOptions{ diff --git a/cli/agent.go b/cli/agent.go index cb1a456105..754550b938 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -125,7 +125,9 @@ func workspaceAgent() *cobra.Command { return xerrors.Errorf("writing agent url to config: %w", err) } - closer := agent.New(client.ListenWorkspaceAgent, logger) + closer := agent.New(client.ListenWorkspaceAgent, &agent.Options{ + Logger: logger, + }) <-cmd.Context().Done() return closer.Close() }, diff --git a/cli/configssh_test.go b/cli/configssh_test.go index 70a6e4feb7..61b83c0781 100644 --- a/cli/configssh_test.go +++ b/cli/configssh_test.go @@ -71,7 +71,9 @@ func TestConfigSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) t.Cleanup(func() { _ = agentCloser.Close() }) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 095720b35c..868f813b1c 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -69,7 +69,9 @@ func TestSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -112,7 +114,9 @@ func TestSSH(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) agentClient := codersdk.New(client.URL) agentClient.SessionToken = agentToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) diff --git a/coderd/coderd.go b/coderd/coderd.go index f06dc36f13..b1cb613fc8 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -228,6 +228,7 @@ func New(options *Options) (http.Handler, func()) { r.Get("/", api.workspaceAgent) r.Get("/dial", api.workspaceAgentDial) r.Get("/turn", api.workspaceAgentTurn) + r.Get("/pty", api.workspaceAgentPTY) r.Get("/iceservers", api.workspaceAgentICEServers) }) }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 4873d13420..24c35ee5ff 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -10,6 +10,7 @@ import ( "strconv" "time" + "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" "nhooyr.io/websocket" @@ -19,7 +20,9 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" "github.com/coder/coder/peerbroker" "github.com/coder/coder/peerbroker/proto" "github.com/coder/coder/provisionersdk" @@ -59,9 +62,7 @@ func (api *api) workspaceAgentDial(rw http.ResponseWriter, r *http.Request) { return } - conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ - CompressionMode: websocket.CompressionDisabled, - }) + conn, err := websocket.Accept(rw, r, nil) if err != nil { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("accept websocket: %s", err), @@ -320,6 +321,135 @@ func (api *api) workspaceAgentTurn(rw http.ResponseWriter, r *http.Request) { api.Logger.Debug(r.Context(), "completed turn connection", slog.F("remote-address", r.RemoteAddr), slog.F("local-address", localAddress)) } +// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket. +// This is used for the web terminal. +func (api *api) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ + Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status), + }) + return + } + + reconnect, err := uuid.Parse(r.URL.Query().Get("reconnect")) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("reconnection must be a uuid: %s", err), + }) + return + } + height, err := strconv.Atoi(r.URL.Query().Get("height")) + if err != nil { + height = 80 + } + width, err := strconv.Atoi(r.URL.Query().Get("width")) + if err != nil { + width = 80 + } + + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "ended") + }() + // Accept text connections, because it's more developer friendly. + wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary) + agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err)) + return + } + defer agentConn.Close() + ptNetConn, err := agentConn.ReconnectingPTY(reconnect.String(), uint16(height), uint16(width)) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err)) + return + } + defer ptNetConn.Close() + // Pipe the ends together! + go func() { + _, _ = io.Copy(wsNetConn, ptNetConn) + }() + _, _ = io.Copy(ptNetConn, wsNetConn) +} + +// dialWorkspaceAgent connects to a workspace agent by ID. +func (api *api) dialWorkspaceAgent(r *http.Request, agentID uuid.UUID) (*agent.Conn, error) { + client, server := provisionersdk.TransportPipe() + go func() { + _ = peerbroker.ProxyListen(r.Context(), server, peerbroker.ProxyOptions{ + ChannelID: agentID.String(), + Logger: api.Logger.Named("peerbroker-proxy-dial"), + Pubsub: api.Pubsub, + }) + _ = client.Close() + _ = server.Close() + }() + + peerClient := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client)) + stream, err := peerClient.NegotiateConnection(r.Context()) + if err != nil { + return nil, xerrors.Errorf("negotiate: %w", err) + } + options := &peer.ConnOptions{} + options.SettingEngine.SetSrflxAcceptanceMinWait(0) + options.SettingEngine.SetRelayAcceptanceMinWait(0) + // Use the ProxyDialer for the TURN server. + // This is required for connections where P2P is not enabled. + options.SettingEngine.SetICEProxyDialer(turnconn.ProxyDialer(func() (c net.Conn, err error) { + clientPipe, serverPipe := net.Pipe() + go func() { + <-r.Context().Done() + _ = clientPipe.Close() + _ = serverPipe.Close() + }() + localAddress, _ := r.Context().Value(http.LocalAddrContextKey).(*net.TCPAddr) + remoteAddress := &net.TCPAddr{ + IP: net.ParseIP(r.RemoteAddr), + } + // By default requests have the remote address and port. + host, port, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil, xerrors.Errorf("split remote address: %w", err) + } + remoteAddress.IP = net.ParseIP(host) + remoteAddress.Port, err = strconv.Atoi(port) + if err != nil { + return nil, xerrors.Errorf("convert remote port: %w", err) + } + api.TURNServer.Accept(clientPipe, remoteAddress, localAddress) + return serverPipe, nil + })) + peerConn, err := peerbroker.Dial(stream, append(api.ICEServers, turnconn.Proxy), options) + if err != nil { + return nil, xerrors.Errorf("dial: %w", err) + } + return &agent.Conn{ + Negotiator: peerClient, + Conn: peerConn, + }, nil +} + func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency time.Duration) (codersdk.WorkspaceAgent, error) { var envs map[string]string if dbAgent.EnvironmentVariables.Valid { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 108d02e2f2..2b740dcb0f 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -2,6 +2,8 @@ package coderd_test import ( "context" + "encoding/json" + "strings" "testing" "github.com/google/uuid" @@ -90,7 +92,9 @@ func TestWorkspaceAgentListen(t *testing.T) { agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -138,7 +142,9 @@ func TestWorkspaceAgentTURN(t *testing.T) { agentClient := codersdk.New(client.URL) agentClient.SessionToken = authToken - agentCloser := agent.New(agentClient.ListenWorkspaceAgent, slogtest.Make(t, nil)) + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) t.Cleanup(func() { _ = agentCloser.Close() }) @@ -156,3 +162,80 @@ func TestWorkspaceAgentTURN(t *testing.T) { _, err = conn.Ping() require.NoError(t, err) } + +func TestWorkspaceAgentPTY(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + conn, err := client.WorkspaceAgentReconnectingPTY(context.Background(), resources[0].Agents[0].ID, uuid.New(), 80, 80) + require.NoError(t, err) + defer conn.Close() + + // First attempt to resize the TTY. + // The websocket will close if it fails! + data, err := json.Marshal(agent.ReconnectingPTYRequest{ + Height: 250, + Width: 250, + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + data, err = json.Marshal(agent.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + findEcho := func() { + for { + read, err := conn.Read(data) + require.NoError(t, err) + if strings.Contains(string(data[:read]), "test") { + return + } + } + } + + findEcho() + findEcho() +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 284a363240..be98b4696f 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -338,6 +338,37 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) } +// WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided. +// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON. +// Responses are PTY output that can be rendered. +func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, reconnect uuid.UUID, height, width int) (net.Conn, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty?reconnect=%s&height=%d&width=%d", agentID, reconnect, height, width)) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.AuthCookie, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil +} + func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer { return turnconn.ProxyDialer(func() (net.Conn, error) { turnURL, err := c.URL.Parse(path) diff --git a/go.mod b/go.mod index 32a0550c2e..958bef712f 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( cdr.dev/slog v1.4.1 cloud.google.com/go/compute v1.6.1 github.com/AlecAivazis/survey/v2 v2.3.4 + github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/bgentry/speakeasy v0.1.0 github.com/briandowns/spinner v1.18.1 diff --git a/go.sum b/go.sum index ce5e08df20..2fb2e435b3 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +189,8 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= +github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.0/go.mod h1:zXjbSimjXTd7vOpY8B0/2LpvNvDoXBuplAD+gJD3GYs= diff --git a/pty/pty_other.go b/pty/pty_other.go index 5884eb13cc..b826bd3a33 100644 --- a/pty/pty_other.go +++ b/pty/pty_other.go @@ -46,8 +46,8 @@ func (p *otherPty) Resize(height uint16, width uint16) error { p.mutex.Lock() defer p.mutex.Unlock() return pty.Setsize(p.pty, &pty.Winsize{ - Rows: width, - Cols: height, + Rows: height, + Cols: width, }) } diff --git a/site/jest.config.js b/site/jest.config.js index e53c2d01d0..5cc3ea0bbb 100644 --- a/site/jest.config.js +++ b/site/jest.config.js @@ -28,6 +28,9 @@ module.exports = { testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", testPathIgnorePatterns: ["/node_modules/", "/__tests__/fakes", "/e2e/"], moduleDirectories: ["node_modules", ""], + moduleNameMapper: { + "\\.css$": "/src/testHelpers/styleMock.ts", + }, }, { displayName: "lint", diff --git a/site/package.json b/site/package.json index 1b6a2d6729..bb0f7230fc 100644 --- a/site/package.json +++ b/site/package.json @@ -41,7 +41,13 @@ "react-dom": "17.0.2", "react-router-dom": "6.3.0", "swr": "1.2.2", + "uuid": "^8.3.2", "xstate": "4.31.0", + "xterm": "^4.18.0", + "xterm-addon-fit": "^0.5.0", + "xterm-addon-web-links": "^0.5.1", + "xterm-addon-webgl": "^0.11.4", + "xterm-for-react": "^1.0.4", "yup": "0.32.11" }, "devDependencies": { @@ -60,6 +66,7 @@ "@types/react": "17.0.44", "@types/react-dom": "17.0.16", "@types/superagent": "4.1.15", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "5.21.0", "@typescript-eslint/parser": "5.21.0", "@xstate/cli": "0.1.7", @@ -80,8 +87,10 @@ "eslint-plugin-react-hooks": "4.5.0", "html-webpack-plugin": "5.5.0", "jest": "27.5.1", + "jest-canvas-mock": "^2.4.0", "jest-junit": "13.2.0", "jest-runner-eslint": "1.0.0", + "jest-websocket-mock": "^2.3.0", "mini-css-extract-plugin": "2.6.0", "msw": "0.39.2", "prettier": "2.6.2", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 691f46bf9a..134559e3b9 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage" import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage" import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage" import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage" +import { TerminalPage } from "./pages/TerminalPage/TerminalPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage" @@ -126,6 +127,19 @@ export const AppRouter: React.FC = () => ( } /> + + + + + + } + /> + + + {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit routes for. */} diff --git a/site/src/api/index.ts b/site/src/api/index.ts index fd773394f6..078de80e3c 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -80,6 +80,27 @@ export const getUsers = async (): Promise => { return response.data } +export const getOrganizations = async (): Promise => { + const response = await axios.get("/api/v2/users/me/organizations") + return response.data +} + +export const getWorkspace = async ( + organizationID: string, + username = "me", + workspaceName: string, +): Promise => { + const response = await axios.get( + `/api/v2/organizations/${organizationID}/workspaces/${username}/${workspaceName}`, + ) + return response.data +} + +export const getWorkspaceResources = async (workspaceBuildID: string): Promise => { + const response = await axios.get(`/api/v2/workspacebuilds/${workspaceBuildID}/resources`) + return response.data +} + export const createUser = async (user: Types.CreateUserRequest): Promise => { const response = await axios.post("/api/v2/users", user) return response.data diff --git a/site/src/api/types.ts b/site/src/api/types.ts index a431b756f4..f2308aeb9b 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -63,9 +63,10 @@ export interface CreateWorkspaceRequest { template_id: string } -/** - * @remarks Keep in sync with codersdk/workspaces.go - */ +export interface WorkspaceBuild { + id: string +} + export interface Workspace { id: string created_at: string @@ -75,6 +76,18 @@ export interface Workspace { name: string autostart_schedule: string autostop_schedule: string + latest_build: WorkspaceBuild +} + +export interface WorkspaceResource { + id: string + agents?: WorkspaceAgent[] +} + +export interface WorkspaceAgent { + id: string + name: string + operating_system: string } export interface APIKeyResponse { @@ -100,3 +113,9 @@ export interface UpdateProfileRequest { readonly username: string readonly email: string } + +export interface ReconnectingPTYRequest { + readonly data?: string + readonly height?: number + readonly width?: number +} diff --git a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx index fa3347fb65..a7ea381efd 100644 --- a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx @@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles" import React from "react" import { Link, useNavigate, useParams } from "react-router-dom" import useSWR from "swr" -import { Organization, Template, Workspace } from "../../../../api/types" +import { Organization, Template, Workspace, WorkspaceBuild } from "../../../../api/types" import { EmptyState } from "../../../../components/EmptyState/EmptyState" import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary" import { Header } from "../../../../components/Header/Header" @@ -64,7 +64,7 @@ export const TemplatePage: React.FC = () => { { key: "name", name: "Name", - renderer: (nameField: string, workspace: Workspace) => { + renderer: (nameField: string | WorkspaceBuild, workspace: Workspace) => { return {nameField} }, }, diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx new file mode 100644 index 0000000000..270e28cc1d --- /dev/null +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -0,0 +1,149 @@ +import { waitFor } from "@testing-library/react" +import "jest-canvas-mock" +import WS from "jest-websocket-mock" +import { rest } from "msw" +import React from "react" +import { Route, Routes } from "react-router-dom" +import { TextDecoder, TextEncoder } from "util" +import { ReconnectingPTYRequest } from "../../api/types" +import { history, MockWorkspaceAgent, render } from "../../testHelpers" +import { server } from "../../testHelpers/server" +import { Language, TerminalPage } from "./TerminalPage" + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) + +Object.defineProperty(window, "TextEncoder", { + value: TextEncoder, +}) + +const renderTerminal = () => { + return render( + + } /> + , + ) +} + +const expectTerminalText = (container: HTMLElement, text: string) => { + return waitFor(() => { + const elements = container.getElementsByClassName("xterm-rows") + if (elements.length < 1) { + throw new Error("no xterm-rows") + } + const row = elements[0] as HTMLDivElement + if (!row.textContent) { + throw new Error("no text content") + } + expect(row.textContent).toContain(text) + }) +} + +describe("TerminalPage", () => { + beforeEach(() => { + history.push("/some-user/my-workspace/terminal") + }) + + it("shows an error if fetching organizations fails", async () => { + // Given + server.use( + rest.get("/api/v2/users/me/organizations", async (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ message: "nope" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.organizationsErrorMessagePrefix) + }) + + it("shows an error if fetching workspace fails", async () => { + // Given + server.use( + rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ id: "workspace-id" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.workspaceErrorMessagePrefix) + }) + + it("shows an error if fetching workspace agent fails", async () => { + // Given + server.use( + rest.get("/api/v2/workspacebuilds/:workspaceId/resources", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({ message: "nope" })) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.workspaceAgentErrorMessagePrefix) + }) + + it("shows an error if the websocket fails", async () => { + // Given + server.use( + rest.get("/api/v2/workspaceagents/:agentId/pty", (req, res, ctx) => { + return res(ctx.status(500), ctx.json({})) + }), + ) + + // When + const { container } = renderTerminal() + + // Then + await expectTerminalText(container, Language.websocketErrorMessagePrefix) + }) + + it("renders data from the backend", async () => { + // Given + const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") + const text = "something to render" + + // When + const { container } = renderTerminal() + + // Then + await server.connected + server.send(text) + await expectTerminalText(container, text) + server.close() + }) + + it("resizes on connect", async () => { + // Given + const server = new WS("ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty") + + // When + renderTerminal() + + // Then + await server.connected + const msg = await server.nextMessage + const req: ReconnectingPTYRequest = JSON.parse(new TextDecoder().decode(msg as Uint8Array)) + + expect(req.height).toBeGreaterThan(0) + expect(req.width).toBeGreaterThan(0) + server.close() + }) +}) diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx new file mode 100644 index 0000000000..a95785ae7d --- /dev/null +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -0,0 +1,249 @@ +import { makeStyles } from "@material-ui/core/styles" +import { useMachine } from "@xstate/react" +import React from "react" +import { useLocation, useNavigate, useParams } from "react-router-dom" +import { v4 as uuidv4 } from "uuid" +import * as XTerm from "xterm" +import { FitAddon } from "xterm-addon-fit" +import { WebLinksAddon } from "xterm-addon-web-links" +import "xterm/css/xterm.css" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import { terminalMachine } from "../../xServices/terminal/terminalXService" + +export const Language = { + organizationsErrorMessagePrefix: "Unable to fetch organizations: ", + workspaceErrorMessagePrefix: "Unable to fetch workspace: ", + workspaceAgentErrorMessagePrefix: "Unable to fetch workspace agent: ", + websocketErrorMessagePrefix: "WebSocket failed: ", +} + +export const TerminalPage: React.FC<{ + readonly renderer?: XTerm.RendererType +}> = ({ renderer }) => { + const location = useLocation() + const navigate = useNavigate() + const styles = useStyles() + const { username, workspace } = useParams() + const xtermRef = React.useRef(null) + const [terminal, setTerminal] = React.useState(null) + const [fitAddon, setFitAddon] = React.useState(null) + // The reconnection token is a unique token that identifies + // a terminal session. It's generated by the client to reduce + // a round-trip, and must be a UUIDv4. + const [reconnectionToken] = React.useState(() => { + const search = new URLSearchParams(location.search) + return search.get("reconnect") ?? uuidv4() + }) + const [terminalState, sendEvent] = useMachine(terminalMachine, { + context: { + reconnection: reconnectionToken, + workspaceName: workspace, + username: username, + }, + actions: { + readMessage: (_, event) => { + if (typeof event.data === "string") { + // This exclusively occurs when testing. + // "jest-websocket-mock" doesn't support ArrayBuffer. + terminal?.write(event.data) + } else { + terminal?.write(new Uint8Array(event.data)) + } + }, + }, + }) + const isConnected = terminalState.matches("connected") + const isDisconnected = terminalState.matches("disconnected") + const { organizationsError, workspaceError, workspaceAgentError, workspaceAgent, websocketError } = + terminalState.context + + // Create the terminal! + React.useEffect(() => { + if (!xtermRef.current) { + return + } + const terminal = new XTerm.Terminal({ + allowTransparency: true, + disableStdin: false, + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 16, + theme: { + // This is a slight off-black. + // It's really easy on the eyes! + background: "#1F1F1F", + }, + rendererType: renderer, + }) + const fitAddon = new FitAddon() + setFitAddon(fitAddon) + terminal.loadAddon(fitAddon) + terminal.loadAddon(new WebLinksAddon()) + terminal.onData((data) => { + sendEvent({ + type: "WRITE", + request: { + data: data, + }, + }) + }) + terminal.onResize((event) => { + sendEvent({ + type: "WRITE", + request: { + height: event.rows, + width: event.cols, + }, + }) + }) + setTerminal(terminal) + terminal.open(xtermRef.current) + const listener = () => { + // This will trigger a resize event on the terminal. + fitAddon.fit() + } + window.addEventListener("resize", listener) + return () => { + window.removeEventListener("resize", listener) + terminal.dispose() + } + }, [renderer, sendEvent, xtermRef]) + + // Triggers the initial terminal connection using + // the reconnection token and workspace name found + // from the router. + React.useEffect(() => { + const search = new URLSearchParams(location.search) + search.set("reconnect", reconnectionToken) + navigate( + { + search: search.toString(), + }, + { + replace: true, + }, + ) + }, [location.search, navigate, reconnectionToken]) + + // Apply terminal options based on connection state. + React.useEffect(() => { + if (!terminal || !fitAddon) { + return + } + + // We have to fit twice here. It's unknown why, but + // the first fit will overflow slightly in some + // scenarios. Applying a second fit resolves this. + fitAddon.fit() + fitAddon.fit() + + if (!isConnected) { + // Disable user input when not connected. + terminal.options = { + disableStdin: true, + } + if (organizationsError instanceof Error) { + terminal.writeln(Language.organizationsErrorMessagePrefix + organizationsError.message) + } + if (workspaceError instanceof Error) { + terminal.writeln(Language.workspaceErrorMessagePrefix + workspaceError.message) + } + if (workspaceAgentError instanceof Error) { + terminal.writeln(Language.workspaceAgentErrorMessagePrefix + workspaceAgentError.message) + } + if (websocketError instanceof Error) { + terminal.writeln(Language.websocketErrorMessagePrefix + websocketError.message) + } + return + } + + // The terminal should be cleared on each reconnect + // because all data is re-rendered from the backend. + terminal.clear() + + // Focusing on connection allows users to reload the + // page and start typing immediately. + terminal.focus() + terminal.options = { + disableStdin: false, + windowsMode: workspaceAgent?.operating_system === "windows", + } + + // Update the terminal size post-fit. + sendEvent({ + type: "WRITE", + request: { + height: terminal.rows, + width: terminal.cols, + }, + }) + }, [ + workspaceError, + organizationsError, + workspaceAgentError, + websocketError, + workspaceAgent, + terminal, + fitAddon, + isConnected, + sendEvent, + ]) + + return ( + <> + {/* This overlay makes it more obvious that the terminal is disconnected. */} + {/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */} +
+ Disconnected +
+
+ + ) +} + +const useStyles = makeStyles(() => ({ + overlay: { + position: "absolute", + pointerEvents: "none", + top: 0, + left: 0, + bottom: 0, + right: 0, + zIndex: 1, + alignItems: "center", + justifyContent: "center", + display: "flex", + color: "white", + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 18, + backgroundColor: "rgba(0, 0, 0, 0.2)", + "&.connected": { + opacity: 0, + }, + }, + terminal: { + width: "100vw", + height: "100vh", + overflow: "hidden", + // These styles attempt to mimic the VS Code scrollbar. + "& .xterm": { + padding: 4, + width: "100vw", + height: "100vh", + }, + "& .xterm-viewport": { + // This is required to force full-width on the terminal. + // Otherwise there's a small white bar to the right of the scrollbar. + width: "auto !important", + }, + "& .xterm-viewport::-webkit-scrollbar": { + width: "10px", + }, + "& .xterm-viewport::-webkit-scrollbar-track": { + backgroundColor: "inherit", + }, + "& .xterm-viewport::-webkit-scrollbar-thumb": { + minHeight: 20, + backgroundColor: "rgba(255, 255, 255, 0.18)", + }, + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index dca9b8d1fe..e7c3e91274 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -6,7 +6,9 @@ import { UserAgent, UserResponse, Workspace, + WorkspaceAgent, WorkspaceAutostartRequest, + WorkspaceResource, } from "../api/types" import { AuthMethods } from "../api/typesGenerated" @@ -87,6 +89,20 @@ export const MockWorkspace: Workspace = { owner_id: MockUser.id, autostart_schedule: MockWorkspaceAutostartEnabled.schedule, autostop_schedule: MockWorkspaceAutostopEnabled.schedule, + latest_build: { + id: "test-workspace-build", + }, +} + +export const MockWorkspaceAgent: WorkspaceAgent = { + id: "test-workspace-agent", + name: "a-workspace-agent", + operating_system: "linux", +} + +export const MockWorkspaceResource: WorkspaceResource = { + id: "test-workspace-resource", + agents: [MockWorkspaceAgent], } export const MockUserAgent: UserAgent = { diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 8774267a55..dbc2334c13 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -30,6 +30,9 @@ export const handlers = [ rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), + rest.get("/api/v2/users/me/organizations", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockOrganization])) + }), rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockOrganization)) }), @@ -50,6 +53,9 @@ export const handlers = [ }), // workspaces + rest.get("/api/v2/organizations/:organizationId/workspaces/:userName/:workspaceName", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspace)) + }), rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), @@ -59,4 +65,9 @@ export const handlers = [ rest.put("/api/v2/workspaces/:workspaceId/autostop", async (req, res, ctx) => { return res(ctx.status(200)) }), + + // workspace builds + rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([M.MockWorkspaceResource])) + }), ] diff --git a/site/src/testHelpers/styleMock.ts b/site/src/testHelpers/styleMock.ts new file mode 100644 index 0000000000..b1c6ea436a --- /dev/null +++ b/site/src/testHelpers/styleMock.ts @@ -0,0 +1 @@ +export default {} diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts new file mode 100644 index 0000000000..a308836842 --- /dev/null +++ b/site/src/xServices/terminal/terminalXService.ts @@ -0,0 +1,275 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api" +import * as Types from "../../api/types" + +export interface TerminalContext { + organizationsError?: Error | unknown + organizations?: Types.Organization[] + workspaceError?: Error | unknown + workspace?: Types.Workspace + workspaceAgent?: Types.WorkspaceAgent + workspaceAgentError?: Error | unknown + websocket?: WebSocket + websocketError?: Error | unknown + + // Assigned by connecting! + username?: string + workspaceName?: string + reconnection?: string +} + +export type TerminalEvent = + | { type: "CONNECT"; reconnection?: string; workspaceName?: string; username?: string } + | { type: "WRITE"; request: Types.ReconnectingPTYRequest } + | { type: "READ"; data: ArrayBuffer } + | { type: "DISCONNECT" } + +export const terminalMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5FKQd27OJTNVAA */ + createMachine( + { + tsTypes: {} as import("./terminalXService.typegen").Typegen0, + schema: { + context: {} as TerminalContext, + events: {} as TerminalEvent, + services: {} as { + getOrganizations: { + data: Types.Organization[] + } + getWorkspace: { + data: Types.Workspace + } + getWorkspaceAgent: { + data: Types.WorkspaceAgent + } + connect: { + data: WebSocket + } + }, + }, + id: "terminalState", + initial: "gettingOrganizations", + states: { + gettingOrganizations: { + invoke: { + src: "getOrganizations", + id: "getOrganizations", + onDone: [ + { + actions: ["assignOrganizations", "clearOrganizationsError"], + target: "gettingWorkspace", + }, + ], + onError: [ + { + actions: "assignOrganizationsError", + target: "disconnected", + }, + ], + }, + tags: "loading", + }, + gettingWorkspace: { + invoke: { + src: "getWorkspace", + id: "getWorkspace", + onDone: [ + { + actions: ["assignWorkspace", "clearWorkspaceError"], + target: "gettingWorkspaceAgent", + }, + ], + onError: [ + { + actions: "assignWorkspaceError", + target: "disconnected", + }, + ], + }, + }, + gettingWorkspaceAgent: { + invoke: { + src: "getWorkspaceAgent", + id: "getWorkspaceAgent", + onDone: [ + { + actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"], + target: "connecting", + }, + ], + onError: [ + { + actions: "assignWorkspaceAgentError", + target: "disconnected", + }, + ], + }, + }, + connecting: { + invoke: { + src: "connect", + id: "connect", + onDone: [ + { + actions: ["assignWebsocket", "clearWebsocketError"], + target: "connected", + }, + ], + onError: [ + { + actions: "assignWebsocketError", + target: "disconnected", + }, + ], + }, + }, + connected: { + on: { + WRITE: { + actions: "sendMessage", + }, + READ: { + actions: "readMessage", + }, + DISCONNECT: { + actions: "disconnect", + target: "disconnected", + }, + }, + }, + disconnected: { + on: { + CONNECT: { + actions: "assignConnection", + target: "gettingOrganizations", + }, + }, + }, + }, + }, + { + services: { + getOrganizations: API.getOrganizations, + getWorkspace: async (context) => { + if (!context.organizations || !context.workspaceName) { + throw new Error("organizations or workspace not set") + } + return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName) + }, + getWorkspaceAgent: async (context) => { + if (!context.workspace || !context.workspaceName) { + throw new Error("workspace or workspace name is not set") + } + // The workspace name is in the format: + // [.] + // The workspace agent is entirely optional. + const workspaceNameParts = context.workspaceName.split(".") + const agentName = workspaceNameParts[1] + + const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) + + const agent = resources + .map((resource) => { + if (!resource.agents || resource.agents.length < 1) { + return + } + if (!agentName) { + return resource.agents[0] + } + return resource.agents.find((agent) => agent.name === agentName) + }) + .filter((a) => a)[0] + if (!agent) { + throw new Error("no agent found with id") + } + return agent + }, + connect: (context) => (send) => { + return new Promise((resolve, reject) => { + if (!context.workspaceAgent) { + return reject("workspace agent is not set") + } + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}`, + ) + socket.binaryType = "arraybuffer" + socket.addEventListener("open", () => { + resolve(socket) + }) + socket.addEventListener("error", () => { + reject(new Error("socket errored")) + }) + socket.addEventListener("close", () => { + send({ + type: "DISCONNECT", + }) + }) + socket.addEventListener("message", (event) => { + send({ + type: "READ", + data: event.data, + }) + }) + }) + }, + }, + actions: { + assignConnection: assign((context, event) => ({ + ...context, + reconnection: event.reconnection ?? context.reconnection, + workspaceName: event.workspaceName ?? context.workspaceName, + })), + assignOrganizations: assign({ + organizations: (_, event) => event.data, + }), + assignOrganizationsError: assign({ + organizationsError: (_, event) => event.data, + }), + clearOrganizationsError: assign((context) => ({ + ...context, + organizationsError: undefined, + })), + assignWorkspace: assign({ + workspace: (_, event) => event.data, + }), + assignWorkspaceError: assign({ + workspaceError: (_, event) => event.data, + }), + clearWorkspaceError: assign((context) => ({ + ...context, + workspaceError: undefined, + })), + assignWorkspaceAgent: assign({ + workspaceAgent: (_, event) => event.data, + }), + assignWorkspaceAgentError: assign({ + workspaceAgentError: (_, event) => event.data, + }), + clearWorkspaceAgentError: assign((context: TerminalContext) => ({ + ...context, + workspaceAgentError: undefined, + })), + assignWebsocket: assign({ + websocket: (_, event) => event.data, + }), + assignWebsocketError: assign({ + websocketError: (_, event) => event.data, + }), + clearWebsocketError: assign((context: TerminalContext) => ({ + ...context, + webSocketError: undefined, + })), + sendMessage: (context, event) => { + if (!context.websocket) { + throw new Error("websocket doesn't exist") + } + context.websocket.send(new TextEncoder().encode(JSON.stringify(event.request))) + }, + disconnect: (context: TerminalContext) => { + // Code 1000 is a successful exit! + context.websocket?.close(1000) + }, + }, + }, + ) diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts index 642f358f4a..47e78b236f 100644 --- a/site/webpack.dev.ts +++ b/site/webpack.dev.ts @@ -60,7 +60,10 @@ const config: Configuration = { hot: true, port: process.env.PORT || 8080, proxy: { - "/api": "http://localhost:3000", + "/api": { + target: "http://localhost:3000", + ws: true, + }, }, static: ["./static"], }, diff --git a/site/yarn.lock b/site/yarn.lock index b731d95945..5de660c468 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -3194,6 +3194,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/webpack-env@^1.16.0": version "1.16.3" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.3.tgz#b776327a73e561b71e7881d0cd6d34a1424db86a" @@ -5083,7 +5088,7 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -5586,6 +5591,11 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssfontparser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3" + integrity sha1-9AIvyPlwDGgCnVQghK+69CWj8+M= + cssnano-preset-default@^5.2.5: version "5.2.5" resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.5.tgz#267ded811a3e1664d78707f5355fcd89feeb38ac" @@ -8510,6 +8520,14 @@ iterate-value@^1.0.2: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +jest-canvas-mock@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341" + integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ== + dependencies: + cssfontparser "^1.2.1" + moo-color "^1.0.2" + jest-changed-files@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-27.5.1.tgz#a348aed00ec9bf671cc58a66fcbe7c3dfd6a68f5" @@ -8592,7 +8610,7 @@ jest-config@^27.5.1: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^27.2.5, jest-diff@^27.5.1: +jest-diff@^27.0.2, jest-diff@^27.2.5, jest-diff@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== @@ -8973,6 +8991,14 @@ jest-watcher@^27.5.1: jest-util "^27.5.1" string-length "^4.0.1" +jest-websocket-mock@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.3.0.tgz#317e7d7f8ba54ba632a7300777b02b7ebb606845" + integrity sha512-kXhRRApRdT4hLG/4rhsfcR0Ke0OzqIsDj0P5t0dl5aiAftShSgoRqp/0pyjS5bh+b9GrIzmfkrV2cn9LxxvSvA== + dependencies: + jest-diff "^27.0.2" + mock-socket "^9.1.0" + jest-worker@^25.1.0: version "25.5.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-25.5.0.tgz#2611d071b79cea0f43ee57a3d118593ac1547db1" @@ -9831,6 +9857,18 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mock-socket@^9.1.0: + version "9.1.3" + resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.3.tgz#bcb106c6b345001fa7619466fcf2f8f5a156b10f" + integrity sha512-uz8lx8c5wuJYJ21f5UtovqpV0+KJuVwE7cVOLNhrl2QW/CvmstOLRfjXnLSbfFHZtJtiaSGQu0oCJA8SmRcK6A== + +moo-color@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" + integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ== + dependencies: + color-name "^1.1.4" + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" @@ -14129,6 +14167,34 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + +xterm-addon-web-links@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.5.1.tgz#73bfa3ed567af98fba947f638bd12093ee2a0bc6" + integrity sha512-dBjbOIrCNmxAcUQkkSrKj9BM6yLpmqUpZ9SOCUuZe/sznPl4d8OBZQClK7VcdZ0vf0+5i5Fce2rUUrew/XTZTg== + +xterm-addon-webgl@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/xterm-addon-webgl/-/xterm-addon-webgl-0.11.4.tgz#e22f3ec0cafca3d4adcabb89bb7c16efaaf3c8db" + integrity sha512-/a/VFeftc+etGXQYWaaks977j1P7/wickBXn15zDxZzXYYMT9RN17ztqyIDVLXg9krtg28+icKK6lvgIYghJ0w== + +xterm-for-react@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/xterm-for-react/-/xterm-for-react-1.0.4.tgz#6b35b9b037a0f9d979e7b57bb1d7c6ab7565b380" + integrity sha512-DCkLR9ZXeW907YyyaCTk/3Ol34VRHfCnf3MAPOkj3dUNA85sDqHvTXN8efw4g7bx7gWdJQRsEpGt2tJOXKG3EQ== + dependencies: + prop-types "^15.7.2" + xterm "^4.5.0" + +xterm@^4.18.0, xterm@^4.5.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1" + integrity sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ== + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"