forked from molecule-ai/molecule-core
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
173 lines
4.6 KiB
Go
173 lines
4.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
|
"github.com/docker/docker/api/types"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/client"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const terminalSessionTimeout = 30 * time.Minute
|
|
|
|
var termUpgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" ||
|
|
strings.HasPrefix(origin, "http://localhost:") ||
|
|
strings.HasPrefix(origin, "https://localhost:") {
|
|
return true
|
|
}
|
|
// Also allow origins from CORS_ORIGINS env var
|
|
if corsOrigins := os.Getenv("CORS_ORIGINS"); corsOrigins != "" {
|
|
for _, allowed := range strings.Split(corsOrigins, ",") {
|
|
if strings.TrimSpace(allowed) == origin {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
}
|
|
|
|
type TerminalHandler struct {
|
|
docker *client.Client
|
|
}
|
|
|
|
func NewTerminalHandler(cli *client.Client) *TerminalHandler {
|
|
return &TerminalHandler{docker: cli}
|
|
}
|
|
|
|
// HandleConnect handles WS /workspaces/:id/terminal
|
|
func (h *TerminalHandler) HandleConnect(c *gin.Context) {
|
|
if h.docker == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker not available"})
|
|
return
|
|
}
|
|
|
|
workspaceID := c.Param("id")
|
|
ctx := c.Request.Context()
|
|
|
|
// Try multiple container name patterns:
|
|
// 1. Provisioner naming: ws-{id[:12]}
|
|
// 2. Full workspace ID fallback
|
|
// 3. Workspace name from DB (normalized to lowercase-hyphen)
|
|
name := provisioner.ContainerName(workspaceID)
|
|
candidates := []string{name}
|
|
if name != "ws-"+workspaceID {
|
|
candidates = append(candidates, "ws-"+workspaceID)
|
|
}
|
|
|
|
// Look up workspace name for manual container naming
|
|
var wsName string
|
|
if _, err := h.docker.Ping(ctx); err == nil {
|
|
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
|
if wsName != "" {
|
|
candidates = append(candidates, wsName)
|
|
}
|
|
}
|
|
|
|
// Find the first running container that matches
|
|
var containerName string
|
|
for _, name := range candidates {
|
|
info, err := h.docker.ContainerInspect(ctx, name)
|
|
if err == nil && info.State.Running {
|
|
containerName = name
|
|
break
|
|
}
|
|
}
|
|
|
|
if containerName == "" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "container not running"})
|
|
return
|
|
}
|
|
|
|
// Upgrade to WebSocket
|
|
conn, err := termUpgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("Terminal WebSocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// No hard session deadline — terminal stays open as long as there is activity.
|
|
// The idle timeout (terminalSessionTimeout) resets on each keystroke in the
|
|
// WebSocket→stdin bridge loop below.
|
|
// The container exec ends when the user types 'exit' or the container stops.
|
|
|
|
// Try bash first for better UX (tab completion, history), fall back to sh.
|
|
// ContainerExecCreate succeeds even if the binary doesn't exist — the error
|
|
// only surfaces at attach/start time, so we must retry at the attach level.
|
|
var resp types.HijackedResponse
|
|
for _, shell := range []string{"/bin/bash", "/bin/sh"} {
|
|
execCfg := container.ExecOptions{
|
|
Cmd: []string{shell},
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: true,
|
|
}
|
|
execID, createErr := h.docker.ContainerExecCreate(ctx, containerName, execCfg)
|
|
if createErr != nil {
|
|
err = createErr
|
|
continue
|
|
}
|
|
resp, err = h.docker.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{Tty: true})
|
|
if err == nil {
|
|
break
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Printf("Terminal exec error: %v", err)
|
|
conn.WriteMessage(websocket.TextMessage, []byte("Error: failed to create shell session\r\n"))
|
|
return
|
|
}
|
|
defer resp.Close()
|
|
|
|
// Bridge: container stdout → WebSocket
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := resp.Reader.Read(buf)
|
|
if n > 0 {
|
|
if writeErr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
|
|
return
|
|
}
|
|
}
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
log.Printf("Terminal read error: %v", err)
|
|
}
|
|
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Bridge: WebSocket → container stdin
|
|
for {
|
|
_, msg, err := conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
if _, err := resp.Conn.Write(msg); err != nil {
|
|
break
|
|
}
|
|
// Reset read deadline on activity
|
|
conn.SetReadDeadline(time.Now().Add(terminalSessionTimeout))
|
|
}
|
|
|
|
<-done
|
|
}
|