Skip to content

Commit e36fadb

Browse files
resolve conflits
2 parents b766157 + 6d8bce0 commit e36fadb

27 files changed

+811
-32
lines changed

Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ WORKDIR $GOPATH/src/packages/ai-developer/
5959

6060
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /go/executor executor.go
6161

62+
FROM build-base AS terminal-base
63+
64+
WORKDIR $GOPATH/src/packages/ai-developer/
65+
66+
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /go/terminal terminal.go
67+
6268

6369
FROM build-base AS worker-development
6470

@@ -105,6 +111,19 @@ COPY ./app/prompts /go/prompts
105111

106112
ENTRYPOINT ["bash", "-c", "/go/executor"]
107113

114+
FROM superagidev/supercoder-python-ide:latest AS terminal
115+
116+
RUN git config --global user.email "[email protected]"
117+
RUN git config --global user.name "SuperCoder"
118+
119+
ENV TERM xterm
120+
ENV HOME /home/coder
121+
122+
COPY --from=terminal-base /go/terminal /go/terminal
123+
COPY ./app/prompts /go/prompts
124+
125+
ENTRYPOINT ["bash", "-c", "/go/terminal"]
126+
108127
FROM public.ecr.aws/docker/library/debian:bookworm-slim as production
109128

110129
# install git

