From 7380fd4c8fdc15d0a578125fa3e6232cf8be3224 Mon Sep 17 00:00:00 2001 From: Hally Maschine Date: Tue, 31 Mar 2026 18:16:45 -0700 Subject: [PATCH] cmd/sgai: add embedded IDE workspace tab --- cmd/sgai/serve.go | 27 + cmd/sgai/serve_api.go | 66 +- cmd/sgai/serve_ide.go | 1155 +++++++++++++++++ cmd/sgai/serve_ide_test.go | 1155 +++++++++++++++++ cmd/sgai/state_watcher.go | 2 + cmd/sgai/webapp/src/lib/api.ts | 12 + cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx | 28 +- .../pages/__tests__/WorkspaceDetail.test.tsx | 115 ++ cmd/sgai/webapp/src/pages/tabs/IDETab.tsx | 154 +++ .../src/pages/tabs/__tests__/IDETab.test.tsx | 370 ++++++ cmd/sgai/webapp/src/types/index.ts | 32 + 11 files changed, 3110 insertions(+), 6 deletions(-) create mode 100644 cmd/sgai/serve_ide.go create mode 100644 cmd/sgai/serve_ide_test.go create mode 100644 cmd/sgai/webapp/src/pages/tabs/IDETab.tsx create mode 100644 cmd/sgai/webapp/src/pages/tabs/__tests__/IDETab.test.tsx diff --git a/cmd/sgai/serve.go b/cmd/sgai/serve.go index 9917007..7dffe2e 100644 --- a/cmd/sgai/serve.go +++ b/cmd/sgai/serve.go @@ -150,6 +150,8 @@ func isEditorAvailable(command string) bool { type Server struct { mu sync.Mutex sessions map[string]*session + ideSessions map[string]*ideSession + browserSessions map[string]*browserSession everStartedDirs map[string]bool pinnedDirs map[string]bool pinnedConfigDir string @@ -161,6 +163,11 @@ type Server struct { editorName string editor editorOpener shutdownCtx context.Context + ideRuntime ideRuntime + ideNow func() time.Time + ideAccessTTL time.Duration + ideIdleTimeout time.Duration + browserSessionTTL time.Duration signals *signalBroker @@ -179,6 +186,12 @@ type Server struct { workspaceListCache *ttlCache[string, apiWorkspaceListResponse] workspacePageFlight singleflight[string, apiWorkspaceFullState] workspacePageCache *ttlCache[string, apiWorkspaceFullState] + stateFlight singleflight[string, apiFactoryState] + stateCache *ttlCache[string, apiFactoryState] + ideStatusFlight singleflight[string, ideRuntimeStatus] + ideStatusCache *ttlCache[string, ideRuntimeStatus] + ideStartFlight singleflight[string, ideSessionStartResult] + stateGeneration uint64 goalTitleComposer func(workspacePath string, goalContent []byte) (string, error) goalTitleReadFile func(path string) ([]byte, error) @@ -208,6 +221,8 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server { return &Server{ mu: sync.Mutex{}, sessions: make(map[string]*session), + ideSessions: make(map[string]*ideSession), + browserSessions: make(map[string]*browserSession), everStartedDirs: make(map[string]bool), pinnedDirs: make(map[string]bool), pinnedConfigDir: paths.pinnedConfigDir, @@ -215,6 +230,11 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server { externalConfigDir: paths.externalConfigDir, adhocStates: make(map[string]*adhocPromptState), shutdownCtx: context.Background(), + ideRuntime: newDockerIDERuntime(), + ideNow: time.Now, + ideAccessTTL: defaultIDEAccessTTL, + ideIdleTimeout: defaultIDEIdleTimeout, + browserSessionTTL: defaultBrowserSessionTTL, signals: newSignalBroker(), rootDir: absRootDir, editorAvailable: editorAvail, @@ -226,6 +246,8 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server { svgCache: newTTLCache[string, string](10 * time.Second), workspaceListCache: newTTLCache[string, apiWorkspaceListResponse](3 * time.Second), workspacePageCache: newTTLCache[string, apiWorkspaceFullState](3 * time.Second), + stateCache: newTTLCache[string, apiFactoryState](30 * time.Second), + ideStatusCache: newTTLCache[string, ideRuntimeStatus](defaultIDEStatusTTL), promptActionRunner: nil, scriptActionRunner: nil, workspaceScanFlight: singleflight[string, []workspaceGroup]{mu: sync.Mutex{}, calls: nil}, @@ -233,6 +255,10 @@ func NewServer(rootDir string, paths serverPaths, editorConfig string) *Server { svgFlight: singleflight[string, string]{mu: sync.Mutex{}, calls: nil}, workspaceListFlight: singleflight[string, apiWorkspaceListResponse]{mu: sync.Mutex{}, calls: nil}, workspacePageFlight: singleflight[string, apiWorkspaceFullState]{mu: sync.Mutex{}, calls: nil}, + stateFlight: singleflight[string, apiFactoryState]{mu: sync.Mutex{}, calls: nil}, + ideStatusFlight: singleflight[string, ideRuntimeStatus]{mu: sync.Mutex{}, calls: nil}, + ideStartFlight: singleflight[string, ideSessionStartResult]{mu: sync.Mutex{}, calls: nil}, + stateGeneration: 0, goalTitleComposer: defaultGoalTitleComposer, goalTitleReadFile: os.ReadFile, goalTitleRepairMu: sync.Mutex{}, @@ -725,6 +751,7 @@ func cmdServe(args []string) { mux := http.NewServeMux() srv.registerAPIRoutes(mux) + srv.registerIDERoutes(mux) externalMCPHandler, errExternalMCP := buildExternalMCPHandler(srv) if errExternalMCP != nil { log.Println("failed to build external MCP handler:", errExternalMCP) diff --git a/cmd/sgai/serve_api.go b/cmd/sgai/serve_api.go index 2f32474..b728e1b 100644 --- a/cmd/sgai/serve_api.go +++ b/cmd/sgai/serve_api.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -149,6 +150,7 @@ func (s *signalSubscriber) nextPendingEvent() (signalEvent, bool) { } func (s *Server) registerAPIRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/v1/state", s.handleAPIState) mux.HandleFunc("GET /api/v1/signal", s.handleSignalStream) mux.HandleFunc("GET /api/v1/workspaces", s.handleAPIWorkspaceList) mux.HandleFunc("GET /api/v1/workspaces/{name}/state", s.handleAPIWorkspaceState) @@ -178,6 +180,8 @@ func (s *Server) registerAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/workspaces/{name}/workflow.svg", s.handleAPIWorkflowSVG) mux.HandleFunc("POST /api/v1/workspaces/{name}/steer", s.handleAPISteer) mux.HandleFunc("POST /api/v1/workspaces/{name}/pin", s.handleAPITogglePin) + mux.HandleFunc("GET /api/v1/workspaces/{id}/ide", s.handleAPIIDEStatus) + mux.HandleFunc("POST /api/v1/workspaces/{id}/ide/access", s.handleAPIIDEAccess) mux.HandleFunc("POST /api/v1/workspaces/{name}/open-editor", s.handleAPIOpenEditor) mux.HandleFunc("GET /api/v1/models", s.handleAPIListModels) @@ -228,6 +232,10 @@ func (s *Server) handleSignalStream(w http.ResponseWriter, r *http.Request) { } } +type apiFactoryState struct { + Workspaces []apiWorkspaceFullState `json:"workspaces"` +} + type apiWorkspaceListResponse struct { Workspaces []apiWorkspaceListEntry `json:"workspaces"` } @@ -308,6 +316,7 @@ type apiWorkspaceFullState struct { Actions []apiActionEntry `json:"actions,omitempty"` ActionConfigError string `json:"actionConfigError,omitempty"` RepositoryAction apiRepositoryAction `json:"repositoryAction"` + IDE apiWorkspaceIDEState `json:"ide"` } func (s *Server) handleAPIWorkspaceList(w http.ResponseWriter, _ *http.Request) { @@ -349,6 +358,33 @@ func (s *Server) resolveWorkspacePageFromPath(w http.ResponseWriter, r *http.Req return workspacePath, true } +func (s *Server) handleAPIState(w http.ResponseWriter, r *http.Request) { + if ok := s.ensureBrowserSession(w, r); !ok { + return + } + if cached, ok := s.stateCache.get("state"); ok { + writeJSON(w, cached) + return + } + factoryState, _ := s.stateFlight.do("state", func() (apiFactoryState, error) { + if cached, ok := s.stateCache.get("state"); ok { + return cached, nil + } + s.mu.Lock() + genBefore := s.stateGeneration + s.mu.Unlock() + result := s.buildFullFactoryState() + s.mu.Lock() + genAfter := s.stateGeneration + s.mu.Unlock() + if genBefore == genAfter { + s.stateCache.set("state", result) + } + return result, nil + }) + writeJSON(w, factoryState) +} + func (s *Server) loadWorkspaceListResponse() apiWorkspaceListResponse { if cached, ok := s.workspaceListCache.get("workspaces"); ok { return cached @@ -403,6 +439,25 @@ func (s *Server) loadWorkspaceState(dir string) state.Workflow { return s.workspaceCoordinator(dir).State() } +func (s *Server) buildFullFactoryState() apiFactoryState { + groups, errScan := s.scanWorkspaceGroups() + if errScan != nil { + return apiFactoryState{Workspaces: nil} + } + + allWorkspaces := workspaceInfos(groups) + workspaces := make([]apiWorkspaceFullState, len(allWorkspaces)) + var wg sync.WaitGroup + for i, ws := range allWorkspaces { + wg.Go(func() { + workspaces[i] = s.buildWorkspaceFullState(ws, groups) + }) + } + wg.Wait() + + return apiFactoryState{Workspaces: workspaces} +} + func (s *Server) buildWorkspaceListResponse() apiWorkspaceListResponse { groups, errScan := s.scanWorkspaceGroups() if errScan != nil { @@ -615,6 +670,7 @@ func (s *Server) buildWorkspaceFullState(ws workspaceInfo, groups []workspaceGro Actions: actionState.Actions, ActionConfigError: actionState.ConfigError, RepositoryAction: repositoryAction.api(ws.DirName), + IDE: s.buildWorkspaceIDEState(context.Background(), ws.Directory), } if ws.IsRoot { @@ -705,7 +761,7 @@ func (s *Server) spaMiddleware(mux *http.ServeMux) http.Handler { } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isAPIRoute(r.URL.Path) { + if isBackendPassthroughRoute(r.URL.Path) { mux.ServeHTTP(w, r) return } @@ -720,6 +776,10 @@ func (s *Server) spaMiddleware(mux *http.ServeMux) http.Handler { return } + if ok := s.ensureBrowserSession(w, r); !ok { + return + } + serveReactIndex(w, webappFS) }) } @@ -728,6 +788,10 @@ func isAPIRoute(urlPath string) bool { return strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath, "/mcp/") } +func isBackendPassthroughRoute(urlPath string) bool { + return isAPIRoute(urlPath) || isIDEProxyRoute(urlPath) +} + func isStaticAsset(urlPath string) bool { ext := path.Ext(urlPath) switch ext { diff --git a/cmd/sgai/serve_ide.go b/cmd/sgai/serve_ide.go new file mode 100644 index 0000000..0463d22 --- /dev/null +++ b/cmd/sgai/serve_ide.go @@ -0,0 +1,1155 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os/exec" + "strconv" + "strings" + "time" +) + +const ( + browserSessionCookieName = "sgai_browser_session" + ideAccessCookieName = "sgai_ide_access" + ideDockerImage = "codercom/code-server:latest" + ideDockerPort = "8080/tcp" + ideDockerPortFlag = "8080" + ideWorkspaceMountPath = "/workspace" + ideStatusCacheKey = "ide-status" + defaultBrowserSessionTTL = 24 * time.Hour + defaultIDEAccessTTL = 24 * time.Hour + defaultIDEIdleTimeout = 30 * time.Minute + defaultIDEStatusTTL = 5 * time.Second + defaultIDERuntimeWait = 30 * time.Second + defaultIDERuntimeProbe = 200 * time.Millisecond +) + +var errIDEUnavailable = errors.New("ide unavailable") + +type ideRuntimeStatus struct { + Available bool + Reason string +} + +type ideRuntimeTarget struct { + ID string + Host string + Port int +} + +type ideStartRequest struct { + WorkspacePath string + ContainerName string +} + +type ideRuntime interface { + status(ctx context.Context) ideRuntimeStatus + start(ctx context.Context, req ideStartRequest) (ideRuntimeTarget, error) + inspect(ctx context.Context, target ideRuntimeTarget) (ideRuntimeTarget, error) + stop(ctx context.Context, target ideRuntimeTarget) error +} + +type ideAccessGrant struct { + Token string + BrowserSessionToken string + ExpiresAt time.Time + LastActivity time.Time +} + +type browserSession struct { + Token string + ExpiresAt time.Time + LastSeen time.Time +} + +type ideSession struct { + workspacePath string + workspaceName string + target ideRuntimeTarget + createdAt time.Time + lastActivity time.Time + lastError string + lastEvent string + accessGrants map[string]ideAccessGrant +} + +type ideSessionCleanupCandidate struct { + workspacePath string + targetID string +} + +type ideWorkspaceRef struct { + Path string + Name string + ID string +} + +type ideSessionStartResult struct { + session *ideSession + reused bool +} + +type apiWorkspaceIDEState struct { + Available bool `json:"available"` + Running bool `json:"running"` + Reason string `json:"reason,omitempty"` + LastError string `json:"lastError,omitempty"` + LastEvent string `json:"lastEvent,omitempty"` + LastActivity string `json:"lastActivity,omitempty"` + AccessPath string `json:"accessPath,omitempty"` + ProxyPath string `json:"proxyPath,omitempty"` +} + +type apiIDESessionInfo struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt,omitempty"` + LastActivity string `json:"lastActivity,omitempty"` + LastEvent string `json:"lastEvent,omitempty"` +} + +type apiIDEStatusResponse struct { + Available bool `json:"available"` + Running bool `json:"running"` + Reason string `json:"reason,omitempty"` + LastError string `json:"lastError,omitempty"` + LastEvent string `json:"lastEvent,omitempty"` + LastActivity string `json:"lastActivity,omitempty"` + AccessPath string `json:"accessPath,omitempty"` + ProxyPath string `json:"proxyPath,omitempty"` + Reused bool `json:"reused,omitempty"` + Session *apiIDESessionInfo `json:"session,omitempty"` +} + +type dockerIDERuntime struct{} + +func newDockerIDERuntime() *dockerIDERuntime { + return &dockerIDERuntime{} +} + +func newIDERuntimeStatus(available bool, reason string) ideRuntimeStatus { + return ideRuntimeStatus{Available: available, Reason: reason} +} + +func newIDERuntimeTarget(id, host string, port int) ideRuntimeTarget { + return ideRuntimeTarget{ID: id, Host: host, Port: port} +} + +func emptyIDESessionStartResult() ideSessionStartResult { + return ideSessionStartResult{session: nil, reused: false} +} + +func (r *dockerIDERuntime) status(ctx context.Context) ideRuntimeStatus { + dockerPath, errDockerPath := exec.LookPath("docker") + if errDockerPath != nil { + return newIDERuntimeStatus(false, "docker unavailable") + } + commandCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(commandCtx, dockerPath, "info", "--format", "{{.ServerVersion}}") + output, errInfo := cmd.CombinedOutput() + if errInfo != nil { + return newIDERuntimeStatus(false, trimDockerOutput("docker unavailable", output, errInfo)) + } + if strings.TrimSpace(string(output)) == "" { + return newIDERuntimeStatus(false, "docker unavailable") + } + return newIDERuntimeStatus(true, "") +} + +func (r *dockerIDERuntime) start(ctx context.Context, req ideStartRequest) (ideRuntimeTarget, error) { + dockerPath, errDockerPath := exec.LookPath("docker") + if errDockerPath != nil { + return newIDERuntimeTarget("", "", 0), fmt.Errorf("looking up docker executable: %w", errDockerPath) + } + commandCtx, cancel := context.WithTimeout(ctx, defaultIDERuntimeWait) + defer cancel() + cmd := exec.CommandContext(commandCtx, dockerPath, buildDockerRunArgs(req)...) + output, errRun := cmd.CombinedOutput() + if errRun != nil { + return newIDERuntimeTarget("", "", 0), errors.New(trimDockerOutput("docker run failed", output, errRun)) + } + containerID := strings.TrimSpace(string(output)) + if containerID == "" { + containerID = req.ContainerName + } + target, errWait := r.waitForTarget(commandCtx, newIDERuntimeTarget(containerID, "", 0)) + if errWait != nil { + _ = r.stop(context.Background(), newIDERuntimeTarget(containerID, "", 0)) + return newIDERuntimeTarget("", "", 0), errWait + } + return target, nil +} + +func buildDockerRunArgs(req ideStartRequest) []string { + workspaceStateRoot := ideWorkspaceMountPath + "/.sgai/code-server" + workspaceHomePath := workspaceStateRoot + "/home" + workspaceTempPath := workspaceStateRoot + "/tmp" + workspaceConfigPath := workspaceStateRoot + "/config" + workspaceDataPath := workspaceStateRoot + "/data" + workspaceUserDataPath := workspaceStateRoot + "/user-data" + workspaceExtensionsPath := workspaceStateRoot + "/extensions" + return []string{ + "run", + "--detach", + "--rm", + "--read-only", + "--tmpfs", "/var/run", + "--name", req.ContainerName, + "--publish", "127.0.0.1::" + ideDockerPortFlag, + "--workdir", ideWorkspaceMountPath, + "--volume", req.WorkspacePath + ":" + ideWorkspaceMountPath, + "--env", "HOME=" + workspaceHomePath, + "--env", "TMPDIR=" + workspaceTempPath, + "--env", "XDG_CONFIG_HOME=" + workspaceConfigPath, + "--env", "XDG_DATA_HOME=" + workspaceDataPath, + ideDockerImage, + "--auth", "none", + "--disable-telemetry", + "--disable-update-check", + "--user-data-dir", workspaceUserDataPath, + "--extensions-dir", workspaceExtensionsPath, + ideWorkspaceMountPath, + } +} + +func (r *dockerIDERuntime) waitForTarget(ctx context.Context, target ideRuntimeTarget) (ideRuntimeTarget, error) { + client := new(http.Client) + client.Timeout = time.Second + ticker := time.NewTicker(defaultIDERuntimeProbe) + defer ticker.Stop() + for { + inspectedTarget, errInspect := r.inspect(ctx, target) + if errInspect == nil { + var probeURL url.URL + probeURL.Scheme = "http" + probeURL.Host = net.JoinHostPort(inspectedTarget.Host, strconv.Itoa(inspectedTarget.Port)) + probeURL.Path = "/" + resp, errRequest := client.Get(probeURL.String()) + if errRequest == nil { + if errClose := resp.Body.Close(); errClose != nil { + return newIDERuntimeTarget("", "", 0), fmt.Errorf("closing ide probe response: %w", errClose) + } + return inspectedTarget, nil + } + } + select { + case <-ctx.Done(): + return newIDERuntimeTarget("", "", 0), fmt.Errorf("waiting for ide runtime: %w", ctx.Err()) + case <-ticker.C: + } + } +} + +func (r *dockerIDERuntime) inspect(ctx context.Context, target ideRuntimeTarget) (ideRuntimeTarget, error) { + dockerPath, errDockerPath := exec.LookPath("docker") + if errDockerPath != nil { + return newIDERuntimeTarget("", "", 0), fmt.Errorf("looking up docker executable: %w", errDockerPath) + } + commandCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + runningCmd := exec.CommandContext(commandCtx, dockerPath, "inspect", "--format", "{{.State.Running}}", target.ID) + runningOutput, errRunning := runningCmd.CombinedOutput() + if errRunning != nil { + return newIDERuntimeTarget("", "", 0), errors.New(trimDockerOutput("docker inspect failed", runningOutput, errRunning)) + } + if strings.TrimSpace(string(runningOutput)) != "true" { + return newIDERuntimeTarget("", "", 0), errors.New("ide runtime is not running") + } + portCmd := exec.CommandContext(commandCtx, dockerPath, "port", target.ID, ideDockerPort) + portOutput, errPort := portCmd.CombinedOutput() + if errPort != nil { + return newIDERuntimeTarget("", "", 0), errors.New(trimDockerOutput("docker port lookup failed", portOutput, errPort)) + } + host, port, errParsePort := parseDockerPortOutput(portOutput) + if errParsePort != nil { + return newIDERuntimeTarget("", "", 0), errParsePort + } + return newIDERuntimeTarget(target.ID, host, port), nil +} + +func (r *dockerIDERuntime) stop(ctx context.Context, target ideRuntimeTarget) error { + if target.ID == "" { + return nil + } + dockerPath, errDockerPath := exec.LookPath("docker") + if errDockerPath != nil { + return fmt.Errorf("looking up docker executable: %w", errDockerPath) + } + commandCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(commandCtx, dockerPath, "stop", "--time", "1", target.ID) + output, errStop := cmd.CombinedOutput() + if errStop != nil { + trimmed := strings.ToLower(strings.TrimSpace(string(output))) + if strings.Contains(trimmed, "no such container") { + return nil + } + return errors.New(trimDockerOutput("docker stop failed", output, errStop)) + } + return nil +} + +func trimDockerOutput(prefix string, output []byte, errCause error) string { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Sprintf("%s: %v", prefix, errCause) + } + return fmt.Sprintf("%s: %s", prefix, trimmed) +} + +func parseDockerPortOutput(output []byte) (host string, port int, err error) { + line := strings.TrimSpace(string(output)) + if line == "" { + return "", 0, errors.New("docker port lookup returned empty output") + } + line = strings.Split(line, "\n")[0] + host, portText, errSplit := net.SplitHostPort(line) + if errSplit != nil { + return "", 0, fmt.Errorf("parsing docker port output: %w", errSplit) + } + port, errPort := strconv.Atoi(portText) + if errPort != nil { + return "", 0, fmt.Errorf("parsing docker port: %w", errPort) + } + return host, port, nil +} + +func (s *Server) registerIDERoutes(mux *http.ServeMux) { + mux.HandleFunc("/workspaces/{id}/ide-proxy", s.handleIDEProxyEntry) + mux.HandleFunc("/workspaces/{id}/ide-proxy/{$}", s.handleIDEProxy) + mux.HandleFunc("/workspaces/{id}/ide-proxy/{path...}", s.handleIDEProxy) +} + +func workspaceRouteID(workspacePath string) string { + sum := sha256.Sum256([]byte(resolveSymlinks(workspacePath))) + return hex.EncodeToString(sum[:]) +} + +func workspaceIDEBasePath(workspaceID string) string { + return "/workspaces/" + url.PathEscape(workspaceID) + "/ide-proxy" +} + +func workspaceIDEProxyPath(workspaceID string) string { + return workspaceIDEBasePath(workspaceID) + "/" +} + +func workspaceIDEAccessPath(workspaceID string) string { + return "/api/v1/workspaces/" + url.PathEscape(workspaceID) + "/ide/access" +} + +func ideAccessCookiePath() string { + return "/" +} + +func legacyIDEAccessCookiePath(workspaceID string) string { + return workspaceIDEBasePath(workspaceID) +} + +func legacyIDEAccessCookiePaths(workspaceID string) []string { + return []string{legacyIDEAccessCookiePath(workspaceID), workspaceIDEProxyPath(workspaceID)} +} + +func ideContainerName(workspacePath string) string { + sum := sha256.Sum256([]byte(resolveSymlinks(workspacePath))) + return "sgai-ide-" + hex.EncodeToString(sum[:6]) +} + +func (s *Server) ideAvailability(ctx context.Context) ideRuntimeStatus { + if s.ideRuntime == nil { + return newIDERuntimeStatus(false, "docker unavailable") + } + if cached, ok := s.ideStatusCache.get(ideStatusCacheKey); ok { + return cached + } + status, _ := s.ideStatusFlight.do(ideStatusCacheKey, func() (ideRuntimeStatus, error) { + if cached, ok := s.ideStatusCache.get(ideStatusCacheKey); ok { + return cached, nil + } + status := s.ideRuntime.status(ctx) + s.ideStatusCache.set(ideStatusCacheKey, status) + return status, nil + }) + return status +} + +func (s *Server) buildWorkspaceIDEState(ctx context.Context, workspacePath string) apiWorkspaceIDEState { + status := s.ideAvailability(ctx) + workspaceID := workspaceRouteID(workspacePath) + state := apiWorkspaceIDEState{ + Available: status.Available, + Running: false, + Reason: status.Reason, + LastError: "", + LastEvent: "", + LastActivity: "", + AccessPath: workspaceIDEAccessPath(workspaceID), + ProxyPath: workspaceIDEProxyPath(workspaceID), + } + s.mu.Lock() + sess := s.ideSessions[workspacePath] + if sess != nil { + if sess.target.ID != "" { + state.Available = true + state.Running = true + } + state.LastError = sess.lastError + state.LastEvent = sess.lastEvent + state.LastActivity = formatOptionalTime(sess.lastActivity) + } + s.mu.Unlock() + if sess == nil { + return state + } + if state.Reason != "" && state.Available { + state.Reason = "" + } + return state +} + +func (s *Server) buildIDEStatusResponse(ctx context.Context, workspacePath string) apiIDEStatusResponse { + state := s.buildWorkspaceIDEState(ctx, workspacePath) + resp := apiIDEStatusResponse{ + Available: state.Available, + Running: state.Running, + Reason: state.Reason, + LastError: state.LastError, + LastEvent: state.LastEvent, + LastActivity: state.LastActivity, + AccessPath: state.AccessPath, + ProxyPath: state.ProxyPath, + Reused: false, + Session: nil, + } + s.mu.Lock() + sess := s.ideSessions[workspacePath] + if sess != nil && sess.target.ID != "" { + resp.Session = &apiIDESessionInfo{ + ID: sess.target.ID, + CreatedAt: formatOptionalTime(sess.createdAt), + LastActivity: formatOptionalTime(sess.lastActivity), + LastEvent: sess.lastEvent, + } + } + s.mu.Unlock() + return resp +} + +func (s *Server) handleAPIIDEStatus(w http.ResponseWriter, r *http.Request) { + workspace, ok := s.resolveIDEWorkspaceFromPath(w, r) + if !ok { + return + } + if _, _, ok := s.authorizeIDERequest(w, r, workspace); !ok { + return + } + writeJSON(w, s.buildIDEStatusResponse(r.Context(), workspace.Path)) +} + +func (s *Server) handleAPIIDEAccess(w http.ResponseWriter, r *http.Request) { + workspace, ok := s.resolveIDEWorkspaceFromPath(w, r) + if !ok { + return + } + browserSession, ok := s.requireBrowserSession(w, r) + if !ok { + return + } + statusResp, cookie, errAccess := s.ensureIDEAccess(r.Context(), workspace.Path, workspace.Name, requestIsHTTPS(r), browserSession) + if errAccess != nil { + statusCode := http.StatusInternalServerError + if errors.Is(errAccess, errIDEUnavailable) { + statusCode = http.StatusServiceUnavailable + } + http.Error(w, errAccess.Error(), statusCode) + return + } + setIDEAccessResponseCookies(w, workspace.ID, requestIsHTTPS(r), cookie) + writeJSON(w, statusResp) +} + +func (s *Server) ensureIDEAccess(ctx context.Context, workspacePath, workspaceName string, secureCookie bool, browserSession *browserSession) (apiIDEStatusResponse, *http.Cookie, error) { + startResult, errEnsure := s.ensureIDESession(ctx, workspacePath, workspaceName) + if errEnsure != nil { + return apiIDEStatusResponse{}, nil, errEnsure + } + grant, errGrant := s.newIDEAccessGrant(startResult.session, browserSession.Token) + if errGrant != nil { + return apiIDEStatusResponse{}, nil, errGrant + } + resp := s.buildIDEStatusResponse(ctx, workspacePath) + resp.Reused = startResult.reused + cookie := newIDEAccessCookie(grant.Token, ideAccessCookiePath(), secureCookie, grant.ExpiresAt, 0) + return resp, cookie, nil +} + +func newIDEAccessCookie(token, path string, secureCookie bool, expiresAt time.Time, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: ideAccessCookieName, + Value: token, + Quoted: false, + Path: path, + Domain: "", + Expires: expiresAt, + RawExpires: "", + MaxAge: maxAge, + Secure: secureCookie, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: false, + Raw: "", + Unparsed: nil, + } +} + +func expiredLegacyIDEAccessCookies(workspaceID string, secureCookie bool) []*http.Cookie { + paths := legacyIDEAccessCookiePaths(workspaceID) + cookies := make([]*http.Cookie, 0, len(paths)) + for _, path := range paths { + cookies = append(cookies, newIDEAccessCookie("", path, secureCookie, time.Unix(0, 0), -1)) + } + return cookies +} + +func setIDEAccessResponseCookies(w http.ResponseWriter, workspaceID string, secureCookie bool, accessCookie *http.Cookie) { + for _, cookie := range expiredLegacyIDEAccessCookies(workspaceID, secureCookie) { + http.SetCookie(w, cookie) + } + if accessCookie != nil { + http.SetCookie(w, accessCookie) + } +} + +func (s *Server) newIDEAccessGrant(sess *ideSession, browserSessionToken string) (ideAccessGrant, error) { + now := s.ideNow() + token, errToken := newIDEAccessToken() + if errToken != nil { + return ideAccessGrant{Token: "", BrowserSessionToken: "", ExpiresAt: time.Time{}, LastActivity: time.Time{}}, errToken + } + grant := ideAccessGrant{Token: token, BrowserSessionToken: browserSessionToken, ExpiresAt: now.Add(s.ideAccessTTL), LastActivity: now} + s.mu.Lock() + if sess.accessGrants == nil { + sess.accessGrants = make(map[string]ideAccessGrant) + } + sess.accessGrants[token] = grant + sess.lastActivity = now + s.mu.Unlock() + return grant, nil +} + +func (s *Server) ensureBrowserSession(w http.ResponseWriter, r *http.Request) bool { + if browserSession, ok := s.lookupBrowserSession(r); ok { + s.setBrowserSessionCookie(w, requestIsHTTPS(r), browserSession) + return true + } + browserSession, errBrowserSession := s.newBrowserSession() + if errBrowserSession != nil { + http.Error(w, fmt.Sprintf("creating browser session: %v", errBrowserSession), http.StatusInternalServerError) + return false + } + s.setBrowserSessionCookie(w, requestIsHTTPS(r), browserSession) + return true +} + +func (s *Server) requireBrowserSession(w http.ResponseWriter, r *http.Request) (*browserSession, bool) { + browserSession, ok := s.lookupBrowserSession(r) + if !ok { + s.clearBrowserSessionCookie(w, requestIsHTTPS(r)) + http.Error(w, "browser session required", http.StatusUnauthorized) + return nil, false + } + s.setBrowserSessionCookie(w, requestIsHTTPS(r), browserSession) + return browserSession, true +} + +func (s *Server) lookupBrowserSession(r *http.Request) (*browserSession, bool) { + cookie, errCookie := r.Cookie(browserSessionCookieName) + if errCookie != nil { + return nil, false + } + now := s.ideNow() + s.mu.Lock() + defer s.mu.Unlock() + current := s.browserSessions[cookie.Value] + if current == nil { + return nil, false + } + if !current.ExpiresAt.IsZero() && now.After(current.ExpiresAt) { + delete(s.browserSessions, cookie.Value) + return nil, false + } + current.LastSeen = now + current.ExpiresAt = now.Add(s.browserSessionTTL) + refreshed := *current + return &refreshed, true +} + +func (s *Server) newBrowserSession() (*browserSession, error) { + token, errToken := newIDEAccessToken() + if errToken != nil { + return nil, errToken + } + now := s.ideNow() + browserSession := &browserSession{ + Token: token, + ExpiresAt: now.Add(s.browserSessionTTL), + LastSeen: now, + } + s.mu.Lock() + s.browserSessions[token] = browserSession + s.mu.Unlock() + cloned := *browserSession + return &cloned, nil +} + +func newBrowserSessionCookie(token string, secureCookie bool, expiresAt time.Time, maxAge int) *http.Cookie { + return &http.Cookie{ + Name: browserSessionCookieName, + Value: token, + Quoted: false, + Path: "/", + Domain: "", + Expires: expiresAt, + RawExpires: "", + MaxAge: maxAge, + Secure: secureCookie, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Partitioned: false, + Raw: "", + Unparsed: nil, + } +} + +func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, secureCookie bool, browserSession *browserSession) { + http.SetCookie(w, newBrowserSessionCookie(browserSession.Token, secureCookie, browserSession.ExpiresAt, 0)) +} + +func (s *Server) clearBrowserSessionCookie(w http.ResponseWriter, secureCookie bool) { + http.SetCookie(w, newBrowserSessionCookie("", secureCookie, time.Unix(0, 0), -1)) +} + +func newIDEAccessToken() (string, error) { + buf := make([]byte, 32) + _, errRead := rand.Read(buf) + if errRead != nil { + return "", fmt.Errorf("reading ide access token bytes: %w", errRead) + } + return hex.EncodeToString(buf), nil +} + +func (s *Server) ensureIDESession(ctx context.Context, workspacePath, workspaceName string) (ideSessionStartResult, error) { + availability := s.ideAvailability(ctx) + if !availability.Available { + s.recordIDEFailure(workspacePath, workspaceName, availability.Reason) + return emptyIDESessionStartResult(), fmt.Errorf("%w: %s", errIDEUnavailable, availability.Reason) + } + if result, ok := s.reuseRunningIDESession(ctx, workspacePath); ok { + return result, nil + } + return s.ideStartFlight.do(workspacePath, func() (ideSessionStartResult, error) { + if result, ok := s.reuseRunningIDESession(ctx, workspacePath); ok { + return result, nil + } + containerName := ideContainerName(workspacePath) + existingTarget, errInspect := s.ideRuntime.inspect(ctx, newIDERuntimeTarget(containerName, "", 0)) + if errInspect == nil { + if errValidate := validateIDETarget(existingTarget); errValidate == nil { + sess := s.storeIDESession(workspacePath, workspaceName, existingTarget, "reused") + log.Printf("ide session reused workspace=%s container=%s", workspaceName, existingTarget.ID) + return ideSessionStartResult{session: sess, reused: true}, nil + } + if errStop := s.ideRuntime.stop(ctx, existingTarget); errStop != nil { + log.Printf("ide session cleanup failed workspace=%s container=%s err=%v", workspaceName, existingTarget.ID, errStop) + } + } + request := ideStartRequest{ + WorkspacePath: workspacePath, + ContainerName: containerName, + } + target, errStart := s.ideRuntime.start(ctx, request) + if errStart != nil { + s.recordIDEFailure(workspacePath, workspaceName, errStart.Error()) + return emptyIDESessionStartResult(), fmt.Errorf("starting ide session: %w", errStart) + } + if errValidate := validateIDETarget(target); errValidate != nil { + if errStop := s.ideRuntime.stop(ctx, target); errStop != nil { + log.Printf("ide session cleanup failed workspace=%s container=%s err=%v", workspaceName, target.ID, errStop) + } + s.recordIDEFailure(workspacePath, workspaceName, errValidate.Error()) + return emptyIDESessionStartResult(), errValidate + } + log.Printf("ide session created workspace=%s container=%s", workspaceName, target.ID) + return ideSessionStartResult{session: s.storeIDESession(workspacePath, workspaceName, target, "created"), reused: false}, nil + }) +} + +func (s *Server) reuseRunningIDESession(ctx context.Context, workspacePath string) (ideSessionStartResult, bool) { + s.mu.Lock() + sess := s.ideSessions[workspacePath] + s.mu.Unlock() + if sess == nil || sess.target.ID == "" { + return emptyIDESessionStartResult(), false + } + target, errInspect := s.ideRuntime.inspect(ctx, sess.target) + if errInspect != nil { + s.mu.Lock() + if current := s.ideSessions[workspacePath]; current != nil { + current.target = newIDERuntimeTarget("", "", 0) + current.accessGrants = make(map[string]ideAccessGrant) + current.lastError = fmt.Sprintf("ide session inspect failed: %v", errInspect) + current.lastEvent = "failed" + } + s.mu.Unlock() + go s.notifyStateChange() + return emptyIDESessionStartResult(), false + } + if errValidate := validateIDETarget(target); errValidate != nil { + if errStop := s.ideRuntime.stop(ctx, target); errStop != nil { + log.Printf("ide session cleanup failed workspace=%s container=%s err=%v", sess.workspaceName, target.ID, errStop) + } + s.recordIDEFailure(workspacePath, sess.workspaceName, errValidate.Error()) + return emptyIDESessionStartResult(), false + } + s.mu.Lock() + if current := s.ideSessions[workspacePath]; current != nil { + current.target = target + current.lastEvent = "reused" + current.lastError = "" + sess = current + } + s.mu.Unlock() + log.Printf("ide session reused workspace=%s container=%s", sess.workspaceName, target.ID) + return ideSessionStartResult{session: sess, reused: true}, true +} + +func (s *Server) storeIDESession(workspacePath, workspaceName string, target ideRuntimeTarget, event string) *ideSession { + now := s.ideNow() + s.mu.Lock() + sess := &ideSession{ + workspacePath: workspacePath, + workspaceName: workspaceName, + target: target, + createdAt: now, + lastActivity: now, + lastError: "", + lastEvent: event, + accessGrants: make(map[string]ideAccessGrant), + } + s.ideSessions[workspacePath] = sess + s.ideStatusCache.delete(ideStatusCacheKey) + s.mu.Unlock() + s.notifyStateChange() + return sess +} + +func (s *Server) recordIDEFailure(workspacePath, workspaceName, message string) { + s.mu.Lock() + s.ideSessions[workspacePath] = &ideSession{ + workspacePath: workspacePath, + workspaceName: workspaceName, + target: newIDERuntimeTarget("", "", 0), + createdAt: time.Time{}, + lastActivity: time.Time{}, + lastError: message, + lastEvent: "failed", + accessGrants: make(map[string]ideAccessGrant), + } + s.ideStatusCache.delete(ideStatusCacheKey) + s.mu.Unlock() + s.notifyStateChange() +} + +func (s *Server) setIDESessionError(workspacePath, message, event string) { + s.mu.Lock() + if sess := s.ideSessions[workspacePath]; sess != nil { + sess.lastError = message + sess.lastEvent = event + } + s.mu.Unlock() + s.notifyStateChange() +} + +func (s *Server) handleIDEProxyEntry(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + workspace, ok := s.resolveIDEWorkspaceFromPath(w, r) + if !ok { + return + } + http.Redirect(w, r, workspaceIDEProxyPath(workspace.ID), http.StatusTemporaryRedirect) + return + } + s.handleIDEProxy(w, r) +} + +func (s *Server) handleIDEProxy(w http.ResponseWriter, r *http.Request) { + workspace, ok := s.resolveIDEWorkspaceFromPath(w, r) + if !ok { + return + } + target, workspaceName, ok := s.authorizeIDERequest(w, r, workspace) + if !ok { + return + } + if target.ID == "" { + http.Error(w, "ide session not found", http.StatusNotFound) + return + } + targetURL := new(url.URL) + targetURL.Scheme = "http" + targetURL.Host = net.JoinHostPort(target.Host, strconv.Itoa(target.Port)) + proxy := new(httputil.ReverseProxy) + proxy.Rewrite = func(proxyRequest *httputil.ProxyRequest) { + proxyRequest.SetURL(targetURL) + proxyRequest.SetXForwarded() + stripIDEProxyAuthCookies(proxyRequest.Out) + basePath := workspaceIDEBasePath(workspace.ID) + proxyRequest.Out.URL.Path = trimIDEProxyRequestPath(proxyRequest.In.URL.Path, basePath) + if proxyRequest.In.URL.RawPath != "" { + proxyRequest.Out.URL.RawPath = trimIDEProxyRequestPath(proxyRequest.In.URL.RawPath, basePath) + } + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, errProxy error) { + if shouldIgnoreIDEProxyError(r, errProxy) { + return + } + message := fmt.Sprintf("ide proxy failed: %v", errProxy) + log.Printf("ide proxy failed workspace=%s err=%v", workspaceName, errProxy) + s.setIDESessionError(workspace.Path, message, "failed") + http.Error(w, message, http.StatusBadGateway) + } + proxy.ServeHTTP(w, r) +} + +func stripIDEProxyAuthCookies(r *http.Request) { + if r == nil || r.Header == nil { + return + } + cookies := r.Cookies() + if len(cookies) == 0 { + return + } + r.Header.Del("Cookie") + for _, cookie := range cookies { + if cookie.Name == browserSessionCookieName || cookie.Name == ideAccessCookieName { + continue + } + r.AddCookie(cookie) + } +} + +func trimIDEProxyRequestPath(urlPath, basePath string) string { + if urlPath == basePath { + return "/" + } + if strings.HasPrefix(urlPath, basePath+"/") { + trimmed := strings.TrimPrefix(urlPath, basePath) + if trimmed == "" { + return "/" + } + return trimmed + } + if urlPath == "" { + return "/" + } + return urlPath +} + +func shouldIgnoreIDEProxyError(r *http.Request, errProxy error) bool { + if errors.Is(errProxy, context.Canceled) { + return true + } + return requestWasCanceled(r) +} + +func requestWasCanceled(r *http.Request) bool { + if r == nil { + return false + } + return errors.Is(r.Context().Err(), context.Canceled) +} + +func (s *Server) resolveIDEWorkspaceFromPath(w http.ResponseWriter, r *http.Request) (ideWorkspaceRef, bool) { + workspaceID := strings.TrimSpace(r.PathValue("id")) + if workspaceID == "" { + http.Error(w, "workspace id is required", http.StatusBadRequest) + return ideWorkspaceRef{Path: "", Name: "", ID: ""}, false + } + groups, errGroups := s.scanWorkspaceGroups() + if errGroups != nil { + http.Error(w, "workspace not found", http.StatusNotFound) + return ideWorkspaceRef{Path: "", Name: "", ID: ""}, false + } + var matches []ideWorkspaceRef + for _, workspace := range workspaceInfos(groups) { + if workspaceRouteID(workspace.Directory) != workspaceID { + continue + } + matches = append(matches, ideWorkspaceRef{Path: workspace.Directory, Name: workspace.DirName, ID: workspaceID}) + } + switch len(matches) { + case 0: + http.Error(w, "workspace not found", http.StatusNotFound) + return ideWorkspaceRef{Path: "", Name: "", ID: ""}, false + case 1: + return matches[0], true + default: + http.Error(w, "workspace id is ambiguous", http.StatusConflict) + return ideWorkspaceRef{Path: "", Name: "", ID: ""}, false + } +} + +func ideAccessCookieValues(r *http.Request) []string { + if r == nil { + return nil + } + cookies := r.Cookies() + values := make([]string, 0, len(cookies)) + for _, cookie := range cookies { + if cookie.Name != ideAccessCookieName || cookie.Value == "" { + continue + } + values = append(values, cookie.Value) + } + return values +} + +func refreshMatchingIDEAccessGrant(sess *ideSession, cookieValues []string, browserSessionToken string, now time.Time, ttl time.Duration) (ideAccessGrant, bool, bool) { + hasBrowserSessionMismatch := false + for _, cookieValue := range cookieValues { + grant, ok := sess.accessGrants[cookieValue] + if !ok { + continue + } + if !grant.ExpiresAt.IsZero() && now.After(grant.ExpiresAt) { + delete(sess.accessGrants, cookieValue) + continue + } + if grant.BrowserSessionToken != browserSessionToken { + delete(sess.accessGrants, cookieValue) + hasBrowserSessionMismatch = true + continue + } + grant.LastActivity = now + grant.ExpiresAt = now.Add(ttl) + sess.accessGrants[cookieValue] = grant + sess.lastActivity = now + sess.lastError = "" + return grant, true, false + } + return ideAccessGrant{Token: "", BrowserSessionToken: "", ExpiresAt: time.Time{}, LastActivity: time.Time{}}, false, hasBrowserSessionMismatch +} + +func refreshLatestIDEAccessGrantForBrowserSession(sess *ideSession, browserSessionToken string, now time.Time, ttl time.Duration) (ideAccessGrant, bool) { + bestToken := "" + bestGrant := ideAccessGrant{Token: "", BrowserSessionToken: "", ExpiresAt: time.Time{}, LastActivity: time.Time{}} + for token, grant := range sess.accessGrants { + if !grant.ExpiresAt.IsZero() && now.After(grant.ExpiresAt) { + delete(sess.accessGrants, token) + continue + } + if grant.BrowserSessionToken != browserSessionToken { + continue + } + if bestToken == "" || grant.ExpiresAt.After(bestGrant.ExpiresAt) || (grant.ExpiresAt.Equal(bestGrant.ExpiresAt) && grant.LastActivity.After(bestGrant.LastActivity)) { + bestToken = token + bestGrant = grant + } + } + if bestToken == "" { + return ideAccessGrant{Token: "", BrowserSessionToken: "", ExpiresAt: time.Time{}, LastActivity: time.Time{}}, false + } + bestGrant.LastActivity = now + bestGrant.ExpiresAt = now.Add(ttl) + sess.accessGrants[bestToken] = bestGrant + sess.lastActivity = now + sess.lastError = "" + return bestGrant, true +} + +func (s *Server) authorizeIDERequest(w http.ResponseWriter, r *http.Request, workspace ideWorkspaceRef) (ideRuntimeTarget, string, bool) { + browserSession, ok := s.lookupBrowserSession(r) + if !ok { + s.clearBrowserSessionCookie(w, requestIsHTTPS(r)) + http.Error(w, "ide access forbidden: browser session required", http.StatusForbidden) + return newIDERuntimeTarget("", "", 0), "", false + } + secureCookie := requestIsHTTPS(r) + s.setBrowserSessionCookie(w, secureCookie, browserSession) + cookieValues := ideAccessCookieValues(r) + now := s.ideNow() + s.mu.Lock() + current := s.ideSessions[workspace.Path] + if current == nil || current.target.ID == "" { + s.mu.Unlock() + http.Error(w, "ide session not found", http.StatusNotFound) + return newIDERuntimeTarget("", "", 0), "", false + } + grant, ok, hasBrowserSessionMismatch := refreshMatchingIDEAccessGrant(current, cookieValues, browserSession.Token, now, s.ideAccessTTL) + if !ok && !hasBrowserSessionMismatch { + grant, ok = refreshLatestIDEAccessGrantForBrowserSession(current, browserSession.Token, now, s.ideAccessTTL) + } + if !ok { + s.mu.Unlock() + if hasBrowserSessionMismatch { + http.Error(w, "ide access forbidden: browser session mismatch", http.StatusForbidden) + return newIDERuntimeTarget("", "", 0), "", false + } + http.Error(w, "ide access forbidden", http.StatusForbidden) + return newIDERuntimeTarget("", "", 0), "", false + } + target := current.target + workspaceName := current.workspaceName + s.mu.Unlock() + setIDEAccessResponseCookies(w, workspace.ID, secureCookie, newIDEAccessCookie(grant.Token, ideAccessCookiePath(), secureCookie, grant.ExpiresAt, 0)) + return target, workspaceName, true +} + +func (s *Server) cleanupIdleIDESessions(now time.Time) { + s.mu.Lock() + for token, browserSession := range s.browserSessions { + if !browserSession.ExpiresAt.IsZero() && now.After(browserSession.ExpiresAt) { + delete(s.browserSessions, token) + } + } + idleCandidates := make([]ideSessionCleanupCandidate, 0, len(s.ideSessions)) + for workspacePath, sess := range s.ideSessions { + for token, grant := range sess.accessGrants { + if !grant.ExpiresAt.IsZero() && now.After(grant.ExpiresAt) { + delete(sess.accessGrants, token) + } + } + if sess.target.ID == "" { + continue + } + if ideSessionIsIdle(sess, now, s.ideIdleTimeout) { + idleCandidates = append(idleCandidates, ideSessionCleanupCandidate{workspacePath: workspacePath, targetID: sess.target.ID}) + } + } + s.mu.Unlock() + for _, candidate := range idleCandidates { + if errStop := s.stopIdleIDESession(context.Background(), candidate, now, "destroyed"); errStop != nil { + log.Printf("ide session cleanup failed workspace=%s err=%v", candidate.workspacePath, errStop) + } + } +} + +func (s *Server) stopAllIDESessions(ctx context.Context) { + s.mu.Lock() + workspacePaths := make([]string, 0, len(s.ideSessions)) + for workspacePath, sess := range s.ideSessions { + if sess.target.ID != "" { + workspacePaths = append(workspacePaths, workspacePath) + } + } + s.mu.Unlock() + for _, workspacePath := range workspacePaths { + if errStop := s.stopIDESession(ctx, workspacePath, "destroyed"); errStop != nil { + log.Printf("ide session shutdown failed workspace=%s err=%v", workspacePath, errStop) + } + } +} + +func (s *Server) stopIDESession(ctx context.Context, workspacePath, event string) error { + return s.stopIDESessionIf(ctx, workspacePath, event, func(*ideSession) bool { + return true + }) +} + +func ideSessionIsIdle(sess *ideSession, now time.Time, idleTimeout time.Duration) bool { + if sess == nil { + return false + } + return now.Sub(sess.lastActivity) > idleTimeout +} + +func (s *Server) stopIdleIDESession(ctx context.Context, candidate ideSessionCleanupCandidate, now time.Time, event string) error { + return s.stopIDESessionIf(ctx, candidate.workspacePath, event, func(sess *ideSession) bool { + return sess.target.ID == candidate.targetID && ideSessionIsIdle(sess, now, s.ideIdleTimeout) + }) +} + +func (s *Server) stopIDESessionIf(ctx context.Context, workspacePath, event string, shouldStop func(*ideSession) bool) error { + s.mu.Lock() + current := s.ideSessions[workspacePath] + if current == nil || current.target.ID == "" || !shouldStop(current) { + s.mu.Unlock() + return nil + } + target := current.target + targetID := current.target.ID + s.mu.Unlock() + errStop := s.ideRuntime.stop(ctx, target) + s.mu.Lock() + current = s.ideSessions[workspacePath] + if current == nil || current.target.ID != targetID || !shouldStop(current) { + s.mu.Unlock() + return errStop + } + if errStop != nil { + current.lastError = errStop.Error() + current.lastEvent = "failed" + s.mu.Unlock() + return errStop + } + log.Printf("ide session %s workspace=%s container=%s", event, current.workspaceName, current.target.ID) + delete(s.ideSessions, workspacePath) + s.ideStatusCache.delete(ideStatusCacheKey) + s.mu.Unlock() + s.notifyStateChange() + return nil +} + +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func validateIDETarget(target ideRuntimeTarget) error { + if target.ID == "" { + return errors.New("ide target id is required") + } + if target.Port <= 0 { + return errors.New("ide target port is required") + } + if !isLoopbackTargetHost(target.Host) { + return fmt.Errorf("ide target must use loopback host: %s", target.Host) + } + return nil +} + +func isLoopbackTargetHost(host string) bool { + if host == "localhost" { + return true + } + trimmed := strings.Trim(host, "[]") + ip := net.ParseIP(trimmed) + if ip == nil { + return false + } + return ip.IsLoopback() +} + +func formatOptionalTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func isIDEProxyRoute(urlPath string) bool { + trimmed := strings.Trim(strings.TrimSpace(urlPath), "/") + parts := strings.Split(trimmed, "/") + return len(parts) >= 3 && parts[0] == "workspaces" && parts[2] == "ide-proxy" +} diff --git a/cmd/sgai/serve_ide_test.go b/cmd/sgai/serve_ide_test.go new file mode 100644 index 0000000..1611fca --- /dev/null +++ b/cmd/sgai/serve_ide_test.go @@ -0,0 +1,1155 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeIDERuntime struct { + statusResult ideRuntimeStatus + startTarget ideRuntimeTarget + inspectTarget ideRuntimeTarget + startErr error + inspectErr error + stopErr error + beforeStop func() + startCalls int + inspectCalls int + stopCalls int + lastStartReq ideStartRequest + lastStopTarget ideRuntimeTarget + lastInspectID string +} + +type writeErrorResponseWriter struct { + header http.Header + statusCode int + writeErr error + onWrite func() +} + +func newFakeIDERuntime() *fakeIDERuntime { + return &fakeIDERuntime{ + statusResult: newIDERuntimeStatus(false, ""), + startTarget: newIDERuntimeTarget("", "", 0), + inspectTarget: newIDERuntimeTarget("", "", 0), + startErr: nil, + inspectErr: nil, + stopErr: nil, + beforeStop: nil, + startCalls: 0, + inspectCalls: 0, + stopCalls: 0, + lastStartReq: ideStartRequest{WorkspacePath: "", ContainerName: ""}, + lastStopTarget: newIDERuntimeTarget("", "", 0), + lastInspectID: "", + } +} + +func newWriteErrorResponseWriter(writeErr error, onWrite func()) *writeErrorResponseWriter { + return &writeErrorResponseWriter{ + header: make(http.Header), + statusCode: 0, + writeErr: writeErr, + onWrite: onWrite, + } +} + +func (w *writeErrorResponseWriter) Header() http.Header { + return w.header +} + +func (w *writeErrorResponseWriter) WriteHeader(statusCode int) { + if w.statusCode != 0 { + return + } + w.statusCode = statusCode +} + +func (w *writeErrorResponseWriter) Write(p []byte) (int, error) { + if w.statusCode == 0 { + w.statusCode = http.StatusOK + } + if w.onWrite != nil { + w.onWrite() + } + if w.writeErr == nil { + return len(p), nil + } + return 0, w.writeErr +} + +func (r *fakeIDERuntime) status(context.Context) ideRuntimeStatus { + return r.statusResult +} + +func (r *fakeIDERuntime) start(_ context.Context, req ideStartRequest) (ideRuntimeTarget, error) { + r.startCalls++ + r.lastStartReq = req + return r.startTarget, r.startErr +} + +func (r *fakeIDERuntime) inspect(_ context.Context, target ideRuntimeTarget) (ideRuntimeTarget, error) { + r.inspectCalls++ + r.lastInspectID = target.ID + if r.inspectErr != nil { + return newIDERuntimeTarget("", "", 0), r.inspectErr + } + if r.inspectTarget.ID != "" || r.inspectTarget.Host != "" || r.inspectTarget.Port != 0 { + return r.inspectTarget, nil + } + if target.Host == "" && target.Port == 0 { + return newIDERuntimeTarget("", "", 0), errors.New("not found") + } + return target, nil +} + +func (r *fakeIDERuntime) stop(_ context.Context, target ideRuntimeTarget) error { + if r.beforeStop != nil { + r.beforeStop() + } + r.stopCalls++ + r.lastStopTarget = target + return r.stopErr +} + +func newIDEProxyHandler(server *Server) http.Handler { + mux := http.NewServeMux() + server.registerAPIRoutes(mux) + server.registerIDERoutes(mux) + return server.spaMiddleware(mux) +} + +func targetFromHTTPServer(t *testing.T, rawURL string) ideRuntimeTarget { + t.Helper() + parsedURL, errParse := url.Parse(rawURL) + require.NoError(t, errParse) + host, portText, errSplit := net.SplitHostPort(parsedURL.Host) + require.NoError(t, errSplit) + port, errPort := net.LookupPort("tcp", portText) + require.NoError(t, errPort) + return newIDERuntimeTarget("target", host, port) +} + +func issueIDEAccessCookies(t *testing.T, server *Server, workspaceID string) (browserSessionCookie, ideCookie *http.Cookie) { + t.Helper() + browserSessionCookie = issueBrowserSessionCookie(t, server) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + req.AddCookie(browserSessionCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(server).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + result := w.Result() + require.NoError(t, result.Body.Close()) + return requireCookieNamed(t, result.Cookies(), browserSessionCookieName), requireCookieNamed(t, result.Cookies(), ideAccessCookieName) +} + +func issueIDEAccessCookie(t *testing.T, server *Server, workspaceID string) *http.Cookie { + t.Helper() + _, ideCookie := issueIDEAccessCookies(t, server, workspaceID) + return ideCookie +} + +func issueBrowserSessionCookie(t *testing.T, server *Server) *http.Cookie { + t.Helper() + w := serveHTTP(server, http.MethodGet, "/api/v1/state", "") + require.Equal(t, http.StatusOK, w.Code) + result := w.Result() + require.NoError(t, result.Body.Close()) + return requireCookieNamed(t, result.Cookies(), browserSessionCookieName) +} + +func newTestCookie(name, value string) *http.Cookie { + return &http.Cookie{ + Name: name, + Value: value, + Quoted: false, + Path: "", + Domain: "", + Expires: time.Time{}, + RawExpires: "", + MaxAge: 0, + Secure: false, + HttpOnly: false, + SameSite: 0, + Partitioned: false, + Raw: "", + Unparsed: nil, + } +} + +func requireCookieNamed(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie { + t.Helper() + var match *http.Cookie + for _, cookie := range cookies { + if cookie.Name != name { + continue + } + if cookie.Value != "" { + match = cookie + continue + } + if match == nil { + match = cookie + } + } + if match != nil { + return match + } + require.Failf(t, "cookie not found", "expected cookie %q", name) + return nil +} + +func TestShouldIgnoreIDEProxyErrorRequiresCanceledRequestForClosedConnection(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/workspaces/test/ide-proxy/static/app.js", http.NoBody) + assert.False(t, shouldIgnoreIDEProxyError(req, net.ErrClosed)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + canceledReq := httptest.NewRequest(http.MethodGet, "/workspaces/test/ide-proxy/static/app.js", http.NoBody).WithContext(ctx) + assert.True(t, shouldIgnoreIDEProxyError(canceledReq, net.ErrClosed)) + assert.True(t, shouldIgnoreIDEProxyError(canceledReq, context.Canceled)) + assert.False(t, shouldIgnoreIDEProxyError(nil, net.ErrClosed)) +} + +func setupNestedTestWorkspace(t *testing.T, server *Server, rootDir string, pathParts ...string) string { + t.Helper() + wsDir := filepath.Join(append([]string{rootDir}, pathParts...)...) + require.NoError(t, os.MkdirAll(filepath.Join(wsDir, ".sgai"), 0o755)) + canonicalDir := resolveSymlinks(wsDir) + server.mu.Lock() + server.externalDirs[canonicalDir] = true + server.mu.Unlock() + server.invalidateWorkspaceScanCache() + return canonicalDir +} + +func TestBuildWorkspaceFullStateIncludesIDEAvailability(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-state-ws") + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "GOAL.md"), []byte("# Goal"), 0o644)) + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(false, "docker unavailable") + srv.ideRuntime = runtime + + ws := workspaceWith(func(workspace *workspaceInfo) { + workspace.DirName = "ide-state-ws" + workspace.Directory = wsDir + workspace.HasWorkspace = true + }) + + result := srv.buildWorkspaceFullState(ws, nil) + + assert.False(t, result.IDE.Available) + assert.False(t, result.IDE.Running) + assert.Equal(t, "docker unavailable", result.IDE.Reason) + assert.Equal(t, workspaceIDEProxyPath(workspaceRouteID(wsDir)), result.IDE.ProxyPath) + assert.Equal(t, "/api/v1/workspaces/"+workspaceRouteID(wsDir)+"/ide/access", result.IDE.AccessPath) +} + +func TestHandleAPIIDEAccessStartsSessionAndSetsCookie(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-access-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + srv.ideNow = func() time.Time { + return time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + } + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie := issueBrowserSessionCookie(t, srv) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + req.AddCookie(browserSessionCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp apiIDEStatusResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.True(t, resp.Available) + assert.True(t, resp.Running) + assert.False(t, resp.Reused) + assert.Equal(t, workspaceIDEProxyPath(workspaceID), resp.ProxyPath) + assert.NotNil(t, resp.Session) + assert.Equal(t, wsDir, runtime.lastStartReq.WorkspacePath) + assert.Equal(t, ideContainerName(wsDir), runtime.lastStartReq.ContainerName) + assert.Equal(t, 1, runtime.startCalls) + + result := w.Result() + require.NoError(t, result.Body.Close()) + cookies := result.Cookies() + ideCookie := requireCookieNamed(t, cookies, ideAccessCookieName) + assert.Equal(t, ideAccessCookiePath(), ideCookie.Path) + assert.True(t, ideCookie.HttpOnly) + assert.Equal(t, http.SameSiteStrictMode, ideCookie.SameSite) +} + +func TestHandleAPIIDEAccessRequiresBrowserSession(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-browser-auth-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + + w := serveHTTP(srv, http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", "") + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, 0, runtime.startCalls) + assert.Contains(t, w.Body.String(), "browser session") +} + +func TestHandleAPIIDEAccessIgnoresForwardedIdentityHeaders(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-forwarded-header-access-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie := issueBrowserSessionCookie(t, srv) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + req.AddCookie(browserSessionCookie) + req.Header.Set("X-Forwarded-User", "spoofed-user") + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 1, runtime.startCalls) +} + +func TestHandleAPIIDEAccessReusesRunningSession(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-reuse-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie := issueBrowserSessionCookie(t, srv) + firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + firstReq.AddCookie(browserSessionCookie) + first := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(first, firstReq) + require.Equal(t, http.StatusOK, first.Code) + secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + secondReq.AddCookie(browserSessionCookie) + second := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(second, secondReq) + require.Equal(t, http.StatusOK, second.Code) + + var resp apiIDEStatusResponse + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &resp)) + assert.True(t, resp.Reused) + assert.Equal(t, 1, runtime.startCalls) + assert.GreaterOrEqual(t, runtime.inspectCalls, 1) +} + +func TestHandleAPIIDEAccessRejectsNonLoopbackTarget(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-loopback-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-unsafe", "0.0.0.0", 12345) + srv.ideRuntime = runtime + + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie := issueBrowserSessionCookie(t, srv) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + req.AddCookie(browserSessionCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "loopback") + assert.Equal(t, 1, runtime.stopCalls) + assert.Equal(t, "container-unsafe", runtime.lastStopTarget.ID) +} + +func TestIDEProxyRequiresAuthorization(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-auth-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + _, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + require.NotNil(t, ideCookie) + + req := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID), http.NoBody) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestIDEProxyRequiresMatchingBrowserSession(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-session-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + ideCookie := issueIDEAccessCookie(t, srv, workspaceID) + + req := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID), http.NoBody) + req.AddCookie(ideCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "browser session") +} + +func TestIDEProxyIgnoresForwardedIdentityHeaders(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-forwarded-header-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + req := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID), http.NoBody) + req.AddCookie(browserSessionCookie) + req.AddCookie(ideCookie) + req.Header.Set("X-Forwarded-User", "spoofed-user") + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestIDEAccessClearsLegacyProxyScopedCookie(t *testing.T) { + tests := []struct { + name string + cookiePath func(workspaceID string) string + }{ + { + name: "base path", + cookiePath: workspaceIDEBasePath, + }, + { + name: "proxy path", + cookiePath: workspaceIDEProxyPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-legacy-cookie-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + front := httptest.NewServer(newIDEProxyHandler(srv)) + defer front.Close() + jar, errJar := cookiejar.New(nil) + require.NoError(t, errJar) + client := &http.Client{Transport: nil, CheckRedirect: nil, Jar: jar, Timeout: 0} + + stateResp, errState := client.Get(front.URL + "/api/v1/state") + require.NoError(t, errState) + require.NoError(t, stateResp.Body.Close()) + + frontURL, errParse := url.Parse(front.URL) + require.NoError(t, errParse) + jar.SetCookies(frontURL, []*http.Cookie{{ + Name: ideAccessCookieName, + Value: "legacy-token", + Path: tt.cookiePath(workspaceID), + Expires: time.Now().Add(time.Hour), + }}) + + accessReq, errAccessReq := http.NewRequest(http.MethodPost, front.URL+"/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + require.NoError(t, errAccessReq) + accessResp, errAccess := client.Do(accessReq) + require.NoError(t, errAccess) + require.Equal(t, http.StatusOK, accessResp.StatusCode) + require.NoError(t, accessResp.Body.Close()) + + proxyResp, errProxy := client.Get(front.URL + workspaceIDEProxyPath(workspaceID)) + require.NoError(t, errProxy) + defer func() { + assert.NoError(t, proxyResp.Body.Close()) + }() + assert.Equal(t, http.StatusNoContent, proxyResp.StatusCode) + }) + } +} + +func TestIDEProxyAllowsFirstRequestAfterAccessWhenRequestStillCarriesStaleCookie(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-first-request-stale-cookie-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie := issueBrowserSessionCookie(t, srv) + + accessReq := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + accessReq.AddCookie(browserSessionCookie) + accessResp := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(accessResp, accessReq) + require.Equal(t, http.StatusOK, accessResp.Code) + + proxyReq := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID), http.NoBody) + proxyReq.AddCookie(browserSessionCookie) + proxyReq.AddCookie(newIDEAccessCookie("stale-token", workspaceIDEProxyPath(workspaceID), false, time.Time{}, 0)) + proxyResp := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(proxyResp, proxyReq) + + assert.Equal(t, http.StatusNoContent, proxyResp.Code) + result := proxyResp.Result() + defer func() { + assert.NoError(t, result.Body.Close()) + }() + refreshedIDECookie := requireCookieNamed(t, result.Cookies(), ideAccessCookieName) + assert.NotEmpty(t, refreshedIDECookie.Value) + assert.Equal(t, ideAccessCookiePath(), refreshedIDECookie.Path) +} + +func TestIDEProxyAllowsFirstRequestAfterAccessWhenRequestHasNoIDECookieYet(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-first-request-no-cookie-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + front := httptest.NewServer(newIDEProxyHandler(srv)) + defer front.Close() + jar, errJar := cookiejar.New(nil) + require.NoError(t, errJar) + authorizedClient := &http.Client{Transport: nil, CheckRedirect: nil, Jar: jar, Timeout: 0} + + stateResp, errState := authorizedClient.Get(front.URL + "/api/v1/state") + require.NoError(t, errState) + require.NoError(t, stateResp.Body.Close()) + + accessReq, errAccessReq := http.NewRequest(http.MethodPost, front.URL+"/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + require.NoError(t, errAccessReq) + accessResp, errAccess := authorizedClient.Do(accessReq) + require.NoError(t, errAccess) + require.Equal(t, http.StatusOK, accessResp.StatusCode) + browserSessionCookie := requireCookieNamed(t, accessResp.Cookies(), browserSessionCookieName) + require.NoError(t, accessResp.Body.Close()) + + proxyReq, errProxyReq := http.NewRequest(http.MethodGet, front.URL+workspaceIDEProxyPath(workspaceID), http.NoBody) + require.NoError(t, errProxyReq) + proxyReq.AddCookie(browserSessionCookie) + proxyClient := &http.Client{Transport: nil, CheckRedirect: nil, Jar: nil, Timeout: 0} + proxyResp, errProxy := proxyClient.Do(proxyReq) + require.NoError(t, errProxy) + defer func() { + assert.NoError(t, proxyResp.Body.Close()) + }() + assert.Equal(t, http.StatusNoContent, proxyResp.StatusCode) + refreshedIDECookie := requireCookieNamed(t, proxyResp.Cookies(), ideAccessCookieName) + assert.NotEmpty(t, refreshedIDECookie.Value) + assert.Equal(t, ideAccessCookiePath(), refreshedIDECookie.Path) +} + +func TestIDEProxyForwardsHTTPRequests(t *testing.T) { + var gotPath string + var gotQuery string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + req := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID)+"static/app.js?theme=dark", http.NoBody) + req.AddCookie(browserSessionCookie) + req.AddCookie(ideCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "/static/app.js", gotPath) + assert.Equal(t, "theme=dark", gotQuery) +} + +func TestIDEProxyDoesNotForwardSgaiAuthCookies(t *testing.T) { + var gotCookieHeader string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookieHeader = r.Header.Get("Cookie") + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-cookie-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + req := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID)+"static/app.js", http.NoBody) + req.AddCookie(browserSessionCookie) + req.AddCookie(ideCookie) + req.AddCookie(newTestCookie("theme", "dark")) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) + assert.Contains(t, gotCookieHeader, "theme=dark") + assert.NotContains(t, gotCookieHeader, browserSessionCookieName+"=") + assert.NotContains(t, gotCookieHeader, ideAccessCookieName+"=") +} + +func TestIDEProxySupportsWebSocketUpgrade(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Connection"), "Upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "upgrade required", http.StatusBadRequest) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Error("response writer does not support hijacking") + http.Error(w, "hijack unavailable", http.StatusInternalServerError) + return + } + conn, writer, errHijack := hijacker.Hijack() + if errHijack != nil { + t.Errorf("hijack failed: %v", errHijack) + return + } + defer func() { + assert.NoError(t, conn.Close()) + }() + _, errWrite := writer.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + if errWrite != nil { + t.Errorf("write failed: %v", errWrite) + return + } + if errFlush := writer.Flush(); errFlush != nil { + t.Errorf("flush failed: %v", errFlush) + } + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-ws-upgrade") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + front := httptest.NewServer(newIDEProxyHandler(srv)) + defer front.Close() + + parsedFrontURL, errParse := url.Parse(front.URL) + require.NoError(t, errParse) + conn, errDial := net.Dial("tcp", parsedFrontURL.Host) + require.NoError(t, errDial) + t.Cleanup(func() { + assert.NoError(t, conn.Close()) + }) + + _, errWrite := fmt.Fprintf(conn, "GET %ssocket HTTP/1.1\r\nHost: %s\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nCookie: %s=%s; %s=%s\r\n\r\n", workspaceIDEProxyPath(workspaceID), parsedFrontURL.Host, browserSessionCookie.Name, browserSessionCookie.Value, ideCookie.Name, ideCookie.Value) + require.NoError(t, errWrite) + + line, errRead := bufio.NewReader(conn).ReadString('\n') + require.NoError(t, errRead) + assert.Contains(t, line, "101 Switching Protocols") +} + +func TestClientDisconnectedIDEProxyRequestDoesNotMarkSessionFailed(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, errWrite := w.Write([]byte("console.log('ready')")) + if errWrite != nil { + t.Errorf("write failed: %v", errWrite) + } + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-cancel-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + ctx, cancel := context.WithCancel(context.Background()) + proxyResp := newWriteErrorResponseWriter(net.ErrClosed, cancel) + proxyReq := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID)+"static/app.js", http.NoBody).WithContext(ctx) + proxyReq.AddCookie(browserSessionCookie) + proxyReq.AddCookie(ideCookie) + newIDEProxyHandler(srv).ServeHTTP(proxyResp, proxyReq) + + statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/ide", http.NoBody) + statusReq.AddCookie(browserSessionCookie) + statusReq.AddCookie(ideCookie) + statusResp := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(statusResp, statusReq) + require.Equal(t, http.StatusOK, statusResp.Code) + + var resp apiIDEStatusResponse + require.NoError(t, json.Unmarshal(statusResp.Body.Bytes(), &resp)) + require.NotNil(t, resp.Session) + assert.True(t, resp.Running) + assert.Equal(t, "created", resp.LastEvent) + assert.Equal(t, "created", resp.Session.LastEvent) + assert.Empty(t, resp.LastError) +} + +func TestIDEProxyFailureMarksSessionFailedAndReturnsBadGateway(t *testing.T) { + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, errListen) + targetAddr := listener.Addr().String() + require.NoError(t, listener.Close()) + + targetURL, errParse := url.Parse("http://" + targetAddr) + require.NoError(t, errParse) + host, portText, errSplit := net.SplitHostPort(targetURL.Host) + require.NoError(t, errSplit) + port, errPort := net.LookupPort("tcp", portText) + require.NoError(t, errPort) + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-proxy-failed-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("target", host, port) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + + proxyReq := httptest.NewRequest(http.MethodGet, workspaceIDEProxyPath(workspaceID)+"static/app.js", http.NoBody) + proxyReq.AddCookie(browserSessionCookie) + proxyReq.AddCookie(ideCookie) + proxyResp := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(proxyResp, proxyReq) + require.Equal(t, http.StatusBadGateway, proxyResp.Code) + assert.Contains(t, proxyResp.Body.String(), "ide proxy failed") + + srv.mu.Lock() + sess := srv.ideSessions[wsDir] + srv.mu.Unlock() + require.NotNil(t, sess) + assert.Equal(t, "failed", sess.lastEvent) + assert.Contains(t, sess.lastError, "ide proxy failed") + + statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/ide", http.NoBody) + statusReq.AddCookie(browserSessionCookie) + statusReq.AddCookie(ideCookie) + statusResp := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(statusResp, statusReq) + require.Equal(t, http.StatusOK, statusResp.Code) + + var resp apiIDEStatusResponse + require.NoError(t, json.Unmarshal(statusResp.Body.Bytes(), &resp)) + require.NotNil(t, resp.Session) + assert.True(t, resp.Running) + assert.Equal(t, "failed", resp.LastEvent) + assert.Equal(t, "failed", resp.Session.LastEvent) +} + +func TestCleanupIdleIDESessionsStopsExpiredSession(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-cleanup-ws") + runtime := newFakeIDERuntime() + srv.ideRuntime = runtime + now := time.Date(2026, time.March, 31, 14, 0, 0, 0, time.UTC) + srv.ideIdleTimeout = time.Minute + srv.ideNow = func() time.Time { + return now + } + + srv.mu.Lock() + srv.ideSessions[wsDir] = &ideSession{ + workspacePath: wsDir, + workspaceName: "ide-cleanup-ws", + target: newIDERuntimeTarget("container-cleanup", "127.0.0.1", 12345), + createdAt: time.Time{}, + lastActivity: now.Add(-2 * time.Minute), + lastError: "", + lastEvent: "", + accessGrants: map[string]ideAccessGrant{ + "token": { + Token: "token", + BrowserSessionToken: "", + ExpiresAt: time.Time{}, + LastActivity: now.Add(-2 * time.Minute), + }, + }, + } + srv.mu.Unlock() + + srv.cleanupIdleIDESessions(now) + + assert.Equal(t, 1, runtime.stopCalls) + srv.mu.Lock() + _, ok := srv.ideSessions[wsDir] + srv.mu.Unlock() + assert.False(t, ok) +} + +func TestCleanupIdleIDESessionsKeepsSessionRefreshedDuringStop(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-cleanup-refresh-ws") + runtime := newFakeIDERuntime() + stopStarted := make(chan struct{}, 1) + allowStop := make(chan struct{}) + runtime.beforeStop = func() { + stopStarted <- struct{}{} + <-allowStop + } + srv.ideRuntime = runtime + now := time.Date(2026, time.March, 31, 14, 0, 0, 0, time.UTC) + srv.ideIdleTimeout = time.Minute + + srv.mu.Lock() + srv.ideSessions[wsDir] = &ideSession{ + workspacePath: wsDir, + workspaceName: "ide-cleanup-refresh-ws", + target: newIDERuntimeTarget("container-refresh", "127.0.0.1", 12345), + createdAt: time.Time{}, + lastActivity: now.Add(-2 * time.Minute), + lastError: "", + lastEvent: "", + accessGrants: map[string]ideAccessGrant{ + "token": { + Token: "token", + BrowserSessionToken: "", + ExpiresAt: time.Time{}, + LastActivity: now.Add(-2 * time.Minute), + }, + }, + } + srv.mu.Unlock() + + done := make(chan struct{}) + go func() { + srv.cleanupIdleIDESessions(now) + close(done) + }() + + <-stopStarted + srv.mu.Lock() + sess := srv.ideSessions[wsDir] + require.NotNil(t, sess) + sess.lastActivity = now + grant := sess.accessGrants["token"] + grant.LastActivity = now + sess.accessGrants["token"] = grant + srv.mu.Unlock() + close(allowStop) + <-done + + srv.mu.Lock() + refreshed := srv.ideSessions[wsDir] + srv.mu.Unlock() + require.NotNil(t, refreshed) + assert.Equal(t, "container-refresh", refreshed.target.ID) + assert.Equal(t, now, refreshed.lastActivity) +} + +func TestCleanupIdleIDESessionsRemovesExpiredBrowserSessions(t *testing.T) { + srv, _ := setupTestServer(t) + now := time.Date(2026, time.March, 31, 14, 0, 0, 0, time.UTC) + + srv.mu.Lock() + srv.browserSessions["expired"] = &browserSession{ + Token: "expired", + ExpiresAt: now.Add(-time.Minute), + LastSeen: now.Add(-2 * time.Minute), + } + srv.browserSessions["active"] = &browserSession{ + Token: "active", + ExpiresAt: now.Add(time.Minute), + LastSeen: now, + } + srv.mu.Unlock() + + srv.cleanupIdleIDESessions(now) + + srv.mu.Lock() + _, expiredOK := srv.browserSessions["expired"] + _, activeOK := srv.browserSessions["active"] + srv.mu.Unlock() + assert.False(t, expiredOK) + assert.True(t, activeOK) +} + +func TestHandleAPIIDEAccessSupportsDuplicateBasenameWorkspaceIDs(t *testing.T) { + srv, rootDir := setupTestServer(t) + firstDir := setupNestedTestWorkspace(t, srv, rootDir, "one", "shared-ws") + secondDir := setupNestedTestWorkspace(t, srv, rootDir, "two", "shared-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + browserSessionCookie := issueBrowserSessionCookie(t, srv) + + firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceRouteID(firstDir)+"/ide/access", http.NoBody) + firstReq.AddCookie(browserSessionCookie) + first := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(first, firstReq) + require.Equal(t, http.StatusOK, first.Code) + + secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceRouteID(secondDir)+"/ide/access", http.NoBody) + secondReq.AddCookie(browserSessionCookie) + second := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(second, secondReq) + require.Equal(t, http.StatusOK, second.Code) + + var firstResp apiIDEStatusResponse + var secondResp apiIDEStatusResponse + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstResp)) + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondResp)) + assert.Equal(t, workspaceIDEProxyPath(workspaceRouteID(firstDir)), firstResp.ProxyPath) + assert.Equal(t, workspaceIDEProxyPath(workspaceRouteID(secondDir)), secondResp.ProxyPath) + assert.NotEqual(t, firstResp.ProxyPath, secondResp.ProxyPath) + assert.Equal(t, 2, runtime.startCalls) +} + +func TestBuildDockerRunArgsKeepWritablePathsInsideWorkspace(t *testing.T) { + req := ideStartRequest{ + WorkspacePath: "/tmp/test-workspace", + ContainerName: "sgai-ide-test", + } + + args := buildDockerRunArgs(req) + + assert.True(t, slices.Contains(args, "--read-only")) + assert.True(t, slices.Contains(args, "--tmpfs")) + assert.Contains(t, args, "/var/run") + assert.True(t, slices.Contains(args, "--env")) + assert.Contains(t, args, "HOME=/workspace/.sgai/code-server/home") + assert.Contains(t, args, "TMPDIR=/workspace/.sgai/code-server/tmp") + assert.Contains(t, args, "/workspace/.sgai/code-server/user-data") + assert.Contains(t, args, "/workspace/.sgai/code-server/extensions") + assert.NotContains(t, args, "--base-path") + assert.NotContains(t, args, "/tmp/sgai-code-server-data") + assert.NotContains(t, args, "/tmp/sgai-code-server-extensions") +} + +func TestIsIDEProxyRouteSkipsWorkspaceDetailIDERoute(t *testing.T) { + assert.False(t, isIDEProxyRoute("/workspaces/sgai-pure-navy-ii76/ide")) + assert.True(t, isIDEProxyRoute("/workspaces/workspace-id/ide-proxy/")) +} + +func TestHandleAPIIDEStatusRequiresIDEAuthorization(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-status-auth-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + + w := serveHTTP(srv, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/ide", "") + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestHandleAPIIDEStatusReturnsMetadataForAuthorizedRequest(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-status-authorized-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = newIDERuntimeTarget("container-1", "127.0.0.1", 12345) + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + front := httptest.NewServer(newIDEProxyHandler(srv)) + defer front.Close() + jar, errJar := cookiejar.New(nil) + require.NoError(t, errJar) + client := &http.Client{Transport: nil, CheckRedirect: nil, Jar: jar, Timeout: 0} + + stateResp, errState := client.Get(front.URL + "/api/v1/state") + require.NoError(t, errState) + require.NoError(t, stateResp.Body.Close()) + + accessReq, errAccessReq := http.NewRequest(http.MethodPost, front.URL+"/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + require.NoError(t, errAccessReq) + accessResp, errAccess := client.Do(accessReq) + require.NoError(t, errAccess) + require.Equal(t, http.StatusOK, accessResp.StatusCode) + require.NoError(t, accessResp.Body.Close()) + + statusResp, errStatus := client.Get(front.URL + "/api/v1/workspaces/" + workspaceID + "/ide") + require.NoError(t, errStatus) + defer func() { + assert.NoError(t, statusResp.Body.Close()) + }() + assert.Equal(t, http.StatusOK, statusResp.StatusCode) + + var resp apiIDEStatusResponse + require.NoError(t, json.NewDecoder(statusResp.Body).Decode(&resp)) + assert.True(t, resp.Available) + assert.True(t, resp.Running) + assert.NotNil(t, resp.Session) + assert.Equal(t, "container-1", resp.Session.ID) +} + +func TestAuthorizedIDERequestsRefreshIDEAccessCookie(t *testing.T) { + tests := []struct { + name string + requestPath func(workspaceID string) string + wantStatus int + }{ + { + name: "status route", + requestPath: func(workspaceID string) string { + return "/api/v1/workspaces/" + workspaceID + "/ide" + }, + wantStatus: http.StatusOK, + }, + { + name: "proxy route", + requestPath: func(workspaceID string) string { + return workspaceIDEProxyPath(workspaceID) + "static/app.js" + }, + wantStatus: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, strings.ReplaceAll(tt.name, " ", "-")) + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startTarget = targetFromHTTPServer(t, backend.URL) + srv.ideRuntime = runtime + + now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + srv.ideNow = func() time.Time { + return now + } + + workspaceID := workspaceRouteID(wsDir) + browserSessionCookie, ideCookie := issueIDEAccessCookies(t, srv, workspaceID) + initialIDEExpiry := ideCookie.Expires + + now = now.Add(12 * time.Hour) + request := httptest.NewRequest(http.MethodGet, tt.requestPath(workspaceID), http.NoBody) + request.AddCookie(browserSessionCookie) + request.AddCookie(ideCookie) + response := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(response, request) + require.Equal(t, tt.wantStatus, response.Code) + + result := response.Result() + defer func() { + assert.NoError(t, result.Body.Close()) + }() + + refreshedBrowserSessionCookie := requireCookieNamed(t, result.Cookies(), browserSessionCookieName) + refreshedIDECookie := requireCookieNamed(t, result.Cookies(), ideAccessCookieName) + assert.Equal(t, ideCookie.Value, refreshedIDECookie.Value) + assert.Equal(t, ideAccessCookiePath(), refreshedIDECookie.Path) + assert.WithinDuration(t, now.Add(srv.ideAccessTTL), refreshedIDECookie.Expires, time.Second) + assert.True(t, refreshedIDECookie.Expires.After(initialIDEExpiry)) + + now = initialIDEExpiry.Add(time.Minute) + followUpRequest := httptest.NewRequest(http.MethodGet, tt.requestPath(workspaceID), http.NoBody) + followUpRequest.AddCookie(refreshedBrowserSessionCookie) + followUpRequest.AddCookie(refreshedIDECookie) + followUpResponse := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(followUpResponse, followUpRequest) + assert.Equal(t, tt.wantStatus, followUpResponse.Code) + }) + } +} + +func TestHandleAPIIDEAccessSurfacesStartFailures(t *testing.T) { + srv, rootDir := setupTestServer(t) + wsDir := setupTestWorkspace(t, srv, rootDir, "ide-failure-ws") + runtime := newFakeIDERuntime() + runtime.statusResult = newIDERuntimeStatus(true, "") + runtime.startErr = errors.New("docker run failed") + srv.ideRuntime = runtime + workspaceID := workspaceRouteID(wsDir) + + browserSessionCookie := issueBrowserSessionCookie(t, srv) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID+"/ide/access", http.NoBody) + req.AddCookie(browserSessionCookie) + w := httptest.NewRecorder() + newIDEProxyHandler(srv).ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "docker run failed") +} diff --git a/cmd/sgai/state_watcher.go b/cmd/sgai/state_watcher.go index d208b3c..72bfafa 100644 --- a/cmd/sgai/state_watcher.go +++ b/cmd/sgai/state_watcher.go @@ -28,9 +28,11 @@ func (s *Server) stateWatcherLoop(ctx context.Context) { for { select { case <-ctx.Done(): + s.stopAllIDESessions(context.Background()) return case <-ticker.C: s.pollWorkspaceStates(snapshots) + s.cleanupIdleIDESessions(s.ideNow()) } } } diff --git a/cmd/sgai/webapp/src/lib/api.ts b/cmd/sgai/webapp/src/lib/api.ts index bc856c8..e2d79ac 100644 --- a/cmd/sgai/webapp/src/lib/api.ts +++ b/cmd/sgai/webapp/src/lib/api.ts @@ -24,6 +24,7 @@ import type { ApiDetachWorkspaceResponse, ApiBrowseDirectoriesResponse, ApiRepositoryOperation, + ApiIDEStatusResponse, } from "../types"; class ApiError extends Error { @@ -170,6 +171,17 @@ export const api = { fetchJSON(`/api/v1/workspaces/${encodeURIComponent(name)}/reset`, { method: "POST", }), + ideStatus: (name: string) => + fetchJSON( + `/api/v1/workspaces/${encodeURIComponent(name)}/ide`, + ), + ideAccess: (workspaceOrPath: string) => + fetchJSON( + workspaceOrPath.startsWith("/") + ? workspaceOrPath + : `/api/v1/workspaces/${encodeURIComponent(workspaceOrPath)}/ide/access`, + { method: "POST" }, + ), }, browse: { diff --git a/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx b/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx index 2bc9dcf..409d791 100644 --- a/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx +++ b/cmd/sgai/webapp/src/pages/WorkspaceDetail.tsx @@ -25,7 +25,7 @@ import { canCreateForkFromWorkspace } from "@/lib/workspace-forks"; import { useWorkspacePageState } from "@/lib/workspace-page-state"; import { useAdhocRun } from "@/hooks/useAdhocRun"; import { ChevronRight, Square } from "lucide-react"; -import type { ApiWorkspaceEntry, ApiActionEntry } from "@/types"; +import type { ApiWorkspaceEntry, ApiActionEntry, ApiWorkspaceIDEState } from "@/types"; import { cn } from "@/lib/utils"; import { buildWorkspaceGoalEditPath, @@ -39,6 +39,7 @@ const LogTab = lazy(() => import("./tabs/LogTab").then((m) => ({ default: m.LogT const RunTab = lazy(() => import("./tabs/RunTab").then((m) => ({ default: m.RunTab }))); const EventsTab = lazy(() => import("./tabs/EventsTab").then((m) => ({ default: m.EventsTab }))); const ForksTab = lazy(() => import("./tabs/ForksTab").then((m) => ({ default: m.ForksTab }))); +const IDETab = lazy(() => import("./tabs/IDETab").then((m) => ({ default: m.IDETab }))); function parseExecTime(value: string | undefined | null): number | null { @@ -83,6 +84,7 @@ function WorkspaceDetailSkeleton() { const TABS = [ { key: "progress", label: "Progress" }, { key: "fork", label: "Fork" }, + { key: "ide", label: "IDE" }, { key: "log", label: "Log" }, { key: "messages", label: "Messages" }, { key: "internals", label: "Internals" }, @@ -92,6 +94,7 @@ const TABS = [ const ROOT_TABS = [ { key: "forks", label: "Forks" }, { key: "fork", label: "Fork" }, + { key: "ide", label: "IDE" }, ] as const; const DEFAULT_TAB = TABS[0].key; @@ -125,12 +128,20 @@ interface TabNavProps { isRoot: boolean; hasForks: boolean; showForkTab: boolean; + ideAvailable: boolean; } -function TabNav({ workspace, activeTab, isRoot, hasForks, showForkTab }: TabNavProps) { +function TabNav({ workspace, activeTab, isRoot, hasForks, showForkTab, ideAvailable }: TabNavProps) { const tabs = isRoot && hasForks - ? ROOT_TABS - : TABS.filter((tab) => showForkTab || tab.key !== "fork"); + ? ROOT_TABS.filter((tab) => { + if (tab.key === "ide" && !ideAvailable) return false; + return true; + }) + : TABS.filter((tab) => { + if (tab.key === "fork" && !showForkTab) return false; + if (tab.key === "ide" && !ideAvailable) return false; + return true; + }); return (