feat: add interactive web terminal for bot containers (#232)

* feat(terminal): add interactive web terminal for bot containers

Add WebSocket-based terminal endpoint (/container/terminal/ws) that
provides a full PTY shell session inside the bot's MCP container.
Extend the gRPC proto with pty and resize fields, implement PTY exec
on the container side using creack/pty, and add an xterm.js-based
terminal component in the frontend bot detail page.

* chore: add /mcp in .gitignore

* feat(terminal): add multi-tab support, localStorage cache, and reactivity fixes

- Support unlimited terminal tabs with add/close/switch
- Cache terminal content to localStorage via SerializeAddon for session persistence
- Use shallowReactive for tab objects to ensure status updates trigger UI reactivity
- Fix listener leak by tracking and disposing onData/onResize on reconnect
- Fix bottom clipping by using inset offsets instead of padding
This commit is contained in:
Acbox Liu
2026-03-11 21:49:05 +08:00
committed by GitHub
parent fe04c5722b
commit 82c8d65a7d
17 changed files with 1037 additions and 105 deletions
+74 -6
View File
@@ -16,6 +16,7 @@ import (
"time"
"unicode/utf8"
"github.com/creack/pty"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -171,7 +172,6 @@ func (*containerServer) ListDir(_ context.Context, req *pb.ListDirRequest) (*pb.
}
func (*containerServer) Exec(stream pb.ContainerService_ExecServer) error {
// Receive first message to get command details
firstMsg, err := stream.Recv()
if err != nil {
return status.Error(codes.InvalidArgument, "failed to receive exec config")
@@ -182,6 +182,77 @@ func (*containerServer) Exec(stream pb.ContainerService_ExecServer) error {
return status.Error(codes.InvalidArgument, "command is required")
}
if firstMsg.GetPty() {
return execPTY(stream, firstMsg)
}
return execPipe(stream, firstMsg)
}
func execPTY(stream pb.ContainerService_ExecServer, firstMsg *pb.ExecInput) error {
command := firstMsg.GetCommand()
workDir := firstMsg.GetWorkDir()
if workDir == "" {
workDir = defaultWorkDir
}
cmd := exec.CommandContext(stream.Context(), "/bin/sh", "-c", command) //nolint:gosec // G204: intentional
cmd.Dir = workDir
cmd.Env = append(os.Environ(), firstMsg.GetEnv()...)
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
initialSize := &pty.Winsize{Rows: 24, Cols: 80}
if r := firstMsg.GetResize(); r != nil && r.GetCols() > 0 && r.GetRows() > 0 {
initialSize.Rows = uint16(r.GetRows()) //nolint:gosec // G115
initialSize.Cols = uint16(r.GetCols()) //nolint:gosec // G115
}
ptmx, err := pty.StartWithSize(cmd, initialSize)
if err != nil {
return status.Errorf(codes.Internal, "pty start: %v", err)
}
defer func() { _ = ptmx.Close() }()
// stdin + resize from stream
go func() {
for {
msg, recvErr := stream.Recv()
if recvErr != nil {
return
}
if r := msg.GetResize(); r != nil && r.GetCols() > 0 && r.GetRows() > 0 {
_ = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(r.GetRows()), //nolint:gosec // G115
Cols: uint16(r.GetCols()), //nolint:gosec // G115
})
}
if data := msg.GetStdinData(); len(data) > 0 {
_, _ = ptmx.Write(data)
}
}
}()
// PTY output -> stream (single fd merges stdout+stderr)
streamPipe(stream, ptmx, pb.ExecOutput_STDOUT)
exitCode := int32(0)
if waitErr := cmd.Wait(); waitErr != nil {
exitErr := &exec.ExitError{}
if errors.As(waitErr, &exitErr) {
ec := exitErr.ExitCode()
exitCode = int32(max(math.MinInt32, min(math.MaxInt32, ec))) //nolint:gosec // G115
} else {
exitCode = -1
}
}
return stream.Send(&pb.ExecOutput{
Stream: pb.ExecOutput_EXIT,
ExitCode: exitCode,
})
}
func execPipe(stream pb.ContainerService_ExecServer, firstMsg *pb.ExecInput) error {
command := firstMsg.GetCommand()
workDir := firstMsg.GetWorkDir()
if workDir == "" {
workDir = defaultWorkDir
@@ -201,7 +272,6 @@ func (*containerServer) Exec(stream pb.ContainerService_ExecServer) error {
cmd.Env = append(os.Environ(), firstMsg.GetEnv()...)
}
// Setup stdin pipe for bidirectional streaming
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return status.Errorf(codes.Internal, "stdin pipe: %v", err)
@@ -220,11 +290,10 @@ func (*containerServer) Exec(stream pb.ContainerService_ExecServer) error {
return status.Errorf(codes.Internal, "start: %v", err)
}
// Handle stdin from stream
go func() {
for {
msg, err := stream.Recv()
if err != nil {
msg, recvErr := stream.Recv()
if recvErr != nil {
_ = stdinPipe.Close()
return
}
@@ -234,7 +303,6 @@ func (*containerServer) Exec(stream pb.ContainerService_ExecServer) error {
}
}()
// Stream stdout/stderr to client
done := make(chan struct{})
go func() {
defer close(done)