From 144ffc1b2e2f422ed34466818593789b3bfcd271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 1 Aug 2026 16:23:21 +0200 Subject: [PATCH 1/3] feat: add WebSocket terminal with SDK InteractiveSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge browser terminal (xterm.js) to sandbox shell via the SDK's Exec().Interactive() API. Uses single-use, 30s-TTL ticket auth instead of raw JWT query params. Includes origin validation, idle timeout, structured session logging, and configurable shell command. Assisted-By: 🤖 Claude Code --- backend/cmd/server/main.go | 13 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/app.go | 24 +- backend/internal/api/terminal_handler.go | 235 ++++++++++++++++++ backend/internal/api/terminal_handler_test.go | 62 +++++ backend/internal/api/ws_ticket.go | 101 ++++++++ backend/internal/api/ws_ticket_test.go | 125 ++++++++++ backend/internal/auth/oidc.go | 10 + frontend/src/api/terminal.ts | 12 + .../src/components/SandboxTerminalTab.tsx | 86 +++++-- 11 files changed, 648 insertions(+), 23 deletions(-) create mode 100644 backend/internal/api/terminal_handler.go create mode 100644 backend/internal/api/terminal_handler_test.go create mode 100644 backend/internal/api/ws_ticket.go create mode 100644 backend/internal/api/ws_ticket_test.go create mode 100644 frontend/src/api/terminal.ts diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2c1b521..5aecd9b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -27,6 +27,14 @@ func envOr(key, fallback string) string { return fallback } +func parseDurationOr(s string, fallback time.Duration) time.Duration { + d, err := time.ParseDuration(s) + if err != nil { + return fallback + } + return d +} + func main() { var ( port = flag.String("port", envOr("PORT", "8080"), "listen port (env PORT)") @@ -36,7 +44,8 @@ func main() { oidcClientID = flag.String("oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "OIDC client ID (env OIDC_CLIENT_ID)") staticDir = flag.String("static-dir", envOr("STATIC_DIR", ""), "frontend static assets directory (env STATIC_DIR)") authDisabled = flag.Bool("auth-disabled", envOr("AUTH_DISABLED", "false") == "true", "skip OIDC validation — dev only (env AUTH_DISABLED)") - origins = flag.String("allowed-origins", envOr("ALLOWED_ORIGINS", "http://localhost:3000"), "comma-separated CORS origins (env ALLOWED_ORIGINS)") + origins = flag.String("allowed-origins", envOr("ALLOWED_ORIGINS", "http://localhost:3000"), "comma-separated CORS origins (env ALLOWED_ORIGINS)") + terminalIdleTimeout = flag.Duration("terminal-idle-timeout", parseDurationOr(envOr("TERMINAL_IDLE_TIMEOUT", "30m"), 30*time.Minute), "terminal session idle timeout (env TERMINAL_IDLE_TIMEOUT)") ) flag.Parse() @@ -90,7 +99,7 @@ func main() { } defer sdkClient.Close() - app := api.NewApp(sdkClient, authMiddleware, *staticDir, strings.Split(*origins, ",")) + app := api.NewApp(sdkClient, authMiddleware, *staticDir, strings.Split(*origins, ","), *terminalIdleTimeout) addr := ":" + *port slog.Info("openshell-dashboard BFF listening", diff --git a/backend/go.mod b/backend/go.mod index d463d39..463185e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.1 require ( github.com/coreos/go-oidc/v3 v3.20.0 github.com/go-chi/chi/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 github.com/rhuss/openshell-sdk-go v0.3.1 google.golang.org/grpc v1.82.1 ) diff --git a/backend/go.sum b/backend/go.sum index e6fd804..cd7c2aa 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -18,6 +18,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rhuss/openshell-sdk-go v0.3.1 h1:OwHWofN59X//KKiAn5h7X95YUfqfmfJ1t1Yd2gPFF38= diff --git a/backend/internal/api/app.go b/backend/internal/api/app.go index 1dd3308..31183bc 100644 --- a/backend/internal/api/app.go +++ b/backend/internal/api/app.go @@ -2,10 +2,12 @@ package api import ( + "context" "net/http" "os" "path/filepath" "strings" + "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -22,15 +24,21 @@ type App struct { staticDir string // allowedOrigins for CORS, e.g. the webpack dev server origin. allowedOrigins []string + // terminalIdleTimeout is how long a terminal session can be idle before auto-disconnect. + terminalIdleTimeout time.Duration + // tickets stores short-lived, single-use WebSocket auth tickets. + tickets *ticketStore } // NewApp builds the application. -func NewApp(client openshell.ClientInterface, authMiddleware *auth.Middleware, staticDir string, allowedOrigins []string) *App { +func NewApp(client openshell.ClientInterface, authMiddleware *auth.Middleware, staticDir string, allowedOrigins []string, terminalIdleTimeout time.Duration) *App { return &App{ - client: client, - auth: authMiddleware, - staticDir: staticDir, - allowedOrigins: allowedOrigins, + client: client, + auth: authMiddleware, + staticDir: staticDir, + allowedOrigins: allowedOrigins, + terminalIdleTimeout: terminalIdleTimeout, + tickets: newTicketStore(context.Background()), } } @@ -50,9 +58,15 @@ func (app *App) Routes() http.Handler { // BFF liveness (does not call the gateway). r.Get("/healthz", app.GetHealthz) + // Terminal uses ticket-based auth (not bearer tokens), so it sits outside the auth middleware group. + r.Route("/workspaces/{workspace}/sandboxes/{name}", func(r chi.Router) { + r.Get("/terminal", app.Terminal) + }) + r.Group(func(r chi.Router) { r.Use(app.auth.Handler) + r.Post("/auth/ws-ticket", app.IssueTicket) r.Get("/auth/userinfo", app.GetUserInfo) r.Get("/auth/whoami", app.GetWhoAmI) r.Get("/gateway", app.GetGateway) diff --git a/backend/internal/api/terminal_handler.go b/backend/internal/api/terminal_handler.go new file mode 100644 index 0000000..b2bca60 --- /dev/null +++ b/backend/internal/api/terminal_handler.go @@ -0,0 +1,235 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strconv" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" +) + +type resizeMessage struct { + Type string `json:"type"` + Cols uint32 `json:"cols"` + Rows uint32 `json:"rows"` +} + +func (app *App) wsUpgrader() websocket.Upgrader { + return websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + for _, allowed := range app.allowedOrigins { + if origin == allowed { + return true + } + } + return false + }, + } +} + +const ( + maxCols uint32 = 500 + maxRows uint32 = 200 + wsReadLimit int64 = 64 * 1024 +) + +func (app *App) Terminal(w http.ResponseWriter, r *http.Request) { + workspace := chi.URLParam(r, "workspace") + name := chi.URLParam(r, "name") + + ticketStr := r.URL.Query().Get("ticket") + if ticketStr == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "missing ticket") + return + } + jwt, claims, ok := app.tickets.validate(ticketStr) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid, expired, or already-used ticket") + return + } + + ctx := auth.WithToken(r.Context(), jwt) + ctx = auth.WithClaims(ctx, claims) + + cols := uint32(80) + rows := uint32(24) + if c, err := strconv.ParseUint(r.URL.Query().Get("cols"), 10, 32); err == nil { + cols = uint32(c) + } + if ro, err := strconv.ParseUint(r.URL.Query().Get("rows"), 10, 32); err == nil { + rows = uint32(ro) + } + if cols > maxCols { + cols = maxCols + } + if rows > maxRows { + rows = maxRows + } + command := r.URL.Query().Get("command") + if command == "" { + command = "/bin/bash" + } + + upgrader := app.wsUpgrader() + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("websocket upgrade failed", "error", err) + return + } + defer ws.Close() + ws.SetReadLimit(wsReadLimit) + + var wsMu sync.Mutex + wsWrite := func(msgType int, data []byte) error { + wsMu.Lock() + defer wsMu.Unlock() + return ws.WriteMessage(msgType, data) + } + wsClose := func(code int, reason string) { + wsMu.Lock() + defer wsMu.Unlock() + ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, reason)) + } + + userEmail := "" + if claims != nil { + userEmail = claims.Email + } + startTime := time.Now() + slog.Info("terminal session opened", + "user", userEmail, + "workspace", workspace, + "sandbox", name, + "cols", cols, + "rows", rows, + "command", command, + ) + + var closeOnce sync.Once + logClose := func(reason string) { + closeOnce.Do(func() { + logSessionClose(userEmail, workspace, name, startTime, reason) + }) + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + session, err := app.client.Exec().Interactive(ctx, workspace, name, []string{command}, cols, rows) + if err != nil { + slog.Error("interactive session failed", "error", err) + wsClose(websocket.CloseInternalServerErr, "failed to start shell") + logClose("error") + return + } + defer session.Close() + + idleReset := make(chan struct{}, 1) + idleTimeout := app.terminalIdleTimeout + if idleTimeout == 0 { + idleTimeout = 30 * time.Minute + } + + // session.Read -> WS + go func() { + defer cancel() + buf := make([]byte, 4096) + for { + n, err := session.Read(buf) + if n > 0 { + if writeErr := wsWrite(websocket.BinaryMessage, buf[:n]); writeErr != nil { + logClose("client_disconnect") + return + } + select { + case idleReset <- struct{}{}: + default: + } + } + if err != nil { + if err == io.EOF { + exitCode, _ := session.ExitCode() + wsClose(websocket.CloseNormalClosure, "exit:"+strconv.Itoa(exitCode)) + logClose("exit") + } else { + wsClose(websocket.CloseGoingAway, "sandbox disconnected") + logClose("disconnect") + } + return + } + } + }() + + // Idle timeout monitor + go func() { + timer := time.NewTimer(idleTimeout) + defer timer.Stop() + for { + select { + case <-timer.C: + wsClose(websocket.CloseNormalClosure, "idle timeout") + logClose("idle_timeout") + cancel() + return + case <-idleReset: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(idleTimeout) + case <-ctx.Done(): + return + } + } + }() + + // WS -> session.Write/Resize + for { + msgType, data, err := ws.ReadMessage() + if err != nil { + cancel() + logClose("client_disconnect") + return + } + select { + case idleReset <- struct{}{}: + default: + } + if msgType == websocket.TextMessage { + var resize resizeMessage + if json.Unmarshal(data, &resize) == nil && resize.Type == "resize" { + session.Resize(resize.Cols, resize.Rows) + continue + } + } + if _, err := session.Write(data); err != nil { + cancel() + logClose("write_error") + return + } + } +} + +func logSessionClose(user, workspace, sandbox string, start time.Time, reason string) { + slog.Info("terminal session closed", + "user", user, + "workspace", workspace, + "sandbox", sandbox, + "duration", time.Since(start).Round(time.Millisecond).String(), + "reason", reason, + ) +} diff --git a/backend/internal/api/terminal_handler_test.go b/backend/internal/api/terminal_handler_test.go new file mode 100644 index 0000000..3da5ace --- /dev/null +++ b/backend/internal/api/terminal_handler_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTerminal_MissingTicket(t *testing.T) { + app := &App{tickets: newTicketStore(context.Background())} + req := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/default/sandboxes/test/terminal", nil) + w := httptest.NewRecorder() + + app.Terminal(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestTerminal_InvalidTicket(t *testing.T) { + app := &App{tickets: newTicketStore(context.Background())} + req := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/default/sandboxes/test/terminal?ticket=invalid", nil) + w := httptest.NewRecorder() + + app.Terminal(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestWsUpgrader_OriginValidation(t *testing.T) { + app := &App{allowedOrigins: []string{"http://localhost:3000", "https://dashboard.example.com"}} + upgrader := app.wsUpgrader() + + tests := []struct { + name string + origin string + want bool + }{ + {"empty origin allowed", "", true}, + {"matching origin allowed", "http://localhost:3000", true}, + {"second allowed origin", "https://dashboard.example.com", true}, + {"non-matching origin rejected", "https://evil.com", false}, + {"partial match rejected", "http://localhost:3001", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/terminal", nil) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + got := upgrader.CheckOrigin(req) + if got != tt.want { + t.Errorf("CheckOrigin(%q) = %v, want %v", tt.origin, got, tt.want) + } + }) + } +} diff --git a/backend/internal/api/ws_ticket.go b/backend/internal/api/ws_ticket.go new file mode 100644 index 0000000..c5de9cf --- /dev/null +++ b/backend/internal/api/ws_ticket.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "sync" + "time" + + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" +) + +const maxTickets = 10000 + +type wsTicket struct { + jwt string + claims *auth.Claims + expiry time.Time +} + +type ticketStore struct { + mu sync.Mutex + tickets map[string]wsTicket +} + +func newTicketStore(ctx context.Context) *ticketStore { + s := &ticketStore{tickets: make(map[string]wsTicket)} + go s.cleanupLoop(ctx) + return s +} + +func (s *ticketStore) issue(jwt string, claims *auth.Claims) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + ticket := hex.EncodeToString(b) + s.mu.Lock() + if len(s.tickets) >= maxTickets { + s.mu.Unlock() + return "", fmt.Errorf("ticket store full") + } + s.tickets[ticket] = wsTicket{ + jwt: jwt, + claims: claims, + expiry: time.Now().Add(30 * time.Second), + } + s.mu.Unlock() + return ticket, nil +} + +func (s *ticketStore) validate(ticket string) (string, *auth.Claims, bool) { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tickets[ticket] + if !ok { + return "", nil, false + } + delete(s.tickets, ticket) + if time.Now().After(t.expiry) { + return "", nil, false + } + return t.jwt, t.claims, true +} + +func (s *ticketStore) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.mu.Lock() + now := time.Now() + for k, v := range s.tickets { + if now.After(v.expiry) { + delete(s.tickets, k) + } + } + s.mu.Unlock() + case <-ctx.Done(): + return + } + } +} + +func (app *App) IssueTicket(w http.ResponseWriter, r *http.Request) { + jwt := auth.TokenFromContext(r.Context()) + claims := auth.ClaimsFromContext(r.Context()) + if jwt == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "missing bearer token") + return + } + ticket, err := app.tickets.issue(jwt, claims) + if err != nil { + writeError(w, http.StatusInternalServerError, "ticket_error", "failed to generate ticket") + return + } + writeJSON(w, http.StatusOK, map[string]string{"ticket": ticket}) +} diff --git a/backend/internal/api/ws_ticket_test.go b/backend/internal/api/ws_ticket_test.go new file mode 100644 index 0000000..97bb649 --- /dev/null +++ b/backend/internal/api/ws_ticket_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Gkrumbach07/openshell-dashboard/backend/internal/auth" +) + +func TestTicketStore_IssueAndValidate(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newTicketStore(ctx) + + ticket, err := store.issue("jwt-token", &auth.Claims{Email: "user@test.com"}) + if err != nil { + t.Fatalf("issue failed: %v", err) + } + if len(ticket) != 64 { + t.Errorf("ticket length = %d, want 64 hex chars", len(ticket)) + } + + jwt, claims, ok := store.validate(ticket) + if !ok { + t.Fatal("validate returned false for valid ticket") + } + if jwt != "jwt-token" { + t.Errorf("jwt = %q, want %q", jwt, "jwt-token") + } + if claims.Email != "user@test.com" { + t.Errorf("email = %q, want %q", claims.Email, "user@test.com") + } +} + +func TestTicketStore_SingleUse(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newTicketStore(ctx) + + ticket, _ := store.issue("jwt", &auth.Claims{}) + store.validate(ticket) + + _, _, ok := store.validate(ticket) + if ok { + t.Error("second validate should return false (single-use)") + } +} + +func TestTicketStore_Expiry(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newTicketStore(ctx) + + ticket, _ := store.issue("jwt", &auth.Claims{}) + + store.mu.Lock() + if entry, ok := store.tickets[ticket]; ok { + entry.expiry = time.Now().Add(-time.Second) + store.tickets[ticket] = entry + } + store.mu.Unlock() + + _, _, ok := store.validate(ticket) + if ok { + t.Error("validate should return false for expired ticket") + } +} + +func TestTicketStore_InvalidTicket(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newTicketStore(ctx) + + _, _, ok := store.validate("nonexistent") + if ok { + t.Error("validate should return false for nonexistent ticket") + } +} + +func TestTicketStore_MaxTickets(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + store := newTicketStore(ctx) + + for i := 0; i < maxTickets; i++ { + if _, err := store.issue("jwt", &auth.Claims{}); err != nil { + t.Fatalf("issue %d failed: %v", i, err) + } + } + + _, err := store.issue("jwt", &auth.Claims{}) + if err == nil { + t.Error("issue should fail when store is full") + } +} + +func TestIssueTicket_NoAuth(t *testing.T) { + app := &App{tickets: newTicketStore(context.Background())} + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil) + w := httptest.NewRecorder() + + app.IssueTicket(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestIssueTicket_WithAuth(t *testing.T) { + app := &App{tickets: newTicketStore(context.Background())} + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/ws-ticket", nil) + ctx := auth.WithToken(req.Context(), "test-jwt") + ctx = auth.WithClaims(ctx, &auth.Claims{Email: "user@test.com"}) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + app.IssueTicket(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index fcf8df7..0c092c7 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -144,3 +144,13 @@ func ClaimsFromContext(ctx context.Context) *Claims { claims, _ := ctx.Value(claimsContextKey).(*Claims) return claims } + +// WithToken stores a raw bearer token on the context (for use outside the middleware path). +func WithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, tokenContextKey, token) +} + +// WithClaims stores parsed claims on the context (for use outside the middleware path). +func WithClaims(ctx context.Context, claims *Claims) context.Context { + return context.WithValue(ctx, claimsContextKey, claims) +} diff --git a/frontend/src/api/terminal.ts b/frontend/src/api/terminal.ts new file mode 100644 index 0000000..11db2ed --- /dev/null +++ b/frontend/src/api/terminal.ts @@ -0,0 +1,12 @@ +import { apiFetch } from './client'; + +type WsTicketResponse = { + ticket: string; +}; + +export const fetchWsTicket = async (): Promise => { + const response = await apiFetch('/api/v1/auth/ws-ticket', { + method: 'POST', + }); + return response.ticket; +}; diff --git a/frontend/src/components/SandboxTerminalTab.tsx b/frontend/src/components/SandboxTerminalTab.tsx index 00f9dcf..0da2a4f 100644 --- a/frontend/src/components/SandboxTerminalTab.tsx +++ b/frontend/src/components/SandboxTerminalTab.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Alert, Bullseye, Button, Content, Spinner, Stack, StackItem } from '@patternfly/react-core'; -import { getToken } from '../app/authStore'; +import { fetchWsTicket } from '../api/terminal'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; @@ -9,17 +9,22 @@ import '@xterm/xterm/css/xterm.css'; type SandboxTerminalTabProps = { workspace: string; sandboxName: string; + sandboxPhase?: string; }; -const SandboxTerminalTab: React.FC = ({ workspace, sandboxName }) => { +const SandboxTerminalTab: React.FC = ({ workspace, sandboxName, sandboxPhase }) => { const termRef = useRef(null); const [connected, setConnected] = useState(false); const [error, setError] = useState(null); const [exitCode, setExitCode] = useState(null); const wsRef = useRef(null); const terminalRef = useRef(null); + const cleanupRef = useRef<(() => void) | undefined>(undefined); + + const connect = async (): Promise<(() => void) | undefined> => { + cleanupRef.current?.(); + cleanupRef.current = undefined; - const connect = () => { if (!termRef.current) { return; } @@ -43,10 +48,17 @@ const SandboxTerminalTab: React.FC = ({ workspace, sand fitAddon.fit(); terminalRef.current = terminal; + let ticket: string; + try { + ticket = await fetchWsTicket(); + } catch { + setError('Failed to authenticate terminal session'); + terminal.dispose(); + return; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = getToken(); - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''; - const wsUrl = `${protocol}//${window.location.host}/api/v1/workspaces/${encodeURIComponent(workspace)}/sandboxes/${encodeURIComponent(sandboxName)}/terminal?cols=${terminal.cols}&rows=${terminal.rows}${tokenParam}`; + const wsUrl = `${protocol}//${window.location.host}/api/v1/workspaces/${encodeURIComponent(workspace)}/sandboxes/${encodeURIComponent(sandboxName)}/terminal?cols=${terminal.cols}&rows=${terminal.rows}&ticket=${encodeURIComponent(ticket)}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; @@ -64,15 +76,23 @@ const SandboxTerminalTab: React.FC = ({ workspace, sand ws.onclose = (event) => { setConnected(false); if (event.reason) { - const code = parseInt(event.reason, 10); - if (!isNaN(code)) { - setExitCode(code); + if (event.reason.startsWith('exit:')) { + const code = parseInt(event.reason.slice(5), 10); + if (!isNaN(code)) { + setExitCode(code); + } + } else if (event.reason === 'idle timeout') { + setError('Session timed out due to inactivity'); + } else if (event.reason === 'session expired') { + setError('Session expired, please re-authenticate'); + } else if (event.reason === 'sandbox disconnected') { + setError('Sandbox disconnected'); } } }; ws.onerror = () => { - setError('Terminal connection failed'); + setError('Connection lost'); setConnected(false); }; @@ -91,24 +111,58 @@ const SandboxTerminalTab: React.FC = ({ workspace, sand const resizeObserver = new ResizeObserver(() => fitAddon.fit()); resizeObserver.observe(termRef.current); - return () => { + const cleanup = () => { resizeObserver.disconnect(); ws.close(); terminal.dispose(); }; + cleanupRef.current = cleanup; + return cleanup; }; useEffect(() => { - const cleanup = connect(); - return cleanup; + if (sandboxPhase && sandboxPhase !== 'READY') { + return; + } + let cleanup: (() => void) | undefined; + let cancelled = false; + connect().then((fn) => { + if (cancelled) { + fn?.(); + } else { + cleanup = fn; + } + }); + return () => { + cancelled = true; + cleanupRef.current?.(); + cleanupRef.current = undefined; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspace, sandboxName]); + }, [workspace, sandboxName, sandboxPhase]); + + if (sandboxPhase && sandboxPhase !== 'READY') { + return ( + + + + Terminal unavailable: sandbox is not running + + + + ); + } return ( {error && ( - + { connect(); }}>Reconnect} + > {error} @@ -119,7 +173,7 @@ const SandboxTerminalTab: React.FC = ({ workspace, sand variant={exitCode === 0 ? 'success' : 'warning'} isInline title={`Session ended (exit code ${exitCode})`} - actionLinks={} + actionLinks={} /> )} From 8defe5053970f6728bfdf6777e36ccdfa2890515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Sat, 1 Aug 2026 16:27:55 +0200 Subject: [PATCH 2/3] fix: terminal route shadowing sandbox detail endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register terminal as a flat Get route instead of a Route group to avoid claiming the {name} path parameter and shadowing GET /sandboxes/{name} in the authenticated group. Assisted-By: 🤖 Claude Code --- backend/internal/api/app.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/internal/api/app.go b/backend/internal/api/app.go index 31183bc..5c057b5 100644 --- a/backend/internal/api/app.go +++ b/backend/internal/api/app.go @@ -59,9 +59,7 @@ func (app *App) Routes() http.Handler { r.Get("/healthz", app.GetHealthz) // Terminal uses ticket-based auth (not bearer tokens), so it sits outside the auth middleware group. - r.Route("/workspaces/{workspace}/sandboxes/{name}", func(r chi.Router) { - r.Get("/terminal", app.Terminal) - }) + r.Get("/workspaces/{workspace}/sandboxes/{name}/terminal", app.Terminal) r.Group(func(r chi.Router) { r.Use(app.auth.Handler) From b427f91be80ceb99702521de76b86f880b9488f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Mon, 3 Aug 2026 11:08:34 +0200 Subject: [PATCH 3/3] style: use JetBrains Mono font for terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-By: 🤖 Claude Code --- frontend/public/index.html | 3 +++ frontend/src/components/SandboxTerminalTab.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index 2ddbbce..15f6495 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,6 +4,9 @@ + + + OpenShell Dashboard diff --git a/frontend/src/components/SandboxTerminalTab.tsx b/frontend/src/components/SandboxTerminalTab.tsx index 0da2a4f..ec8c34e 100644 --- a/frontend/src/components/SandboxTerminalTab.tsx +++ b/frontend/src/components/SandboxTerminalTab.tsx @@ -33,9 +33,11 @@ const SandboxTerminalTab: React.FC = ({ workspace, sand setExitCode(null); const terminal = new Terminal({ - cursorBlink: true, + cursorBlink: false, fontSize: 14, - fontFamily: 'var(--pf-t--global--font--family--mono)', + lineHeight: 1.35, + letterSpacing: 0, + fontFamily: "'JetBrains Mono', var(--pf-t--global--font--family--mono)", theme: { background: '#1e1e1e', foreground: '#d4d4d4',