app/controllers/terminal.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package controllers
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"ai-developer/app/types/request"
16+
"ai-developer/app/utils"
17+
18+
"github.com/creack/pty"
19+
"github.com/gin-gonic/gin"
20+
"github.com/gorilla/websocket"
21+
"go.uber.org/zap"
22+
)
23+
24+
type TTYSize struct {
25+
Cols uint16 `json:"cols"`
26+
Rows uint16 `json:"rows"`
27+
X uint16 `json:"x"`
28+
Y uint16 `json:"y"`
29+
}
30+
31+
var WebsocketMessageType = map[int]string{
32+
websocket.BinaryMessage: "binary",
33+
websocket.TextMessage: "text",
34+
websocket.CloseMessage: "close",
35+
websocket.PingMessage: "ping",
36+
websocket.PongMessage: "pong",
37+
}
38+
39+
type TerminalController struct {
40+
DefaultConnectionErrorLimit int
41+
MaxBufferSizeBytes int
42+
KeepalivePingTimeout time.Duration
43+
ConnectionErrorLimit int
44+
cmd *exec.Cmd
45+
Command string
46+
Arguments []string
47+
AllowedHostnames []string
48+
logger *zap.Logger
49+
tty *os.File
50+
cancelFunc context.CancelFunc
51+
writeMutex sync.Mutex
52+
historyBuffer bytes.Buffer
53+
}
54+
55+
func NewTerminalController(logger *zap.Logger, command string, arguments []string, allowedHostnames []string) (*TerminalController, error) {
56+
cmd := exec.Command(command, arguments...)
57+
tty, err := pty.Start(cmd)
58+
if err != nil {
59+
logger.Warn("failed to start command", zap.Error(err))
60+
return nil, err
61+
}
62+
ttyBuffer := bytes.Buffer{}
63+
return &TerminalController{
64+
DefaultConnectionErrorLimit: 10,
65+
MaxBufferSizeBytes: 1024,
66+
KeepalivePingTimeout: 20 * time.Second,
67+
ConnectionErrorLimit: 10,
68+
tty: tty,
69+
cmd: cmd,
70+
Arguments: arguments,
71+
AllowedHostnames: allowedHostnames,
72+
logger: logger,
73+
historyBuffer: ttyBuffer,
74+
}, nil
75+
}
76+
77+
func (controller *TerminalController) RunCommand(ctx *gin.Context) {
78+
var commandRequest request.RunCommandRequest
79+
if err := ctx.ShouldBindJSON(&commandRequest); err != nil {
80+
ctx.JSON(400, gin.H{"error": err.Error()})
81+
return
82+
}
83+
command := commandRequest.Command
84+
if command == "" {
85+
ctx.JSON(400, gin.H{"error": "command is required"})
86+
return
87+
}
88+
if !strings.HasSuffix(command, "\n") {
89+
command += "\n"
90+
}
91+
92+
_, err := controller.tty.Write([]byte(command))
93+
if err != nil {
94+
return
95+
}
96+
}
97+
98+
func (controller *TerminalController) NewTerminal(ctx *gin.Context) {
99+
subCtx, cancelFunc := context.WithCancel(ctx)
100+
controller.cancelFunc = cancelFunc
101+
102+
controller.logger.Info("setting up new terminal connection...")
103+
104+
connection, err := controller.setupConnection(ctx, ctx.Writer, ctx.Request)
105+
defer func(connection *websocket.Conn) {
106+
controller.logger.Info("closing websocket connection...")
107+
err := connection.Close()
108+
if err != nil {
109+
controller.logger.Warn("failed to close connection", zap.Error(err))
110+
}
111+
}(connection)
112+
if err != nil {
113+
controller.logger.Warn("failed to setup connection", zap.Error(err))
114+
return
115+
}
116+
117+
// restore history from buffer
118+
controller.writeMutex.Lock()
119+
if err := connection.WriteMessage(websocket.BinaryMessage, controller.historyBuffer.Bytes()); err != nil {
120+
controller.logger.Info("failed to write tty buffer to xterm.js", zap.Error(err))
121+
}
122+
controller.writeMutex.Unlock()
123+
124+
var waiter sync.WaitGroup
125+
126+
waiter.Add(3)
127+
128+
go controller.keepAlive(subCtx, connection, &waiter)
129+
130+
go controller.readFromTTY(subCtx, connection, &waiter)
131+
132+
go controller.writeToTTY(subCtx, connection, &waiter)
133+
134+
waiter.Wait()
135+
136+
controller.logger.Info("closing connection...")
137+
}
138+
139+
func (controller *TerminalController) setupConnection(ctx context.Context, w gin.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
140+
upgrader := utils.GetConnectionUpgrader(controller.AllowedHostnames, controller.MaxBufferSizeBytes)
141+
connection, err := upgrader.Upgrade(w, r, nil)
142+
if err != nil {
143+
return nil, err
144+
}
145+
return connection, nil
146+
}
147+
148+
func (controller *TerminalController) keepAlive(ctx context.Context, connection *websocket.Conn, waiter *sync.WaitGroup) {
149+
defer func() {
150+
waiter.Done()
151+
controller.logger.Info("keepAlive goroutine exiting...")
152+
}()
153+
lastPongTime := time.Now()
154+
keepalivePingTimeout := controller.KeepalivePingTimeout
155+
156+
connection.SetPongHandler(func(msg string) error {
157+
lastPongTime = time.Now()
158+
return nil
159+
})
160+
161+
for {
162+
select {
163+
case <-ctx.Done():
164+
controller.logger.Info("done keeping alive...")
165+
return
166+
default:
167+
controller.logger.Info("sending keepalive ping message...")
168+
controller.writeMutex.Lock()
169+
if err := connection.WriteMessage(websocket.PingMessage, []byte("keepalive")); err != nil {
170+
controller.writeMutex.Unlock()
171+
controller.logger.Error("failed to write ping message", zap.Error(err))
172+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
173+
controller.cancelFunc()
174+
}
175+
return
176+
}
177+
controller.writeMutex.Unlock()
178+
179+
time.Sleep(keepalivePingTimeout / 2)
180+
181+
if time.Now().Sub(lastPongTime) > keepalivePingTimeout {
182+
controller.logger.Warn("failed to get response from ping, triggering disconnect now...")
183+
return
184+
}
185+
controller.logger.Info("received response from ping successfully")
186+
}
187+
}
188+
}
189+
190+
func (controller *TerminalController) readFromTTY(ctx context.Context, connection *websocket.Conn, waiter *sync.WaitGroup) {
191+
defer func() {
192+
waiter.Done()
193+
controller.logger.Info("readFromTTY goroutine exiting...")
194+
}()
195+
errorCounter := 0
196+
buffer := make([]byte, controller.MaxBufferSizeBytes)
197+
for {
198+
select {
199+
case <-ctx.Done():
200+
controller.logger.Info("done reading from tty...")
201+
return
202+
default:
203+
204+
readLength, err := controller.tty.Read(buffer)
205+
if err != nil {
206+
controller.logger.Warn("failed to read from tty", zap.Error(err))
207+
controller.writeMutex.Lock()
208+
if err := connection.WriteMessage(websocket.TextMessage, []byte("bye!")); err != nil {
209+
controller.logger.Warn("failed to send termination message from tty to xterm.js", zap.Error(err))
210+
}
211+
controller.writeMutex.Unlock()
212+
return
213+
}
214+
215+
controller.writeMutex.Lock()
216+
// save to history buffer
217+
controller.historyBuffer.Write(buffer[:readLength])
218+
if err := connection.WriteMessage(websocket.BinaryMessage, buffer[:readLength]); err != nil {
219+
controller.writeMutex.Unlock()
220+
controller.logger.Warn(fmt.Sprintf("failed to send %v bytes from tty to xterm.js", readLength), zap.Int("read_length", readLength), zap.Error(err))
221+
errorCounter++
222+
if errorCounter > controller.ConnectionErrorLimit {
223+
return
224+
}
225+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
226+
controller.logger.Info("WebSocket closed by client")
227+
controller.cancelFunc()
228+
return
229+
}
230+
continue
231+
}
232+
controller.writeMutex.Unlock()
233+
234+
controller.logger.Info(fmt.Sprintf("sent message of size %v bytes from tty to xterm.js", readLength), zap.Int("read_length", readLength))
235+
errorCounter = 0
236+
}
237+
}
238+
}
239+
240+
func (controller *TerminalController) writeToTTY(ctx context.Context, connection *websocket.Conn, waiter *sync.WaitGroup) {
241+
defer func() {
242+
waiter.Done()
243+
controller.logger.Info("writeToTTY goroutine exiting...")
244+
}()
245+
for {
246+
select {
247+
case <-ctx.Done():
248+
controller.logger.Info("done writing from tty...")
249+
return
250+
default:
251+
252+
messageType, data, err := connection.ReadMessage()
253+
if err != nil {
254+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
255+
controller.logger.Info("WebSocket closed by client")
256+
controller.cancelFunc()
257+
return
258+
}
259+
controller.logger.Warn("failed to get next reader", zap.Error(err))
260+
return
261+
}
262+
263+
dataLength := len(data)
264+
dataBuffer := bytes.Trim(data, "\x00")
265+
dataType := WebsocketMessageType[messageType]
266+
267+
controller.logger.Info(fmt.Sprintf("received %s (type: %v) message of size %v byte(s) from xterm.js with key sequence: %v", dataType, messageType, dataLength, dataBuffer))
268+
269+
if messageType == websocket.BinaryMessage && dataBuffer[0] == 1 {
270+
controller.resizeTTY(dataBuffer)
271+
continue
272+
}
273+
274+
bytesWritten, err := controller.tty.Write(dataBuffer)
275+
if err != nil {
276+
controller.logger.Error(fmt.Sprintf("failed to write %v bytes to tty: %s", len(dataBuffer), err), zap.Int("bytes_written", bytesWritten), zap.Error(err))
277+
continue
278+
}
279+
controller.logger.Info("bytes written to tty...", zap.Int("bytes_written", bytesWritten))
280+
}
281+
}
282+
}
283+
284+
func (controller *TerminalController) resizeTTY(dataBuffer []byte) {
285+
ttySize := &TTYSize{}
286+
resizeMessage := bytes.Trim(dataBuffer[1:], " \n\r\t\x00\x01")
287+
if err := json.Unmarshal(resizeMessage, ttySize); err != nil {
288+
controller.logger.Warn(fmt.Sprintf("failed to unmarshal received resize message '%s'", resizeMessage), zap.ByteString("resizeMessage", resizeMessage), zap.Error(err))
289+
return
290+
}
291+
controller.logger.Info("resizing tty ", zap.Uint16("rows", ttySize.Rows), zap.Uint16("cols", ttySize.Cols))
292+
if err := pty.Setsize(controller.tty, &pty.Winsize{
293+
Rows: ttySize.Rows,
294+
Cols: ttySize.Cols,
295+
}); err != nil {
296+
controller.logger.Warn("failed to resize tty", zap.Error(err))
297+
}
298+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package request
2+
3+
type RunCommandRequest struct {
4+
Command string `json:"command"`
5+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/gorilla/websocket"
9+
)
10+
11+
func GetConnectionUpgrader(
12+
allowedHostnames []string,
13+
maxBufferSizeBytes int,
14+
) websocket.Upgrader {
15+
return websocket.Upgrader{
16+
CheckOrigin: func(r *http.Request) bool {
17+
requesterHostname := r.Host
18+
if strings.Index(requesterHostname, ":") != -1 {
19+
requesterHostname = strings.Split(requesterHostname, ":")[0]
20+
}
21+
for _, allowedHostname := range allowedHostnames {
22+
if requesterHostname == allowedHostname {
23+
return true
24+
}
25+
}
26+
fmt.Printf("failed to find '%s' in the list of allowed hostnames ('%s')\n", requesterHostname)
27+
return false
28+
},
29+
HandshakeTimeout: 0,
30+
ReadBufferSize: maxBufferSizeBytes,
31+
WriteBufferSize: maxBufferSizeBytes,
32+
}
33+
}

docker-compose.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ services:
118118
dockerfile: Dockerfile
119119
target: node-executor
120120

121+
terminal:
122+
restart: no
123+
hostname: terminal
124+
container_name: terminal
125+
image: terminal:latest
126+
ports:
127+
- 8084:8080
128+
build:
129+
context: .
130+
dockerfile: Dockerfile
131+
target: terminal
132+
121133
ws:
122134
hostname: ws
123135
restart: always

0 commit comments

Comments
 (0)