diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 4746fb8291..2cb4f3aeff 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -64,6 +64,7 @@ func provideGateway( if lifecycleMgr != nil { gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc) gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(log)) + gateway.SetVscodeProxy(lifecycleMgr) } orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log) @@ -89,6 +90,9 @@ func provideGateway( passthroughHandlers := agenthandlers.NewPassthroughHandlers(lifecycleMgr, log) passthroughHandlers.RegisterHandlers(gateway.Dispatcher) + + vscodeHandlers := agenthandlers.NewVscodeHandlers(lifecycleMgr, gateway.VscodeProxyHandler, log) + vscodeHandlers.RegisterHandlers(gateway.Dispatcher) } go gateway.Hub.Run(ctx) diff --git a/apps/backend/dockerfiles/Dockerfile b/apps/backend/dockerfiles/Dockerfile index a3d09cd5a3..c6553de0a1 100644 --- a/apps/backend/dockerfiles/Dockerfile +++ b/apps/backend/dockerfiles/Dockerfile @@ -22,6 +22,9 @@ RUN npm install -g @augmentcode/auggie # Install Codex CLI from npm RUN npm install -g @openai/codex +# Install code-server for VS Code web access (on-demand, started via agentctl) +RUN curl -fsSL https://code-server.dev/install.sh | sh + # Copy pre-built agentctl binary (run `make build-agentctl` first) COPY bin/agentctl /usr/local/bin/agentctl RUN chmod +x /usr/local/bin/agentctl diff --git a/apps/backend/internal/agent/handlers/vscode_handlers.go b/apps/backend/internal/agent/handlers/vscode_handlers.go new file mode 100644 index 0000000000..920d617737 --- /dev/null +++ b/apps/backend/internal/agent/handlers/vscode_handlers.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// ProxyInvalidator can invalidate a cached VS Code reverse proxy. +type ProxyInvalidator interface { + InvalidateProxy(sessionID string) +} + +// VscodeHandlers provides WebSocket handlers for VS Code server operations. +type VscodeHandlers struct { + lifecycleMgr *lifecycle.Manager + proxyInvalidator ProxyInvalidator + logger *logger.Logger +} + +// NewVscodeHandlers creates a new VscodeHandlers instance. +func NewVscodeHandlers(lifecycleMgr *lifecycle.Manager, proxyInvalidator ProxyInvalidator, log *logger.Logger) *VscodeHandlers { + return &VscodeHandlers{ + lifecycleMgr: lifecycleMgr, + proxyInvalidator: proxyInvalidator, + logger: log.WithFields(zap.String("component", "vscode_handlers")), + } +} + +// RegisterHandlers registers VS Code handlers with the WebSocket dispatcher. +func (h *VscodeHandlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionVscodeStart, h.wsVscodeStart) + d.RegisterFunc(ws.ActionVscodeStop, h.wsVscodeStop) + d.RegisterFunc(ws.ActionVscodeStatus, h.wsVscodeStatus) + d.RegisterFunc(ws.ActionVscodeOpenFile, h.wsVscodeOpenFile) +} + +// VscodeStartRequest for vscode.start action. +type VscodeStartRequest struct { + SessionID string `json:"session_id"` + Theme string `json:"theme"` +} + +// VscodeStopRequest for vscode.stop action. +type VscodeStopRequest struct { + SessionID string `json:"session_id"` +} + +// VscodeStatusRequest for vscode.status action. +type VscodeStatusRequest struct { + SessionID string `json:"session_id"` +} + +// VscodeOpenFileRequest for vscode.openFile action. +type VscodeOpenFileRequest struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + Line int `json:"line,omitempty"` + Col int `json:"col,omitempty"` +} + +func (h *VscodeHandlers) wsVscodeStart(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req VscodeStartRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found or no active execution", nil) + } + + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agentctl client not available", nil) + } + + // Start is non-blocking — returns immediately with initial status. + // Port is allocated by agentctl using OS-assigned random port. + resp, err := client.StartVscode(ctx, req.Theme) + if err != nil { + h.logger.Error("failed to start vscode", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + fmt.Sprintf("failed to start VS Code: %v", err), nil) + } + + h.logger.Info("vscode start initiated", + zap.String("session_id", req.SessionID), + zap.Int("port", resp.Port), + zap.String("status", resp.Status)) + + return ws.NewResponse(msg.ID, msg.Action, types.VscodeStatusResponse{ + Status: resp.Status, + Port: resp.Port, + URL: fmt.Sprintf("/vscode/%s/", req.SessionID), + }) +} + +func (h *VscodeHandlers) wsVscodeStop(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req VscodeStopRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found or no active execution", nil) + } + + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agentctl client not available", nil) + } + + if err := client.StopVscode(ctx); err != nil { + h.logger.Error("failed to stop vscode", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + fmt.Sprintf("failed to stop VS Code: %v", err), nil) + } + + // Invalidate the cached reverse proxy so stale entries don't persist. + if h.proxyInvalidator != nil { + h.proxyInvalidator.InvalidateProxy(req.SessionID) + } + + h.logger.Info("vscode stopped", zap.String("session_id", req.SessionID)) + + return ws.NewResponse(msg.ID, msg.Action, types.VscodeStatusResponse{ + Status: "stopped", + }) +} + +func (h *VscodeHandlers) wsVscodeStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req VscodeStatusRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return ws.NewResponse(msg.ID, msg.Action, types.VscodeStatusResponse{Status: "stopped"}) + } + + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewResponse(msg.ID, msg.Action, types.VscodeStatusResponse{Status: "stopped"}) + } + + status, err := client.VscodeStatus(ctx) + if err != nil { + return ws.NewResponse(msg.ID, msg.Action, types.VscodeStatusResponse{Status: "stopped"}) + } + + resp := types.VscodeStatusResponse{ + Status: status.Status, + Port: status.Port, + URL: status.URL, + Error: status.Error, + Message: status.Message, + } + if status.Status == "running" { + resp.URL = fmt.Sprintf("/vscode/%s/", req.SessionID) + } + + return ws.NewResponse(msg.ID, msg.Action, resp) +} + +func (h *VscodeHandlers) wsVscodeOpenFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req VscodeOpenFileRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.Path == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) + } + + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) + if !ok { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "session not found or no active execution", nil) + } + + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agentctl client not available", nil) + } + + if err := client.VscodeOpenFile(ctx, req.Path, req.Line, req.Col); err != nil { + h.logger.Error("failed to open file in vscode", + zap.Error(err), + zap.String("session_id", req.SessionID), + zap.String("path", req.Path)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + fmt.Sprintf("failed to open file in VS Code: %v", err), nil) + } + + h.logger.Info("opened file in vscode", + zap.String("session_id", req.SessionID), + zap.String("path", req.Path), + zap.Int("line", req.Line), + zap.Int("col", req.Col)) + + return ws.NewResponse(msg.ID, msg.Action, types.VscodeOpenFileResponse{Success: true}) +} diff --git a/apps/backend/internal/agent/handlers/vscode_handlers_test.go b/apps/backend/internal/agent/handlers/vscode_handlers_test.go new file mode 100644 index 0000000000..c354e1c38c --- /dev/null +++ b/apps/backend/internal/agent/handlers/vscode_handlers_test.go @@ -0,0 +1,325 @@ +package handlers + +import ( + "context" + "encoding/json" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// mockProxyInvalidator tracks InvalidateProxy calls. +type mockProxyInvalidator struct { + invalidated []string +} + +func (m *mockProxyInvalidator) InvalidateProxy(sessionID string) { + m.invalidated = append(m.invalidated, sessionID) +} + +func TestNewVscodeHandlers(t *testing.T) { + log := newTestLogger() + proxy := &mockProxyInvalidator{} + h := NewVscodeHandlers(nil, proxy, log) + + if h == nil { + t.Fatal("expected non-nil handlers") + } + if h.proxyInvalidator == nil { + t.Error("expected proxyInvalidator to be set") + } +} + +func TestRegisterVscodeHandlers(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + dispatcher := ws.NewDispatcher() + h.RegisterHandlers(dispatcher) + + actions := []string{ + ws.ActionVscodeStart, + ws.ActionVscodeStop, + ws.ActionVscodeStatus, + ws.ActionVscodeOpenFile, + } + for _, action := range actions { + if !dispatcher.HasHandler(action) { + t.Errorf("expected handler for %s to be registered", action) + } + } +} + +func TestWsVscodeStart_InvalidPayload(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionVscodeStart, + Payload: json.RawMessage(`{invalid json`), + } + + resp, err := h.wsVscodeStart(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeStart_MissingSessionID(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStart, VscodeStartRequest{SessionID: "", Theme: "dark"}) + + resp, err := h.wsVscodeStart(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeValidation { + t.Errorf("expected VALIDATION_ERROR, got %q", errPayload.Code) + } +} + +func TestWsVscodeStart_NoExecution(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + h := NewVscodeHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStart, VscodeStartRequest{SessionID: "nonexistent", Theme: "dark"}) + + resp, err := h.wsVscodeStart(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeNotFound { + t.Errorf("expected NOT_FOUND, got %q", errPayload.Code) + } +} + +func TestWsVscodeStop_InvalidPayload(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionVscodeStop, + Payload: json.RawMessage(`{invalid json`), + } + + resp, err := h.wsVscodeStop(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeStop_MissingSessionID(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStop, VscodeStopRequest{SessionID: ""}) + + resp, err := h.wsVscodeStop(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeValidation { + t.Errorf("expected VALIDATION_ERROR, got %q", errPayload.Code) + } +} + +func TestWsVscodeStop_NoExecution(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + h := NewVscodeHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStop, VscodeStopRequest{SessionID: "nonexistent"}) + + resp, err := h.wsVscodeStop(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeStatus_InvalidPayload(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionVscodeStatus, + Payload: json.RawMessage(`{invalid json`), + } + + resp, err := h.wsVscodeStatus(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeStatus_MissingSessionID(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStatus, VscodeStatusRequest{SessionID: ""}) + + resp, err := h.wsVscodeStatus(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeStatus_NoExecution_ReturnsStopped(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + h := NewVscodeHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeStatus, VscodeStatusRequest{SessionID: "nonexistent"}) + + resp, err := h.wsVscodeStatus(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Status handler returns "stopped" when no execution found (not an error) + if resp.Type != ws.MessageTypeResponse { + t.Errorf("expected response type, got %q", resp.Type) + } + var payload map[string]any + if err := resp.ParsePayload(&payload); err != nil { + t.Fatalf("failed to parse payload: %v", err) + } + if payload["status"] != "stopped" { + t.Errorf("expected status=stopped, got %v", payload["status"]) + } +} + +func TestWsVscodeOpenFile_InvalidPayload(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg := &ws.Message{ + ID: "test-1", + Action: ws.ActionVscodeOpenFile, + Payload: json.RawMessage(`{invalid json`), + } + + resp, err := h.wsVscodeOpenFile(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } +} + +func TestWsVscodeOpenFile_MissingSessionID(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeOpenFile, VscodeOpenFileRequest{ + SessionID: "", + Path: "main.go", + }) + + resp, err := h.wsVscodeOpenFile(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeValidation { + t.Errorf("expected VALIDATION_ERROR, got %q", errPayload.Code) + } +} + +func TestWsVscodeOpenFile_MissingPath(t *testing.T) { + log := newTestLogger() + h := NewVscodeHandlers(nil, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeOpenFile, VscodeOpenFileRequest{ + SessionID: "session-1", + Path: "", + }) + + resp, err := h.wsVscodeOpenFile(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeValidation { + t.Errorf("expected VALIDATION_ERROR, got %q", errPayload.Code) + } +} + +func TestWsVscodeOpenFile_NoExecution(t *testing.T) { + log := newTestLogger() + mgr := newTestManager() + h := NewVscodeHandlers(mgr, nil, log) + + msg, _ := ws.NewRequest("test-1", ws.ActionVscodeOpenFile, VscodeOpenFileRequest{ + SessionID: "nonexistent", + Path: "main.go", + }) + + resp, err := h.wsVscodeOpenFile(context.Background(), msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Type != ws.MessageTypeError { + t.Errorf("expected error type, got %q", resp.Type) + } + var errPayload ws.ErrorPayload + if err := resp.ParsePayload(&errPayload); err != nil { + t.Fatalf("failed to parse error: %v", err) + } + if errPayload.Code != ws.ErrorCodeNotFound { + t.Errorf("expected NOT_FOUND, got %q", errPayload.Code) + } +} diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index f0271ed370..3972db22b0 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "sync" "time" @@ -351,11 +352,160 @@ func (c *Client) WaitForReady(ctx context.Context, timeout time.Duration) error } } +// Re-export VS Code types from shared types package. +type ( + VscodeStartResponse = types.VscodeStartResponse + VscodeStatusResponse = types.VscodeStatusResponse + VscodeStopResponse = types.VscodeStopResponse +) + +// StartVscode starts the code-server with the given theme. +// The port is allocated by agentctl using an OS-assigned random port. +func (c *Client) StartVscode(ctx context.Context, theme string) (*VscodeStartResponse, error) { + payload := struct { + Theme string `json:"theme,omitempty"` + }{Theme: theme} + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/start", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("vscode start failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result VscodeStartResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse vscode start response: %w", err) + } + return &result, nil +} + +// StopVscode stops the code-server. +func (c *Client) StopVscode(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/stop", nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := readResponseBody(resp) + return fmt.Errorf("vscode stop failed with status %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// VscodeOpenFile opens a file in the running VS Code instance via agentctl. +func (c *Client) VscodeOpenFile(ctx context.Context, path string, line, col int) error { + payload := types.VscodeOpenFileRequest{ + Path: path, + Line: line, + Col: col, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/open-file", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("vscode open-file failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result types.VscodeOpenFileResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("failed to parse vscode open-file response: %w", err) + } + if !result.Success { + return fmt.Errorf("vscode open-file failed: %s", result.Error) + } + return nil +} + +// VscodeStatus returns the current code-server state. +func (c *Client) VscodeStatus(ctx context.Context) (*VscodeStatusResponse, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/vscode/status", nil) + if err != nil { + return nil, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("vscode status failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result VscodeStatusResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse vscode status response: %w", err) + } + return &result, nil +} + // BaseURL returns the base URL of the agentctl client func (c *Client) BaseURL() string { return c.baseURL } +// Host returns the host portion (without port) of the agentctl client URL. +func (c *Client) Host() string { + parsed, err := url.Parse(c.baseURL) + if err != nil { + return c.baseURL + } + return parsed.Hostname() +} + // Re-export types from types package for convenience. // These types are defined in the streams subpackage and re-exported through types. type ( diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 60ec421c47..4bae8c0863 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -100,6 +100,12 @@ func (s *Server) setupRoutes() { api.GET("/processes", s.handleListProcesses) api.GET("/processes/:id", s.handleGetProcess) + // VS Code server management + api.POST("/vscode/start", s.handleVscodeStart) + api.POST("/vscode/stop", s.handleVscodeStop) + api.GET("/vscode/status", s.handleVscodeStatus) + api.POST("/vscode/open-file", s.handleVscodeOpenFile) + // Git operations api.POST("/git/pull", s.handleGitPull) api.POST("/git/push", s.handleGitPush) diff --git a/apps/backend/internal/agentctl/server/api/vscode_handlers.go b/apps/backend/internal/agentctl/server/api/vscode_handlers.go new file mode 100644 index 0000000000..a8fa06d816 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/vscode_handlers.go @@ -0,0 +1,87 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +// VscodeStartRequest is the request body for starting code-server. +type VscodeStartRequest struct { + Theme string `json:"theme"` // "dark" or "light" +} + +func (s *Server) handleVscodeStart(c *gin.Context) { + var req VscodeStartRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, types.VscodeStartResponse{ + Error: "invalid request: " + err.Error(), + }) + return + } + + // Start is non-blocking — it launches in a background goroutine. + // Port is allocated automatically using an OS-assigned random port. + s.procMgr.StartVscode(c.Request.Context(), req.Theme) + s.logger.Info("code-server start initiated", zap.String("theme", req.Theme)) + + // Return current status (will be "installing") + info := s.procMgr.VscodeInfo() + c.JSON(http.StatusOK, types.VscodeStartResponse{ + Success: true, + Status: string(info.Status), + Port: info.Port, + }) +} + +func (s *Server) handleVscodeStop(c *gin.Context) { + if err := s.procMgr.StopVscode(c.Request.Context()); err != nil { + s.logger.Error("failed to stop code-server", zap.Error(err)) + c.JSON(http.StatusInternalServerError, types.VscodeStopResponse{ + Error: err.Error(), + }) + return + } + + s.logger.Info("code-server stopped") + c.JSON(http.StatusOK, types.VscodeStopResponse{Success: true}) +} + +func (s *Server) handleVscodeStatus(c *gin.Context) { + info := s.procMgr.VscodeInfo() + c.JSON(http.StatusOK, types.VscodeStatusResponse{ + Status: string(info.Status), + Port: info.Port, + Error: info.Error, + Message: info.Message, + }) +} + +func (s *Server) handleVscodeOpenFile(c *gin.Context) { + var req types.VscodeOpenFileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, types.VscodeOpenFileResponse{ + Error: "invalid request: " + err.Error(), + }) + return + } + + if req.Path == "" { + c.JSON(http.StatusBadRequest, types.VscodeOpenFileResponse{ + Error: "path is required", + }) + return + } + + if err := s.procMgr.VscodeOpenFile(c.Request.Context(), req.Path, req.Line, req.Col); err != nil { + s.logger.Error("failed to open file in vscode", zap.Error(err), zap.String("path", req.Path)) + c.JSON(http.StatusInternalServerError, types.VscodeOpenFileResponse{ + Error: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, types.VscodeOpenFileResponse{Success: true}) +} diff --git a/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go b/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go new file mode 100644 index 0000000000..ed9aabb85f --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go @@ -0,0 +1,170 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kandev/kandev/internal/agentctl/types" +) + +func TestHandleVscodeStart_Success(t *testing.T) { + s := newTestServer(t) + + body, _ := json.Marshal(VscodeStartRequest{Theme: "dark"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/start", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + + var resp types.VscodeStartResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if !resp.Success { + t.Errorf("expected success=true, got false (error: %s)", resp.Error) + } + // Status should be "installing" since start is non-blocking + if resp.Status != "installing" { + t.Errorf("expected status=installing, got %q", resp.Status) + } +} + +func TestHandleVscodeStart_InvalidBody(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/start", bytes.NewReader([]byte(`not json`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleVscodeStop_WhenNotRunning(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/stop", nil) + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + // StopVscode on a non-running instance returns nil (success) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var resp types.VscodeStopResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if !resp.Success { + t.Errorf("expected success=true, got false") + } +} + +func TestHandleVscodeStatus_Initial(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/vscode/status", nil) + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var resp types.VscodeStatusResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Status != "stopped" { + t.Errorf("expected status=stopped, got %q", resp.Status) + } +} + +func TestHandleVscodeOpenFile_MissingPath(t *testing.T) { + s := newTestServer(t) + + body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: ""}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/open-file", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + var resp types.VscodeOpenFileResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.Success { + t.Error("expected success=false for empty path") + } +} + +func TestHandleVscodeOpenFile_InvalidBody(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/open-file", bytes.NewReader([]byte(`not json`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleVscodeOpenFile_NotRunning(t *testing.T) { + s := newTestServer(t) + + body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: "main.go", Line: 10, Col: 5}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/open-file", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + // Code-server is not running, should get 500 + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +func TestHandleVscodeStatus_AfterStart(t *testing.T) { + s := newTestServer(t) + + // Start VS Code + startBody, _ := json.Marshal(VscodeStartRequest{Theme: "dark"}) + startReq := httptest.NewRequest(http.MethodPost, "/api/v1/vscode/start", bytes.NewReader(startBody)) + startReq.Header.Set("Content-Type", "application/json") + startW := httptest.NewRecorder() + s.router.ServeHTTP(startW, startReq) + + // Check status — should be "installing" (async start) + statusReq := httptest.NewRequest(http.MethodGet, "/api/v1/vscode/status", nil) + statusW := httptest.NewRecorder() + s.router.ServeHTTP(statusW, statusReq) + + var resp types.VscodeStatusResponse + if err := json.Unmarshal(statusW.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + // Status should be installing or error (since code-server binary won't exist in tests) + if resp.Status != "installing" && resp.Status != "error" && resp.Status != "starting" { + t.Errorf("expected installing/error/starting, got %q", resp.Status) + } +} diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index f4e5e47ea1..371d185921 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -38,6 +38,9 @@ type Config struct { LogLevel string LogFormat string McpLogFile string // Optional file path for MCP debug logs + + // VS Code server configuration + VscodeCommand string // Command to run code-server (default: "code-server") } // PortConfig defines port allocation for instances @@ -140,6 +143,9 @@ type InstanceConfig struct { // SessionID is the session ID for this agent instance (used in MCP tool calls) SessionID string + + // VscodeCommand is the command to run the VS Code server (e.g., "code-server") + VscodeCommand string } // Load loads the configuration from environment variables. @@ -160,9 +166,10 @@ func Load() *Config { ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024), }, ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true), - LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"), - LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"), - McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""), + LogLevel: getEnvWithFallback("AGENTCTL_LOG_LEVEL", "KANDEV_LOG_LEVEL", "info"), + LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"), + McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""), + VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"), } } @@ -180,6 +187,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst LogLevel: c.LogLevel, LogFormat: c.LogFormat, ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes, + VscodeCommand: c.VscodeCommand, } applyOverrides(cfg, overrides) diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 7149d2568a..ec398b1654 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -6,7 +6,9 @@ import ( "context" "fmt" "io" + "os" "os/exec" + "path/filepath" "regexp" "strings" "sync" @@ -18,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/shell" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" + tools "github.com/kandev/kandev/internal/tools/installer" "go.uber.org/zap" ) @@ -99,6 +102,10 @@ type Manager struct { pendingPermissions map[string]*PendingPermission permissionMu sync.RWMutex + // VS Code server manager (lazy-initialized on demand) + vscode *VscodeManager + vscodeMu sync.Mutex + // Git operator for git operations (lazy-initialized) gitOperator *GitOperator gitOperatorMu sync.Mutex @@ -550,8 +557,15 @@ func (m *Manager) Stop(ctx context.Context) error { return nil } -// stopShellAndProcesses stops the shell session, workspace processes, and workspace tracker. +// stopShellAndProcesses stops the shell session, VS Code, workspace processes, and workspace tracker. func (m *Manager) stopShellAndProcesses(ctx context.Context) { + // Stop VS Code server if running + m.logger.Debug("stopping vscode server") + if err := m.StopVscode(ctx); err != nil { + m.logger.Debug("failed to stop vscode server", zap.Error(err)) + } + m.logger.Debug("vscode server stopped") + m.logger.Debug("stopping shell session") if m.shell != nil { if err := m.shell.Stop(); err != nil { @@ -996,3 +1010,87 @@ func (m *Manager) StartShell() error { m.logger.Info("shell session started independently") return nil } + +// StartVscode starts the code-server process on a random OS-assigned port. +// The VS Code server runs independently of the agent process. +// Start is non-blocking — the caller should poll VscodeInfo() for status updates. +func (m *Manager) StartVscode(_ context.Context, theme string) { + m.vscodeMu.Lock() + defer m.vscodeMu.Unlock() + + if m.vscode != nil { + info := m.vscode.Info() + if info.Status == VscodeStatusRunning || info.Status == VscodeStatusStarting || info.Status == VscodeStatusInstalling { + return + } + } + + command := m.cfg.VscodeCommand + if command == "" { + command = "code-server" + } + + strategy := codeServerInstallStrategy(m.logger) + m.vscode = NewVscodeManager(command, m.cfg.WorkDir, theme, strategy, m.logger) + m.vscode.Start() +} + +// codeServerInstallStrategy returns a tarball strategy that auto-installs code-server. +func codeServerInstallStrategy(log *logger.Logger) tools.Strategy { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + installDir := filepath.Join(home, ".kandev", "tools", "code-server") + + return tools.NewGithubTarballStrategy(installDir, "code-server", tools.GithubTarballConfig{ + Owner: "coder", + Repo: "code-server", + Version: "4.96.4", + AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz", + BinaryPath: "code-server-{version}-{os}-{arch}/bin/code-server", + Targets: map[string]string{ + "darwin/arm64": "macos-arm64", + "darwin/amd64": "macos-amd64", + "linux/amd64": "linux-amd64", + "linux/arm64": "linux-arm64", + }, + }, log) +} + +// StopVscode stops the code-server process if running. +func (m *Manager) StopVscode(ctx context.Context) error { + m.vscodeMu.Lock() + defer m.vscodeMu.Unlock() + + if m.vscode == nil { + return nil + } + + err := m.vscode.Stop(ctx) + m.vscode = nil + return err +} + +// VscodeInfo returns the current VS Code server status. +func (m *Manager) VscodeInfo() VscodeInfo { + m.vscodeMu.Lock() + defer m.vscodeMu.Unlock() + + if m.vscode == nil { + return VscodeInfo{Status: VscodeStatusStopped} + } + return m.vscode.Info() +} + +// VscodeOpenFile opens a file in the running VS Code instance via the Remote CLI. +func (m *Manager) VscodeOpenFile(ctx context.Context, path string, line, col int) error { + m.vscodeMu.Lock() + vscode := m.vscode + m.vscodeMu.Unlock() + + if vscode == nil { + return fmt.Errorf("code-server is not running") + } + return vscode.OpenFile(ctx, path, line, col) +} diff --git a/apps/backend/internal/agentctl/server/process/vscode.go b/apps/backend/internal/agentctl/server/process/vscode.go new file mode 100644 index 0000000000..a1cf6d1ec7 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/vscode.go @@ -0,0 +1,519 @@ +// Package process - VscodeManager manages a code-server subprocess for VS Code web access. +package process + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + tools "github.com/kandev/kandev/internal/tools/installer" + "go.uber.org/zap" +) + +// VscodeStatus represents the code-server process status. +type VscodeStatus string + +const ( + VscodeStatusStopped VscodeStatus = "stopped" + VscodeStatusInstalling VscodeStatus = "installing" + VscodeStatusStarting VscodeStatus = "starting" + VscodeStatusRunning VscodeStatus = "running" + VscodeStatusError VscodeStatus = "error" +) + +// VscodeInfo holds the current state of the VS Code server. +type VscodeInfo struct { + Status VscodeStatus `json:"status"` + Port int `json:"port"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +// VscodeManager manages a code-server subprocess. +type VscodeManager struct { + command string // code-server binary name or path + workDir string + port int + theme string // "dark" or "light" + installStrategy tools.Strategy // optional: auto-installs code-server if not found + logger *logger.Logger + + cmd *exec.Cmd + resolvedPath string // resolved code-server binary path (set after startup) + status VscodeStatus + err string + message string + mu sync.Mutex + cancelStart context.CancelFunc // cancels startAsync goroutine + stopCh chan struct{} + doneCh chan struct{} + stopped bool // guards stopCh against double-close +} + +// NewVscodeManager creates a new VS Code process manager. +// The port is allocated via the OS when Start is called. +func NewVscodeManager( + command, workDir string, + theme string, + strategy tools.Strategy, + log *logger.Logger, +) *VscodeManager { + return &VscodeManager{ + command: command, + workDir: workDir, + theme: theme, + installStrategy: strategy, + logger: log.WithFields(zap.String("component", "vscode-manager")), + status: VscodeStatusStopped, + } +} + +// Start launches the code-server process asynchronously. +// It returns immediately after setting status to "installing" and spawns +// a background goroutine that resolves the binary, writes theme settings, +// and starts the process. +func (v *VscodeManager) Start() { + v.mu.Lock() + defer v.mu.Unlock() + + if v.status == VscodeStatusRunning || v.status == VscodeStatusStarting || v.status == VscodeStatusInstalling { + return + } + + v.setStatusLocked(VscodeStatusInstalling) + v.setMessageLocked("Preparing code-server...") + v.err = "" + v.stopped = false + + ctx, cancel := context.WithCancel(context.Background()) + v.cancelStart = cancel + + go v.startAsync(ctx) +} + +// startAsync runs the full startup sequence in a background goroutine. +func (v *VscodeManager) startAsync(ctx context.Context) { + // Write theme settings before starting + v.writeThemeSettings() + + // Resolve the binary, auto-installing if needed (the slow part) + v.setMessage("Installing code-server (this may take a moment)...") + resolvedPath, err := tools.ResolveBinary(ctx, v.command, nil, v.installStrategy, v.logger) + if err != nil { + v.setError(fmt.Sprintf("code-server binary not available: %s", err)) + return + } + v.mu.Lock() + v.resolvedPath = resolvedPath + v.mu.Unlock() + + // Allocate a random port via the OS to avoid collisions + // with other kandev instances or concurrent sessions. + port, err := allocatePort() + if err != nil { + v.setError(fmt.Sprintf("failed to allocate port: %s", err)) + return + } + v.mu.Lock() + v.port = port + v.mu.Unlock() + + v.setStatus(VscodeStatusStarting) + v.setMessage("Starting code-server...") + + if err := v.startProcess(ctx, resolvedPath); err != nil { + v.setError(fmt.Sprintf("code-server failed to start: %s", err)) + return + } + + v.setStatus(VscodeStatusRunning) + v.setMessage("") + v.logger.Info("code-server started", + zap.Int("port", v.port), + zap.Int("pid", v.cmd.Process.Pid)) +} + +// allocatePort finds a free port using the OS. +func allocatePort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("failed to allocate port: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + _ = listener.Close() + return port, nil +} + +// startProcess creates and starts the code-server subprocess. +func (v *VscodeManager) startProcess(ctx context.Context, binaryPath string) error { + bindAddr := fmt.Sprintf("0.0.0.0:%d", v.port) + args := []string{ + "--bind-addr", bindAddr, + "--auth", "none", + "--disable-telemetry", + "--disable-update-check", + "--user-data-dir", v.userDataDir(), + } + // Append workDir as positional argument so code-server opens the folder. + args = append(args, v.workDir) + + v.mu.Lock() + v.cmd = exec.Command(binaryPath, args...) + v.cmd.Dir = v.workDir + setProcGroup(v.cmd) + + stderr, err := v.cmd.StderrPipe() + if err != nil { + v.mu.Unlock() + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + if err := v.cmd.Start(); err != nil { + v.mu.Unlock() + return fmt.Errorf("failed to start code-server: %w", err) + } + + v.stopCh = make(chan struct{}) + v.doneCh = make(chan struct{}) + v.mu.Unlock() + + // Read stderr in background for logging + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + v.logger.Debug("code-server", zap.String("line", scanner.Text())) + } + }() + + // Wait for process exit in background + go func() { + defer close(v.doneCh) + waitErr := v.cmd.Wait() + + v.mu.Lock() + defer v.mu.Unlock() + + if waitErr != nil && v.status != VscodeStatusStopped { + v.status = VscodeStatusError + v.err = waitErr.Error() + v.logger.Error("code-server exited with error", zap.Error(waitErr)) + } else { + v.status = VscodeStatusStopped + v.logger.Info("code-server exited") + } + }() + + // Wait for code-server to become ready + if err := v.waitForReady(ctx); err != nil { + _ = v.Stop(ctx) + return fmt.Errorf("code-server failed to become ready: %w", err) + } + + return nil +} + +// waitForReady polls the code-server port until it accepts connections. +func (v *VscodeManager) waitForReady(ctx context.Context) error { + addr := fmt.Sprintf("127.0.0.1:%d", v.port) + deadline := time.After(30 * time.Second) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline: + return fmt.Errorf("timeout waiting for code-server on port %d", v.port) + case <-v.doneCh: + return fmt.Errorf("code-server exited before becoming ready") + default: + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(500 * time.Millisecond) + } + } +} + +// Stop stops the code-server process. +func (v *VscodeManager) Stop(ctx context.Context) error { + v.mu.Lock() + if v.status == VscodeStatusStopped { + v.mu.Unlock() + return nil + } + + v.logger.Info("stopping code-server") + v.status = VscodeStatusStopped + + // Cancel any in-progress startAsync goroutine (e.g. slow binary download). + if v.cancelStart != nil { + v.cancelStart() + v.cancelStart = nil + } + + if v.stopCh != nil && !v.stopped { + close(v.stopCh) + v.stopped = true + } + + cmd := v.cmd + doneCh := v.doneCh + v.mu.Unlock() + + if cmd != nil && cmd.Process != nil { + // Try graceful shutdown via process group kill + pid := cmd.Process.Pid + if err := killProcessGroup(pid); err != nil { + v.logger.Debug("failed to kill code-server process group, trying single process", zap.Error(err)) + if err := cmd.Process.Kill(); err != nil { + v.logger.Warn("failed to kill code-server process", zap.Error(err)) + } + } + } + + // Wait for exit with timeout + if doneCh != nil { + select { + case <-doneCh: + case <-time.After(5 * time.Second): + v.logger.Warn("code-server did not exit in time") + } + } + + v.logger.Info("code-server stopped") + return nil +} + +// Info returns the current VS Code server state. +func (v *VscodeManager) Info() VscodeInfo { + v.mu.Lock() + defer v.mu.Unlock() + return VscodeInfo{ + Status: v.status, + Port: v.port, + Error: v.err, + Message: v.message, + } +} + +// Port returns the port code-server is bound to. +func (v *VscodeManager) Port() int { + v.mu.Lock() + defer v.mu.Unlock() + return v.port +} + +// userDataDir returns the path for code-server user data. +func (v *VscodeManager) userDataDir() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".kandev", "tools", "code-server-data") +} + +// writeThemeSettings ensures VS Code settings.json contains our managed settings. +// Merges with any existing user settings, overwriting only the keys we manage. +func (v *VscodeManager) writeThemeSettings() { + settingsDir := filepath.Join(v.userDataDir(), "User") + settingsPath := filepath.Join(settingsDir, "settings.json") + + themeName := "Default Dark Modern" + if v.theme == "light" { + themeName = "Default Light Modern" + } + + managed := map[string]any{ + "workbench.colorTheme": themeName, + "security.workspace.trust.enabled": false, + "workbench.startupEditor": "none", + "workbench.enableExperiments": false, + "workbench.accounts.experimental.showEntitlements": false, + "settingsSync.enabled": false, + "telemetry.enabled": false, + "workbench.sideBar.location": "right", + "window.commandCenter": false, + "workbench.layoutControl.enabled": false, + "editor.minimap.autohide": true, + } + + // Read existing settings and merge our managed keys on top. + settings := make(map[string]any) + if existing, err := os.ReadFile(settingsPath); err == nil { + _ = json.Unmarshal(existing, &settings) + } + for k, v := range managed { + settings[k] = v + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + v.logger.Warn("failed to marshal theme settings", zap.Error(err)) + return + } + + if err := os.MkdirAll(settingsDir, 0o755); err != nil { + v.logger.Warn("failed to create settings dir", zap.Error(err)) + return + } + + if err := os.WriteFile(settingsPath, data, 0o644); err != nil { + v.logger.Warn("failed to write theme settings", zap.Error(err)) + } +} + +// OpenFile opens a file in the running VS Code instance via the Remote CLI. +// It uses the code-server bundled Remote CLI script and the VSCODE_IPC_HOOK_CLI +// environment variable to communicate with the running VS Code process. +func (v *VscodeManager) OpenFile(ctx context.Context, path string, line, col int) error { + v.mu.Lock() + if v.status != VscodeStatusRunning { + v.mu.Unlock() + return fmt.Errorf("code-server is not running") + } + resolvedPath := v.resolvedPath + workDir := v.workDir + v.mu.Unlock() + + if resolvedPath == "" { + return fmt.Errorf("code-server binary path not resolved") + } + + // Resolve the Remote CLI script relative to the code-server binary. + remoteCLI := resolveRemoteCLI(resolvedPath) + if _, err := os.Stat(remoteCLI); err != nil { + return fmt.Errorf("remote CLI not found at %s: %w", remoteCLI, err) + } + + // Find the IPC socket for the running VS Code instance. + ipcSocket, err := findVscodeIPCSocket() + if err != nil { + return fmt.Errorf("failed to find VS Code IPC socket: %w", err) + } + + // Build absolute path if relative. + absPath := path + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(workDir, absPath) + } + + // Build the --goto argument: :: + gotoArg := absPath + if line > 0 { + gotoArg = fmt.Sprintf("%s:%d", absPath, line) + if col > 0 { + gotoArg = fmt.Sprintf("%s:%d", gotoArg, col) + } + } + + cmd := exec.CommandContext(ctx, remoteCLI, "--goto", gotoArg) + cmd.Env = append(os.Environ(), "VSCODE_IPC_HOOK_CLI="+ipcSocket) + cmd.Dir = workDir + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("remote CLI failed: %w (output: %s)", err, strings.TrimSpace(string(output))) + } + + v.logger.Info("opened file in VS Code", + zap.String("path", absPath), + zap.Int("line", line), + zap.Int("col", col)) + return nil +} + +// resolveRemoteCLI returns the path to the VS Code Remote CLI script +// relative to the resolved code-server binary. +// Layout: /../lib/vscode/bin/remote-cli/code-{platform}.sh +func resolveRemoteCLI(binaryPath string) string { + binDir := filepath.Dir(binaryPath) // .../bin/ + baseDir := filepath.Dir(binDir) // .../code-server-x.y.z-os-arch/ + platform := runtime.GOOS // "darwin" or "linux" + return filepath.Join(baseDir, "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh") +} + +// findVscodeIPCSocket searches /tmp for the most recent vscode-ipc-*.sock file. +func findVscodeIPCSocket() (string, error) { + tmpDir := os.TempDir() + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "", fmt.Errorf("failed to read temp dir: %w", err) + } + + type sockEntry struct { + path string + modTime time.Time + } + var socks []sockEntry + + for _, e := range entries { + if !strings.HasPrefix(e.Name(), "vscode-ipc-") || !strings.HasSuffix(e.Name(), ".sock") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + socks = append(socks, sockEntry{ + path: filepath.Join(tmpDir, e.Name()), + modTime: info.ModTime(), + }) + } + + if len(socks) == 0 { + return "", fmt.Errorf("no vscode-ipc-*.sock found in %s", tmpDir) + } + + // Sort by modification time descending (most recent first). + sort.Slice(socks, func(i, j int) bool { + return socks[i].modTime.After(socks[j].modTime) + }) + + return socks[0].path, nil +} + +// setStatus updates the status under lock. +func (v *VscodeManager) setStatus(s VscodeStatus) { + v.mu.Lock() + defer v.mu.Unlock() + v.status = s +} + +// setStatusLocked updates the status (caller must hold v.mu). +func (v *VscodeManager) setStatusLocked(s VscodeStatus) { + v.status = s +} + +// setMessage updates the message under lock. +func (v *VscodeManager) setMessage(msg string) { + v.mu.Lock() + defer v.mu.Unlock() + v.message = msg +} + +// setMessageLocked updates the message (caller must hold v.mu). +func (v *VscodeManager) setMessageLocked(msg string) { + v.message = msg +} + +// setError sets error status with a message, under lock. +func (v *VscodeManager) setError(errMsg string) { + v.mu.Lock() + defer v.mu.Unlock() + v.status = VscodeStatusError + v.err = errMsg + v.message = "" +} diff --git a/apps/backend/internal/agentctl/server/process/vscode_test.go b/apps/backend/internal/agentctl/server/process/vscode_test.go new file mode 100644 index 0000000000..70999dce60 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/vscode_test.go @@ -0,0 +1,281 @@ +package process + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllocatePort(t *testing.T) { + port, err := allocatePort() + require.NoError(t, err) + assert.Greater(t, port, 0) + assert.Less(t, port, 65536) +} + +func TestAllocatePort_Unique(t *testing.T) { + ports := make(map[int]bool) + for i := 0; i < 5; i++ { + port, err := allocatePort() + require.NoError(t, err) + assert.False(t, ports[port], "port %d allocated twice", port) + ports[port] = true + } +} + +func TestResolveRemoteCLI(t *testing.T) { + platform := runtime.GOOS + + tests := []struct { + name string + binaryPath string + expected string + }{ + { + name: "standard layout", + binaryPath: "/opt/code-server-4.96.4-macos-arm64/bin/code-server", + expected: filepath.Join("/opt/code-server-4.96.4-macos-arm64", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), + }, + { + name: "usr local bin", + binaryPath: "/usr/local/bin/code-server", + expected: filepath.Join("/usr/local", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), + }, + { + name: "home dir install", + binaryPath: "/home/user/.kandev/tools/code-server/code-server-4.96.4-linux-amd64/bin/code-server", + expected: filepath.Join("/home/user/.kandev/tools/code-server/code-server-4.96.4-linux-amd64", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolveRemoteCLI(tt.binaryPath) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFindVscodeIPCSocket_NoSockets(t *testing.T) { + // Use a temp dir that has no vscode-ipc-*.sock files + origTmpDir := os.Getenv("TMPDIR") + tmpDir := t.TempDir() + t.Setenv("TMPDIR", tmpDir) + defer func() { + if origTmpDir != "" { + t.Setenv("TMPDIR", origTmpDir) + } + }() + + _, err := findVscodeIPCSocket() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no vscode-ipc-") +} + +func TestFindVscodeIPCSocket_ReturnsMostRecent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("TMPDIR", tmpDir) + + // Create two sock files with different mod times + sock1 := filepath.Join(tmpDir, "vscode-ipc-old.sock") + sock2 := filepath.Join(tmpDir, "vscode-ipc-new.sock") + + require.NoError(t, os.WriteFile(sock1, nil, 0o600)) + // Set old mod time + oldTime := time.Now().Add(-1 * time.Hour) + require.NoError(t, os.Chtimes(sock1, oldTime, oldTime)) + + require.NoError(t, os.WriteFile(sock2, nil, 0o600)) + + result, err := findVscodeIPCSocket() + require.NoError(t, err) + assert.Equal(t, sock2, result) +} + +func TestFindVscodeIPCSocket_IgnoresNonSockFiles(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("TMPDIR", tmpDir) + + // Create non-matching files + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "vscode-ipc-abc.txt"), nil, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "other-file.sock"), nil, 0o600)) + + _, err := findVscodeIPCSocket() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no vscode-ipc-") +} + +func TestWriteThemeSettings_DarkTheme(t *testing.T) { + tmpDir := t.TempDir() + log := newTestLogger(t) + + v := &VscodeManager{ + theme: "dark", + logger: log, + } + + // Override userDataDir by writing to a known location + settingsDir := filepath.Join(tmpDir, "User") + settingsPath := filepath.Join(settingsDir, "settings.json") + + // Manually call the theme logic (we'll test the settings merge behavior) + require.NoError(t, os.MkdirAll(settingsDir, 0o755)) + + // Write some pre-existing settings + existing := map[string]any{ + "editor.fontSize": 14, + "workbench.colorTheme": "Monokai", + "editor.minimap.autohide": false, + } + data, _ := json.MarshalIndent(existing, "", " ") + require.NoError(t, os.WriteFile(settingsPath, data, 0o644)) + + // Now call writeThemeSettings with the overridden dir + // We need to test via the actual method. Let's patch userDataDir. + // Since we can't easily override, we'll test the merge logic directly. + _ = v // VscodeManager used above for type reference + + // Read existing + settings := make(map[string]any) + raw, _ := os.ReadFile(settingsPath) + _ = json.Unmarshal(raw, &settings) + + // Apply managed keys + managed := map[string]any{ + "workbench.colorTheme": "Default Dark Modern", + "editor.minimap.autohide": true, + } + for k, val := range managed { + settings[k] = val + } + + // Verify merge: existing key preserved, managed keys overwritten + assert.Equal(t, float64(14), settings["editor.fontSize"]) + assert.Equal(t, "Default Dark Modern", settings["workbench.colorTheme"]) + assert.Equal(t, true, settings["editor.minimap.autohide"]) +} + +func TestVscodeManager_InitialState(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + info := v.Info() + assert.Equal(t, VscodeStatusStopped, info.Status) + assert.Equal(t, 0, info.Port) + assert.Empty(t, info.Error) + assert.Empty(t, info.Message) +} + +func TestVscodeManager_PortThreadSafe(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + // Port should be 0 initially and safe to call concurrently + assert.Equal(t, 0, v.Port()) +} + +func TestVscodeManager_StatusTransitions(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + // Initial state + assert.Equal(t, VscodeStatusStopped, v.Info().Status) + + // setStatus + v.setStatus(VscodeStatusStarting) + assert.Equal(t, VscodeStatusStarting, v.Info().Status) + + // setError + v.setError("something failed") + info := v.Info() + assert.Equal(t, VscodeStatusError, info.Status) + assert.Equal(t, "something failed", info.Error) + assert.Empty(t, info.Message) + + // setMessage + v.setMessage("doing stuff") + assert.Equal(t, "doing stuff", v.Info().Message) +} + +func TestVscodeManager_Stop_AlreadyStopped(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + // Stop on already-stopped manager should be a no-op + err := v.Stop(context.Background()) + assert.NoError(t, err) +} + +func TestVscodeManager_Stop_DoubleStop(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + // Simulate a running state with stopCh/doneCh + v.mu.Lock() + v.status = VscodeStatusRunning + v.stopCh = make(chan struct{}) + v.doneCh = make(chan struct{}) + v.mu.Unlock() + + // Close doneCh to simulate process exiting + close(v.doneCh) + + // First stop should succeed + err := v.Stop(context.Background()) + assert.NoError(t, err) + + // Second stop should be a no-op (not panic) + err = v.Stop(context.Background()) + assert.NoError(t, err) +} + +func TestVscodeManager_Start_Idempotent(t *testing.T) { + log := newTestLogger(t) + v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) + + // Manually set status to installing to simulate in-progress + v.mu.Lock() + v.status = VscodeStatusInstalling + v.mu.Unlock() + + // Start should be a no-op when already installing + v.Start() + assert.Equal(t, VscodeStatusInstalling, v.Info().Status) +} + +func TestVscodeInfo_JSON(t *testing.T) { + info := VscodeInfo{ + Status: VscodeStatusRunning, + Port: 8080, + Error: "", + Message: "ready", + } + + data, err := json.Marshal(info) + require.NoError(t, err) + + var decoded VscodeInfo + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, info, decoded) +} + +func TestVscodeInfo_JSON_OmitsEmpty(t *testing.T) { + info := VscodeInfo{ + Status: VscodeStatusStopped, + } + + data, err := json.Marshal(info) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + assert.NotContains(t, raw, "error") + assert.NotContains(t, raw, "message") +} diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index 2594f54e74..891c1fce09 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -318,3 +318,39 @@ type WorkspaceStreamSubscriber chan WorkspaceStreamMessage func timeNowUnixMilli() int64 { return time.Now().UnixMilli() } + +// VscodeStartResponse is returned after starting code-server. +type VscodeStartResponse struct { + Success bool `json:"success"` + Status string `json:"status,omitempty"` + Port int `json:"port,omitempty"` + Error string `json:"error,omitempty"` +} + +// VscodeStatusResponse returns the current code-server state. +type VscodeStatusResponse struct { + Status string `json:"status"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` +} + +// VscodeStopResponse is returned after stopping code-server. +type VscodeStopResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// VscodeOpenFileRequest is the request body for opening a file in code-server. +type VscodeOpenFileRequest struct { + Path string `json:"path"` // Relative or absolute file path + Line int `json:"line,omitempty"` // Line number (1-based, 0 means no line) + Col int `json:"col,omitempty"` // Column number (1-based, 0 means no column) +} + +// VscodeOpenFileResponse is returned after opening a file in code-server. +type VscodeOpenFileResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} diff --git a/apps/backend/internal/editors/discovery/editors.json b/apps/backend/internal/editors/discovery/editors.json index 7d1399dfe9..92f7be4909 100644 --- a/apps/backend/internal/editors/discovery/editors.json +++ b/apps/backend/internal/editors/discovery/editors.json @@ -1,5 +1,12 @@ { "editors": [ + { + "type": "vscode_web", + "name": "VS Code (Embedded)", + "command": "", + "scheme": "", + "enabled": true + }, { "type": "vscode", "name": "VS Code", diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index fc2ab4bcb1..1de4028e02 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -33,6 +33,7 @@ const ( editorKindCustomRemoteSSH = "custom_remote_ssh" editorKindCustomHostedURL = "custom_hosted_url" editorKindCustomCommand = "custom_command" + editorKindInternalVscode = "internal_vscode" ) type UserSettingsProvider interface { @@ -103,6 +104,8 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string func (s *Service) dispatchEditorKind(editor *models.Editor, worktreePath, absPath string, line, column int) (string, error) { switch editor.Kind { + case editorKindInternalVscode: + return buildInternalVscodeURL(worktreePath, absPath, line, column), nil case editorKindCustomRemoteSSH: return openRemoteSSHEditor(editor, absPath, line, column) case editorKindCustomHostedURL: @@ -122,6 +125,31 @@ func (s *Service) dispatchEditorKind(editor *models.Editor, worktreePath, absPat } } +// buildInternalVscodeURL returns a sentinel URL that the frontend intercepts +// to open the embedded code-server panel. Includes goto params when a specific +// file is requested. +func buildInternalVscodeURL(worktreePath, absPath string, line, column int) string { + if absPath == "" || absPath == worktreePath { + return "internal://vscode" + } + // Build goto param: relative/path:line:col + relPath := absPath + if worktreePath != "" { + rel, err := filepath.Rel(worktreePath, absPath) + if err == nil { + relPath = rel + } + } + goto_ := relPath + if line > 0 { + goto_ = fmt.Sprintf("%s:%d", goto_, line) + if column > 0 { + goto_ = fmt.Sprintf("%s:%d", goto_, column) + } + } + return fmt.Sprintf("internal://vscode?goto=%s", goto_) +} + func openRemoteSSHEditor(editor *models.Editor, absPath string, line, column int) (string, error) { cfg, err := parseRemoteSSHConfig(editor.Config) if err != nil { diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go index 275a6451a5..6f495f3073 100644 --- a/apps/backend/internal/editors/service/service_test.go +++ b/apps/backend/internal/editors/service/service_test.go @@ -465,6 +465,75 @@ func TestParseRemoteSSHConfig(t *testing.T) { } } +func TestBuildInternalVscodeURL(t *testing.T) { + tests := []struct { + name string + worktreePath string + absPath string + line int + column int + expected string + }{ + { + name: "empty path returns base URL", + worktreePath: "/workspace", + absPath: "", + line: 0, + column: 0, + expected: "internal://vscode", + }, + { + name: "path same as worktree returns base URL", + worktreePath: "/workspace", + absPath: "/workspace", + line: 0, + column: 0, + expected: "internal://vscode", + }, + { + name: "file path without line", + worktreePath: "/workspace", + absPath: "/workspace/src/main.go", + line: 0, + column: 0, + expected: "internal://vscode?goto=src/main.go", + }, + { + name: "file path with line", + worktreePath: "/workspace", + absPath: "/workspace/src/main.go", + line: 42, + column: 0, + expected: "internal://vscode?goto=src/main.go:42", + }, + { + name: "file path with line and column", + worktreePath: "/workspace", + absPath: "/workspace/src/main.go", + line: 42, + column: 10, + expected: "internal://vscode?goto=src/main.go:42:10", + }, + { + name: "empty worktree uses absolute path", + worktreePath: "", + absPath: "/some/file.go", + line: 5, + column: 0, + expected: "internal://vscode?goto=/some/file.go:5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildInternalVscodeURL(tt.worktreePath, tt.absPath, tt.line, tt.column) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + func TestParseHostedURLConfig(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/editors/store/sqlite.go b/apps/backend/internal/editors/store/sqlite.go index 9a0ebc4874..97d852556d 100644 --- a/apps/backend/internal/editors/store/sqlite.go +++ b/apps/backend/internal/editors/store/sqlite.go @@ -90,16 +90,24 @@ func (r *sqliteRepository) ensureDefaults(ctx context.Context) error { continue } typeSet[def.Type] = struct{}{} + + // Internal editors (no command) are always installed. + // External editors check PATH via exec.LookPath. + kind := "built_in" installed := false - if def.Command != "" { + if def.Command == "" { + kind = "internal_vscode" + installed = true + } else { _, lookupErr := exec.LookPath(def.Command) installed = lookupErr == nil } + defaults = append(defaults, &models.Editor{ ID: uuid.New().String(), Type: def.Type, Name: def.Name, - Kind: "built_in", + Kind: kind, Command: def.Command, Scheme: def.Scheme, Installed: installed, diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index aa41245d58..fc306c12bf 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -12,12 +12,13 @@ import ( // Gateway represents the unified WebSocket gateway type Gateway struct { - Hub *Hub - Dispatcher *ws.Dispatcher - Handler *Handler - TerminalHandler *TerminalHandler - LSPHandler *LSPHandler - logger *logger.Logger + Hub *Hub + Dispatcher *ws.Dispatcher + Handler *Handler + TerminalHandler *TerminalHandler + LSPHandler *LSPHandler + VscodeProxyHandler *VscodeProxyHandler + logger *logger.Logger } // NewGateway creates a new WebSocket gateway with all components initialized @@ -48,6 +49,11 @@ func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSP g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, installerRegistry, g.logger) } +// SetVscodeProxy enables the VS Code reverse proxy handler. +func (g *Gateway) SetVscodeProxy(lifecycleMgr *lifecycle.Manager) { + g.VscodeProxyHandler = NewVscodeProxyHandler(lifecycleMgr, g.logger) +} + // SetupRoutes adds the WebSocket routes to the Gin engine func (g *Gateway) SetupRoutes(router *gin.Engine) { router.GET("/ws", g.Handler.HandleConnection) @@ -61,4 +67,9 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) { if g.LSPHandler != nil { router.GET("/lsp/:sessionId", g.LSPHandler.HandleLSPConnection) } + + // Add VS Code reverse proxy routes if configured + if g.VscodeProxyHandler != nil { + router.Any("/vscode/:sessionId/*path", g.VscodeProxyHandler.HandleVscodeProxy) + } } diff --git a/apps/backend/internal/gateway/websocket/vscode_proxy.go b/apps/backend/internal/gateway/websocket/vscode_proxy.go new file mode 100644 index 0000000000..01a1a81c4b --- /dev/null +++ b/apps/backend/internal/gateway/websocket/vscode_proxy.go @@ -0,0 +1,175 @@ +package websocket + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/common/logger" +) + +// proxyEntry caches both the reverse proxy and its target URL so we can +// detect when the upstream has changed (e.g. VS Code restarted on a new port). +type proxyEntry struct { + proxy *httputil.ReverseProxy + target string // "host:port" +} + +// VscodeProxyHandler reverse-proxies HTTP and WebSocket traffic to code-server +// running inside an agentctl instance. +type VscodeProxyHandler struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger + + // Cache proxies per session to avoid re-creation + mu sync.Mutex + proxies map[string]*proxyEntry +} + +// NewVscodeProxyHandler creates a new VS Code proxy handler. +func NewVscodeProxyHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *VscodeProxyHandler { + return &VscodeProxyHandler{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "vscode-proxy")), + proxies: make(map[string]*proxyEntry), + } +} + +// HandleVscodeProxy handles all HTTP/WS requests to /vscode/:sessionId/*path. +// It resolves the session to find the agentctl host and VS Code port, +// then reverse-proxies the request to code-server. +func (h *VscodeProxyHandler) HandleVscodeProxy(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + return + } + + proxy, err := h.resolveProxy(c, sessionID) + if err != nil { + return // error already written to response + } + + // Strip the /vscode/:sessionId prefix from the request path + originalPath := c.Request.URL.Path + prefix := "/vscode/" + sessionID + newPath := strings.TrimPrefix(originalPath, prefix) + if newPath == "" { + newPath = "/" + } + c.Request.URL.Path = newPath + c.Request.URL.RawPath = "" + + // ReverseProxy panics with http.ErrAbortHandler when the client disconnects + // mid-stream (e.g. closing the VS Code panel). Recover silently to avoid + // scary stack traces in the logs from Gin's recovery middleware. + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + h.logger.Debug("vscode proxy: client disconnected", zap.String("session_id", sessionID)) + return + } + panic(r) // re-panic for unexpected errors + } + }() + + proxy.ServeHTTP(c.Writer, c.Request) +} + +// resolveProxy returns a cached proxy if the target hasn't changed, or creates +// a new one. Only performs the upstream status check when no cache exists or +// when the cached target is stale. +func (h *VscodeProxyHandler) resolveProxy(c *gin.Context, sessionID string) (*httputil.ReverseProxy, error) { + h.mu.Lock() + defer h.mu.Unlock() + + // Fast path: return cached proxy if available + if entry, ok := h.proxies[sessionID]; ok { + return entry.proxy, nil + } + + // Slow path: resolve upstream target + execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found or no active execution"}) + return nil, fmt.Errorf("session not found") + } + + agentctlClient := execution.GetAgentCtlClient() + if agentctlClient == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl client not available"}) + return nil, fmt.Errorf("agentctl client not available") + } + + vscodeStatus, err := agentctlClient.VscodeStatus(c.Request.Context()) + if err != nil { + h.logger.Error("failed to get vscode status", zap.Error(err), zap.String("session_id", sessionID)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to get vscode status"}) + return nil, err + } + + if vscodeStatus.Status != "running" || vscodeStatus.Port == 0 { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "code-server is not running"}) + return nil, fmt.Errorf("code-server not running") + } + + targetHost := agentctlClient.Host() + targetAddr := fmt.Sprintf("%s:%d", targetHost, vscodeStatus.Port) + target := &url.URL{ + Scheme: "http", + Host: targetAddr, + } + + proxy := h.createProxy(sessionID, target) + h.proxies[sessionID] = &proxyEntry{proxy: proxy, target: targetAddr} + return proxy, nil +} + +// createProxy builds a reverse proxy for the given target URL. +func (h *VscodeProxyHandler) createProxy(sessionID string, target *url.URL) *httputil.ReverseProxy { + proxy := httputil.NewSingleHostReverseProxy(target) + + // Allow WebSocket upgrades by preserving hop-by-hop headers + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + // Preserve WebSocket headers that SingleHostReverseProxy strips + if req.Header.Get("Upgrade") != "" { + req.Header.Set("Connection", "Upgrade") + } + } + + // Handle WebSocket connections + proxy.ModifyResponse = func(resp *http.Response) error { + // Remove hop-by-hop restriction for WebSocket upgrades + if resp.StatusCode == http.StatusSwitchingProtocols { + resp.Header.Set("Connection", "Upgrade") + } + return nil + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + h.logger.Error("vscode proxy error", + zap.String("session_id", sessionID), + zap.Error(err)) + // Remove cached proxy on error so it gets recreated + h.InvalidateProxy(sessionID) + http.Error(w, "VS Code proxy error", http.StatusBadGateway) + } + + return proxy +} + +// InvalidateProxy removes a cached proxy for a session (e.g., when VS Code stops). +func (h *VscodeProxyHandler) InvalidateProxy(sessionID string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.proxies, sessionID) +} diff --git a/apps/backend/internal/gateway/websocket/vscode_proxy_test.go b/apps/backend/internal/gateway/websocket/vscode_proxy_test.go new file mode 100644 index 0000000000..00383ec0d7 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/vscode_proxy_test.go @@ -0,0 +1,78 @@ +package websocket + +import ( + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestProxyLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + }) + return log +} + +func TestNewVscodeProxyHandler(t *testing.T) { + log := newTestProxyLogger() + h := NewVscodeProxyHandler(nil, log) + + if h == nil { + t.Fatal("expected non-nil handler") + } + if h.proxies == nil { + t.Error("expected proxies map to be initialized") + } + if h.lifecycleMgr != nil { + t.Error("expected nil lifecycleMgr when nil passed") + } +} + +func TestInvalidateProxy(t *testing.T) { + log := newTestProxyLogger() + h := NewVscodeProxyHandler(nil, log) + + // Manually insert a cache entry + h.mu.Lock() + h.proxies["session-1"] = &proxyEntry{proxy: nil, target: "127.0.0.1:8080"} + h.proxies["session-2"] = &proxyEntry{proxy: nil, target: "127.0.0.1:8081"} + h.mu.Unlock() + + // Invalidate one + h.InvalidateProxy("session-1") + + h.mu.Lock() + defer h.mu.Unlock() + if _, ok := h.proxies["session-1"]; ok { + t.Error("expected session-1 proxy to be removed") + } + if _, ok := h.proxies["session-2"]; !ok { + t.Error("expected session-2 proxy to still exist") + } +} + +func TestInvalidateProxy_Idempotent(t *testing.T) { + log := newTestProxyLogger() + h := NewVscodeProxyHandler(nil, log) + + // Invalidate a non-existent entry — should not panic + h.InvalidateProxy("nonexistent") + + h.mu.Lock() + defer h.mu.Unlock() + if len(h.proxies) != 0 { + t.Errorf("expected empty proxies map, got %d entries", len(h.proxies)) + } +} + +func TestProxyCacheEmpty_OnCreation(t *testing.T) { + log := newTestProxyLogger() + h := NewVscodeProxyHandler(nil, log) + + h.mu.Lock() + defer h.mu.Unlock() + if len(h.proxies) != 0 { + t.Errorf("expected empty proxies on creation, got %d", len(h.proxies)) + } +} diff --git a/apps/backend/internal/lsp/installer/installer.go b/apps/backend/internal/lsp/installer/installer.go index cf041e3260..c297dbe74a 100644 --- a/apps/backend/internal/lsp/installer/installer.go +++ b/apps/backend/internal/lsp/installer/installer.go @@ -1,16 +1,7 @@ package installer -import "context" +import tools "github.com/kandev/kandev/internal/tools/installer" -// InstallResult contains information about an installed language server binary. -type InstallResult struct { - BinaryPath string // absolute path to installed binary -} - -// Strategy is the abstraction for different install methods. -type Strategy interface { - // Install downloads/installs the language server. Blocks until done. - Install(ctx context.Context) (*InstallResult, error) - // Name returns a human-readable name for logging. - Name() string -} +// Re-export shared types for backward compatibility. +type Strategy = tools.Strategy +type InstallResult = tools.InstallResult diff --git a/apps/backend/internal/lsp/installer/registry.go b/apps/backend/internal/lsp/installer/registry.go index 1d99d38e8d..938389d99d 100644 --- a/apps/backend/internal/lsp/installer/registry.go +++ b/apps/backend/internal/lsp/installer/registry.go @@ -7,6 +7,7 @@ import ( "path/filepath" "github.com/kandev/kandev/internal/common/logger" + tools "github.com/kandev/kandev/internal/tools/installer" "go.uber.org/zap" ) @@ -60,30 +61,6 @@ func binaryName(language string) (string, error) { return cfg.binary, nil } -// findGoBinary looks for a Go binary in GOBIN, GOPATH/bin, and ~/go/bin. -func findGoBinary(binary string) (string, error) { - if gobin := os.Getenv("GOBIN"); gobin != "" { - p := filepath.Join(gobin, binary) - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - if gopath := os.Getenv("GOPATH"); gopath != "" { - p := filepath.Join(gopath, "bin", binary) - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - home, _ := os.UserHomeDir() - if home != "" { - p := filepath.Join(home, "go", "bin", binary) - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("%s not found in GOBIN/GOPATH/~/go/bin", binary) -} - // Registry maps language IDs to install strategies and resolves binary paths. type Registry struct { binDir string // resolved absolute path @@ -106,11 +83,11 @@ func NewRegistry(log *logger.Logger) *Registry { func (r *Registry) StrategyFor(language string) (Strategy, error) { switch language { case "typescript": - return NewNpmStrategy(r.binDir, "typescript-language-server", []string{"typescript-language-server", "typescript"}, r.logger), nil + return tools.NewNpmStrategy(r.binDir, "typescript-language-server", []string{"typescript-language-server", "typescript"}, r.logger), nil case "go": - return NewGoInstallStrategy("gopls", "golang.org/x/tools/gopls@latest", r.logger), nil + return tools.NewGoInstallStrategy("gopls", "golang.org/x/tools/gopls@latest", r.logger), nil case "rust": - return NewGithubReleaseStrategy(r.binDir, "rust-analyzer", GithubReleaseConfig{ + return tools.NewGithubReleaseStrategy(r.binDir, "rust-analyzer", tools.GithubReleaseConfig{ Owner: "rust-lang", Repo: "rust-analyzer", AssetPattern: "rust-analyzer-{target}.gz", @@ -122,7 +99,7 @@ func (r *Registry) StrategyFor(language string) (Strategy, error) { }, }, r.logger), nil case "python": - return NewNpmStrategy(r.binDir, "pyright-langserver", []string{"pyright"}, r.logger), nil + return tools.NewNpmStrategy(r.binDir, "pyright-langserver", []string{"pyright"}, r.logger), nil default: return nil, fmt.Errorf("no installer for language: %s", language) } @@ -155,7 +132,7 @@ func (r *Registry) BinaryPath(language string) (string, error) { // Check Go-specific paths for Go binaries if language == "go" { - if p, err := findGoBinary(binary); err == nil { + if p, err := tools.FindGoBinary(binary); err == nil { return p, nil } } diff --git a/apps/backend/internal/lsp/installer/registry_test.go b/apps/backend/internal/lsp/installer/registry_test.go index 0cbe63ba84..bffd7e282d 100644 --- a/apps/backend/internal/lsp/installer/registry_test.go +++ b/apps/backend/internal/lsp/installer/registry_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/kandev/kandev/internal/common/logger" + tools "github.com/kandev/kandev/internal/tools/installer" ) func testLogger() *logger.Logger { @@ -234,12 +235,12 @@ func TestFindGoBinary(t *testing.T) { t.Setenv("GOBIN", tmpDir) t.Setenv("GOPATH", "") - p, err := findGoBinary("gopls") + p, err := tools.FindGoBinary("gopls") if err != nil { - t.Errorf("findGoBinary(\"gopls\") error = %v", err) + t.Errorf("tools.FindGoBinary(\"gopls\") error = %v", err) } if p != fakeBinary { - t.Errorf("findGoBinary(\"gopls\") = %q, want %q", p, fakeBinary) + t.Errorf("tools.FindGoBinary(\"gopls\") = %q, want %q", p, fakeBinary) } } @@ -248,8 +249,8 @@ func TestFindGoBinary_NotFound(t *testing.T) { t.Setenv("GOPATH", t.TempDir()) t.Setenv("HOME", t.TempDir()) - _, err := findGoBinary("nonexistent-binary") + _, err := tools.FindGoBinary("nonexistent-binary") if err == nil { - t.Error("findGoBinary(\"nonexistent-binary\") should return error") + t.Error("tools.FindGoBinary(\"nonexistent-binary\") should return error") } } diff --git a/apps/backend/internal/lsp/installer/github_release_strategy.go b/apps/backend/internal/tools/installer/github_release_strategy.go similarity index 88% rename from apps/backend/internal/lsp/installer/github_release_strategy.go rename to apps/backend/internal/tools/installer/github_release_strategy.go index 0bb50a2367..dd31136663 100644 --- a/apps/backend/internal/lsp/installer/github_release_strategy.go +++ b/apps/backend/internal/tools/installer/github_release_strategy.go @@ -20,11 +20,10 @@ type GithubReleaseConfig struct { Owner string // e.g. "rust-lang" Repo string // e.g. "rust-analyzer" AssetPattern string // e.g. "rust-analyzer-{target}.gz" - Targets map[string]string // "darwin/arm64" → "aarch64-apple-darwin" + Targets map[string]string // "darwin/arm64" -> "aarch64-apple-darwin" } // GithubReleaseStrategy downloads prebuilt binaries from GitHub releases. -// Used for Rust (rust-analyzer). type GithubReleaseStrategy struct { binDir string binary string // "rust-analyzer" @@ -88,11 +87,11 @@ func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, er // Decompress .gz or write directly if strings.HasSuffix(asset, ".gz") { - if err := writeGzipBody(resp.Body, outPath); err != nil { + if err := WriteGzipBody(resp.Body, outPath); err != nil { return nil, err } } else { - if err := writeBody(resp.Body, outPath); err != nil { + if err := WriteBody(resp.Body, outPath); err != nil { return nil, err } } @@ -108,8 +107,8 @@ func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, er }, nil } -// writeGzipBody decompresses a gzip reader into outPath. -func writeGzipBody(body io.Reader, outPath string) error { +// WriteGzipBody decompresses a gzip reader into outPath. +func WriteGzipBody(body io.Reader, outPath string) error { gzReader, err := gzip.NewReader(body) if err != nil { return fmt.Errorf("failed to create gzip reader: %w", err) @@ -128,8 +127,8 @@ func writeGzipBody(body io.Reader, outPath string) error { return nil } -// writeBody writes a plain reader into outPath. -func writeBody(body io.Reader, outPath string) error { +// WriteBody writes a plain reader into outPath. +func WriteBody(body io.Reader, outPath string) error { outFile, err := os.Create(outPath) if err != nil { return fmt.Errorf("failed to create output file: %w", err) diff --git a/apps/backend/internal/tools/installer/github_tarball_strategy.go b/apps/backend/internal/tools/installer/github_tarball_strategy.go new file mode 100644 index 0000000000..966b42a2de --- /dev/null +++ b/apps/backend/internal/tools/installer/github_tarball_strategy.go @@ -0,0 +1,216 @@ +package installer + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// GithubTarballConfig configures a GitHub release tarball download. +type GithubTarballConfig struct { + Owner string // e.g. "coder" + Repo string // e.g. "code-server" + Version string // e.g. "4.96.4" + AssetPattern string // e.g. "code-server-{version}-{os}-{arch}.tar.gz" + BinaryPath string // relative path inside tarball, e.g. "code-server-{version}-{os}-{arch}/bin/code-server" + Targets map[string]string // "darwin/arm64" -> "macos-arm64", "linux/amd64" -> "linux-amd64" +} + +// GithubTarballStrategy downloads and extracts tar.gz archives from GitHub releases. +type GithubTarballStrategy struct { + installDir string // base directory for extracted files + binary string // binary name for logging + config GithubTarballConfig + logger *logger.Logger +} + +// NewGithubTarballStrategy creates a new GitHub tarball download strategy. +func NewGithubTarballStrategy(installDir, binary string, config GithubTarballConfig, log *logger.Logger) *GithubTarballStrategy { + return &GithubTarballStrategy{ + installDir: installDir, + binary: binary, + config: config, + logger: log, + } +} + +func (s *GithubTarballStrategy) Name() string { + return fmt.Sprintf("github tarball %s/%s v%s", s.config.Owner, s.config.Repo, s.config.Version) +} + +func (s *GithubTarballStrategy) Install(ctx context.Context) (*InstallResult, error) { + target, err := s.resolveTarget() + if err != nil { + return nil, err + } + + binaryPath := s.resolveBinaryPath(target) + + // Skip download if the binary already exists from a previous install. + if _, err := os.Stat(binaryPath); err == nil { + s.logger.Info("binary already installed, skipping download", zap.String("binary", binaryPath)) + return &InstallResult{BinaryPath: binaryPath}, nil + } + + url := s.buildURL(target) + s.logger.Info("downloading tarball from GitHub releases", + zap.String("url", url), + zap.String("target", target)) + + if err := s.download(ctx, url); err != nil { + return nil, err + } + + if _, err := os.Stat(binaryPath); err != nil { + return nil, fmt.Errorf("binary not found after extraction: %s", binaryPath) + } + + s.logger.Info("tarball install completed", zap.String("binary", binaryPath)) + return &InstallResult{BinaryPath: binaryPath}, nil +} + +func (s *GithubTarballStrategy) resolveTarget() (string, error) { + targetKey := runtime.GOOS + "/" + runtime.GOARCH + target, ok := s.config.Targets[targetKey] + if !ok { + return "", fmt.Errorf("unsupported platform: %s", targetKey) + } + return target, nil +} + +func (s *GithubTarballStrategy) buildURL(target string) string { + asset := s.expandTemplate(s.config.AssetPattern, target) + return fmt.Sprintf("https://github.com/%s/%s/releases/download/v%s/%s", + s.config.Owner, s.config.Repo, s.config.Version, asset) +} + +func (s *GithubTarballStrategy) resolveBinaryPath(target string) string { + relPath := s.expandTemplate(s.config.BinaryPath, target) + return filepath.Join(s.installDir, relPath) +} + +func (s *GithubTarballStrategy) expandTemplate(tmpl, target string) string { + r := strings.NewReplacer( + "{version}", s.config.Version, + "{os}-{arch}", target, + ) + return r.Replace(tmpl) +} + +func (s *GithubTarballStrategy) download(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d for %s", resp.StatusCode, url) + } + + if err := os.MkdirAll(s.installDir, 0o755); err != nil { + return fmt.Errorf("failed to create install directory %s: %w", s.installDir, err) + } + + return extractTarGz(resp.Body, s.installDir) +} + +// extractTarGz decompresses and extracts a tar.gz stream into destDir. +func extractTarGz(r io.Reader, destDir string) error { + gzReader, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer func() { _ = gzReader.Close() }() + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar read error: %w", err) + } + + if err := extractTarEntry(tarReader, header, destDir); err != nil { + return err + } + } + return nil +} + +func extractTarEntry(tr *tar.Reader, header *tar.Header, destDir string) error { + cleanName, err := sanitizeTarPath(header.Name, destDir) + if err != nil { + return err + } + + target := filepath.Join(destDir, cleanName) + + switch header.Typeflag { + case tar.TypeDir: + return os.MkdirAll(target, os.FileMode(header.Mode)) + case tar.TypeReg: + return writeFileFromTar(tr, target, os.FileMode(header.Mode)) + case tar.TypeSymlink: + // Validate symlink target doesn't escape destDir + linkTarget := filepath.Join(filepath.Dir(target), header.Linkname) + if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(destDir)) { + return fmt.Errorf("symlink %s -> %s escapes install directory", header.Name, header.Linkname) + } + // Remove existing symlink/file before creating (handles re-installs) + _ = os.Remove(target) + return os.Symlink(header.Linkname, target) + default: + // Skip unsupported types (block devices, char devices, etc.) + return nil + } +} + +func writeFileFromTar(tr *tar.Reader, target string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", target, err) + } + defer func() { _ = f.Close() }() + + // Limit copy size to prevent decompression bombs (1 GB) + const maxFileSize = 1 << 30 + if _, err := io.Copy(f, io.LimitReader(tr, maxFileSize)); err != nil { + return fmt.Errorf("failed to write file %s: %w", target, err) + } + return nil +} + +// sanitizeTarPath prevents path traversal attacks in tar archives. +func sanitizeTarPath(name, destDir string) (string, error) { + cleanName := filepath.Clean(name) + if strings.HasPrefix(cleanName, "..") || strings.HasPrefix(cleanName, "/") { + return "", fmt.Errorf("invalid tar entry path: %s", name) + } + absTarget := filepath.Join(destDir, cleanName) + if !strings.HasPrefix(absTarget, filepath.Clean(destDir)+string(os.PathSeparator)) && absTarget != filepath.Clean(destDir) { + return "", fmt.Errorf("tar entry %s would escape destination directory", name) + } + return cleanName, nil +} diff --git a/apps/backend/internal/tools/installer/github_tarball_strategy_test.go b/apps/backend/internal/tools/installer/github_tarball_strategy_test.go new file mode 100644 index 0000000000..adbabc2f0f --- /dev/null +++ b/apps/backend/internal/tools/installer/github_tarball_strategy_test.go @@ -0,0 +1,239 @@ +package installer + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func TestSanitizeTarPath(t *testing.T) { + destDir := "/install" + + tests := []struct { + name string + path string + wantErr bool + wantClean string + }{ + { + name: "simple path", + path: "code-server/bin/code-server", + wantErr: false, + wantClean: "code-server/bin/code-server", + }, + { + name: "nested path", + path: "code-server-4.96.4-macos-arm64/bin/code-server", + wantErr: false, + wantClean: "code-server-4.96.4-macos-arm64/bin/code-server", + }, + { + name: "path traversal with dotdot", + path: "../etc/passwd", + wantErr: true, + }, + { + name: "absolute path", + path: "/etc/passwd", + wantErr: true, + }, + { + name: "current directory", + path: ".", + wantErr: false, + wantClean: ".", + }, + { + name: "hidden traversal via dotdot in middle", + path: "foo/../../etc/passwd", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clean, err := sanitizeTarPath(tt.path, destDir) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for path %q, got nil", tt.path) + } + return + } + if err != nil { + t.Errorf("unexpected error for path %q: %v", tt.path, err) + return + } + if clean != tt.wantClean { + t.Errorf("expected clean path %q, got %q", tt.wantClean, clean) + } + }) + } +} + +func TestExpandTemplate(t *testing.T) { + s := &GithubTarballStrategy{ + config: GithubTarballConfig{ + Version: "4.96.4", + }, + } + + tests := []struct { + name string + tmpl string + target string + expected string + }{ + { + name: "asset pattern", + tmpl: "code-server-{version}-{os}-{arch}.tar.gz", + target: "macos-arm64", + expected: "code-server-4.96.4-macos-arm64.tar.gz", + }, + { + name: "binary path pattern", + tmpl: "code-server-{version}-{os}-{arch}/bin/code-server", + target: "linux-amd64", + expected: "code-server-4.96.4-linux-amd64/bin/code-server", + }, + { + name: "no placeholders", + tmpl: "binary", + target: "linux-amd64", + expected: "binary", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := s.expandTemplate(tt.tmpl, tt.target) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestExtractTarGz(t *testing.T) { + // Create a tar.gz archive in memory with a file and a directory + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + // Add a directory + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "myapp/", + Typeflag: tar.TypeDir, + Mode: 0o755, + }) + + // Add a file + content := []byte("#!/bin/sh\necho hello") + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "myapp/bin/hello", + Typeflag: tar.TypeReg, + Mode: 0o755, + Size: int64(len(content)), + }) + _, _ = tarWriter.Write(content) + + _ = tarWriter.Close() + _ = gzWriter.Close() + + // Extract into temp dir + destDir := t.TempDir() + if err := extractTarGz(&buf, destDir); err != nil { + t.Fatalf("extractTarGz failed: %v", err) + } + + // Verify the file was extracted + extractedPath := filepath.Join(destDir, "myapp", "bin", "hello") + data, err := os.ReadFile(extractedPath) + if err != nil { + t.Fatalf("failed to read extracted file: %v", err) + } + if string(data) != string(content) { + t.Errorf("expected %q, got %q", string(content), string(data)) + } +} + +func TestExtractTarGz_RejectsTraversal(t *testing.T) { + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + // Add a malicious entry + content := []byte("malicious") + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "../etc/passwd", + Typeflag: tar.TypeReg, + Mode: 0o644, + Size: int64(len(content)), + }) + _, _ = tarWriter.Write(content) + + _ = tarWriter.Close() + _ = gzWriter.Close() + + destDir := t.TempDir() + err := extractTarGz(&buf, destDir) + if err == nil { + t.Error("expected error for path traversal, got nil") + } +} + +func TestExtractTarGz_SymlinkEscapeBlocked(t *testing.T) { + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + // Add a symlink that escapes the dest dir + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "escape", + Typeflag: tar.TypeSymlink, + Linkname: "../../../../etc/passwd", + }) + + _ = tarWriter.Close() + _ = gzWriter.Close() + + destDir := t.TempDir() + err := extractTarGz(&buf, destDir) + if err == nil { + t.Error("expected error for symlink escape, got nil") + } +} + +func TestResolveTarget_Unsupported(t *testing.T) { + s := &GithubTarballStrategy{ + config: GithubTarballConfig{ + Targets: map[string]string{ + "linux/amd64": "linux-amd64", + }, + }, + } + // resolveTarget uses runtime.GOOS/GOARCH — if the test platform isn't in Targets, + // it should return an error. We can't guarantee this, so just verify the method works. + _, err := s.resolveTarget() + // The result depends on the test platform; just verify no panic. + _ = err +} + +func TestBuildURL(t *testing.T) { + s := &GithubTarballStrategy{ + config: GithubTarballConfig{ + Owner: "coder", + Repo: "code-server", + Version: "4.96.4", + AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz", + }, + } + + url := s.buildURL("macos-arm64") + expected := "https://github.com/coder/code-server/releases/download/v4.96.4/code-server-4.96.4-macos-arm64.tar.gz" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } +} diff --git a/apps/backend/internal/lsp/installer/go_install_strategy.go b/apps/backend/internal/tools/installer/go_install_strategy.go similarity index 63% rename from apps/backend/internal/lsp/installer/go_install_strategy.go rename to apps/backend/internal/tools/installer/go_install_strategy.go index cf0bf853f1..8a6def2b51 100644 --- a/apps/backend/internal/lsp/installer/go_install_strategy.go +++ b/apps/backend/internal/tools/installer/go_install_strategy.go @@ -3,14 +3,15 @@ package installer import ( "context" "fmt" + "os" "os/exec" + "path/filepath" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) -// GoInstallStrategy installs language servers via `go install`. -// Used for Go (gopls). +// GoInstallStrategy installs tools via `go install`. type GoInstallStrategy struct { binary string // "gopls" importPath string // "golang.org/x/tools/gopls@latest" @@ -45,7 +46,7 @@ func (s *GoInstallStrategy) Install(ctx context.Context) (*InstallResult, error) } // Find the installed binary using the shared Go binary lookup - binaryPath, err := findGoBinary(s.binary) + binaryPath, err := FindGoBinary(s.binary) if err != nil { return nil, err } @@ -55,3 +56,27 @@ func (s *GoInstallStrategy) Install(ctx context.Context) (*InstallResult, error) BinaryPath: binaryPath, }, nil } + +// FindGoBinary looks for a Go binary in GOBIN, GOPATH/bin, and ~/go/bin. +func FindGoBinary(binary string) (string, error) { + if gobin := os.Getenv("GOBIN"); gobin != "" { + p := filepath.Join(gobin, binary) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + if gopath := os.Getenv("GOPATH"); gopath != "" { + p := filepath.Join(gopath, "bin", binary) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + home, _ := os.UserHomeDir() + if home != "" { + p := filepath.Join(home, "go", "bin", binary) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("%s not found in GOBIN/GOPATH/~/go/bin", binary) +} diff --git a/apps/backend/internal/lsp/installer/npm_strategy.go b/apps/backend/internal/tools/installer/npm_strategy.go similarity index 91% rename from apps/backend/internal/lsp/installer/npm_strategy.go rename to apps/backend/internal/tools/installer/npm_strategy.go index 8b93366a04..8901b1aa8b 100644 --- a/apps/backend/internal/lsp/installer/npm_strategy.go +++ b/apps/backend/internal/tools/installer/npm_strategy.go @@ -12,10 +12,9 @@ import ( "go.uber.org/zap" ) -// NpmStrategy installs language servers via npm. -// Used for TypeScript (typescript-language-server) and Python (pyright). +// NpmStrategy installs tools via npm. type NpmStrategy struct { - binDir string // ~/.kandev/lsp-servers + binDir string // install prefix directory binary string // e.g. "typescript-language-server" packages []string // e.g. ["typescript-language-server", "typescript"] logger *logger.Logger diff --git a/apps/backend/internal/tools/installer/resolve.go b/apps/backend/internal/tools/installer/resolve.go new file mode 100644 index 0000000000..15a59211fb --- /dev/null +++ b/apps/backend/internal/tools/installer/resolve.go @@ -0,0 +1,51 @@ +package installer + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// ResolveBinary checks if a binary exists in PATH or searchPaths. +// If not found and a strategy is provided, installs it automatically. +// Returns the resolved absolute path to the binary. +func ResolveBinary(ctx context.Context, binary string, searchPaths []string, strategy Strategy, log *logger.Logger) (string, error) { + // Check system PATH first + if p, err := exec.LookPath(binary); err == nil { + log.Debug("binary found in PATH", zap.String("binary", binary), zap.String("path", p)) + return p, nil + } + + // Check each search path + for _, dir := range searchPaths { + p := dir + if info, err := os.Stat(p); err == nil && !info.IsDir() { + log.Debug("binary found in search path", zap.String("binary", binary), zap.String("path", p)) + return p, nil + } + } + + // Not found — try auto-install if strategy is provided + if strategy == nil { + return "", fmt.Errorf("%s not found in PATH or search paths", binary) + } + + log.Info("binary not found, installing via strategy", + zap.String("binary", binary), + zap.String("strategy", strategy.Name())) + + result, err := strategy.Install(ctx) + if err != nil { + return "", fmt.Errorf("auto-install of %s failed: %w", binary, err) + } + + log.Info("binary installed successfully", + zap.String("binary", binary), + zap.String("path", result.BinaryPath)) + + return result.BinaryPath, nil +} diff --git a/apps/backend/internal/tools/installer/resolve_test.go b/apps/backend/internal/tools/installer/resolve_test.go new file mode 100644 index 0000000000..924e4712e7 --- /dev/null +++ b/apps/backend/internal/tools/installer/resolve_test.go @@ -0,0 +1,88 @@ +package installer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func testLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "console", + }) + return log +} + +func TestResolveBinary_FoundInPath(t *testing.T) { + // "ls" should always be in PATH on any test system + path, err := ResolveBinary(context.Background(), "ls", nil, nil, testLogger()) + if err != nil { + t.Fatalf("expected ls to be found in PATH: %v", err) + } + if path == "" { + t.Error("expected non-empty path") + } +} + +func TestResolveBinary_NotFoundNoStrategy(t *testing.T) { + _, err := ResolveBinary(context.Background(), "nonexistent-binary-xyz-12345", nil, nil, testLogger()) + if err == nil { + t.Error("expected error for nonexistent binary with no strategy") + } +} + +func TestResolveBinary_FoundInSearchPaths(t *testing.T) { + // Create a fake binary in a temp dir + tmpDir := t.TempDir() + fakeBinary := filepath.Join(tmpDir, "my-tool") + if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh"), 0o755); err != nil { + t.Fatalf("failed to create fake binary: %v", err) + } + + path, err := ResolveBinary(context.Background(), "my-tool", []string{fakeBinary}, nil, testLogger()) + if err != nil { + t.Fatalf("expected to find binary in search paths: %v", err) + } + if path != fakeBinary { + t.Errorf("expected %s, got %s", fakeBinary, path) + } +} + +type mockStrategy struct { + binaryPath string + err error + called bool +} + +func (m *mockStrategy) Name() string { return "mock" } +func (m *mockStrategy) Install(_ context.Context) (*InstallResult, error) { + m.called = true + if m.err != nil { + return nil, m.err + } + return &InstallResult{BinaryPath: m.binaryPath}, nil +} + +func TestResolveBinary_FallsBackToStrategy(t *testing.T) { + tmpDir := t.TempDir() + fakeBinary := filepath.Join(tmpDir, "installed-tool") + if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh"), 0o755); err != nil { + t.Fatalf("failed to create fake binary: %v", err) + } + + strategy := &mockStrategy{binaryPath: fakeBinary} + path, err := ResolveBinary(context.Background(), "nonexistent-binary-xyz-12345", nil, strategy, testLogger()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strategy.called { + t.Error("expected strategy to be called") + } + if path != fakeBinary { + t.Errorf("expected %s, got %s", fakeBinary, path) + } +} diff --git a/apps/backend/internal/tools/installer/strategy.go b/apps/backend/internal/tools/installer/strategy.go new file mode 100644 index 0000000000..4b533932b2 --- /dev/null +++ b/apps/backend/internal/tools/installer/strategy.go @@ -0,0 +1,16 @@ +package installer + +import "context" + +// InstallResult contains information about an installed tool binary. +type InstallResult struct { + BinaryPath string // absolute path to installed binary +} + +// Strategy is the abstraction for different install methods. +type Strategy interface { + // Install downloads/installs the tool binary. Blocks until done. + Install(ctx context.Context) (*InstallResult, error) + // Name returns a human-readable name for logging. + Name() string +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 94652b1422..040191408d 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -230,6 +230,12 @@ const ( ActionUserSettingsUpdate = "user.settings.update" ActionUserSettingsUpdated = "user.settings.updated" + // VS Code server actions + ActionVscodeStart = "vscode.start" // Start code-server for a session + ActionVscodeStop = "vscode.stop" // Stop code-server for a session + ActionVscodeStatus = "vscode.status" // Get code-server status for a session + ActionVscodeOpenFile = "vscode.openFile" // Open a file in code-server for a session + // MCP tool actions (agentctl -> backend via WS tunnel) ActionMCPListWorkspaces = "mcp.list_workspaces" ActionMCPListWorkflows = "mcp.list_workflows" diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 46b4bbab8f..5cf4c9027c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -101,10 +101,10 @@ } .dark { - /* Grey scale: #141414 bg → #1c1c1c card → #2a2a2a borders → #333 hover */ - --background: #141414; + /* Grey scale: #181818 bg → #1F1F1F card → #2a2a2a borders → #333 hover */ + --background: #181818; --foreground: #d4d4d4; - --card: #1c1c1c; + --card: #1F1F1F; --card-foreground: #d4d4d4; --popover: #222222; --popover-foreground: #d4d4d4; diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 3aa6fba250..058dc145e9 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -384,7 +384,7 @@ function CustomEditorsList({ ); } -type ExternalEditorsSectionProps = { +type EditorsSectionProps = { defaultOptions: ComboboxOption[]; defaultEditorId: string; availableEditors: EditorOption[]; @@ -400,7 +400,7 @@ type ExternalEditorsSectionProps = { deleteRequest: DeleteReq; }; -function ExternalEditorsSection({ +function EditorsSection({ defaultOptions, defaultEditorId, availableEditors, @@ -414,11 +414,11 @@ function ExternalEditorsSection({ createRequest, updateRequest, deleteRequest, -}: ExternalEditorsSectionProps) { +}: EditorsSectionProps) { return (
- External Editors + Editors
Default
@@ -569,7 +569,7 @@ export function EditorsSettings() { />
- > = files: FilesPanelWrapper, terminal: TerminalPanel, browser: BrowserPanel, + vscode: VscodePanel, plan: PlanPanelComponent, // Backwards compat aliases for saved layouts "diff-files": ChangesPanelWrapper, diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 2169934303..a0dd1c9fe8 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -14,6 +14,7 @@ import { IconLayoutColumns, IconLayoutRows, IconX, + IconBrandVscode, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -60,6 +61,7 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { }); const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel); + const addVscodePanel = useDockviewStore((s) => s.addVscodePanel); const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel); const addPlanPanel = useDockviewStore((s) => s.addPlanPanel); const addFilesPanel = useDockviewStore((s) => s.addFilesPanel); @@ -104,6 +106,13 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { Browser + addVscodePanel()} + className="cursor-pointer text-xs" + > + + VS Code + {!isPassthrough && ( addPlanPanel(group.id)} diff --git a/apps/web/components/task/layout-preset-selector.tsx b/apps/web/components/task/layout-preset-selector.tsx index 28f033e5ac..ca1ae4e4eb 100644 --- a/apps/web/components/task/layout-preset-selector.tsx +++ b/apps/web/components/task/layout-preset-selector.tsx @@ -8,6 +8,7 @@ import { IconColumns3, IconFileText, IconDeviceDesktop, + IconBrandVscode, IconTrash, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -64,6 +65,12 @@ const BUILT_IN_PRESETS: PresetOption[] = [ description: "Sidebar + Agent + Browser (side-by-side)", icon: IconDeviceDesktop, }, + { + id: "vscode", + label: "VS Code", + description: "Sidebar + Agent/VS Code (tabbed)", + icon: IconBrandVscode, + }, ]; function SaveLayoutDialog({ diff --git a/apps/web/components/task/panel-primitives.tsx b/apps/web/components/task/panel-primitives.tsx index cdd7b2bc1c..d373f879c7 100644 --- a/apps/web/components/task/panel-primitives.tsx +++ b/apps/web/components/task/panel-primitives.tsx @@ -103,3 +103,17 @@ export function PanelHeaderBarSplit({ left, right, className }: PanelHeaderBarSp ); } + +/** Fixed-height panel footer bar with border-t. Mirrors PanelHeaderBar but anchors to the bottom. */ +export function PanelFooterBar({ children, className }: PanelHeaderBarProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/components/task/vscode-panel.tsx b/apps/web/components/task/vscode-panel.tsx new file mode 100644 index 0000000000..9915bbdb8a --- /dev/null +++ b/apps/web/components/task/vscode-panel.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { memo, useState, useCallback, useEffect, useRef } from "react"; +import type { IDockviewPanelProps } from "dockview-react"; +import { + IconRefresh, + IconExternalLink, + IconPlayerStop, + IconBrandVscode, + IconLoader2, + IconAlertCircle, +} from "@tabler/icons-react"; +import { useTheme } from "next-themes"; +import { Button } from "@kandev/ui/button"; +import { PanelRoot, PanelBody, PanelFooterBar } from "./panel-primitives"; +import { useAppStore } from "@/components/state-provider"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { + startVscode, + stopVscode, + getVscodeStatus, + type VscodeStatus, +} from "@/lib/api/domains/vscode-api"; +import { getBackendConfig } from "@/lib/config"; + +function VscodeProgress({ + status, + message, + error, + onRetry, +}: { + status: VscodeStatus["status"]; + message?: string; + error?: string; + onRetry: () => void; +}) { + if (status === "error") { + return ( +
+ +

Failed to start VS Code

+ {error &&

{error}

} + +
+ ); + } + + return ( +
+ +

+ {status === "installing" ? "Installing VS Code Server" : "Starting VS Code Server"} +

+ {message &&

{message}

} +
+ ); +} + +function VscodeIframe({ url }: { url: string }) { + const [loaded, setLoaded] = useState(false); + + const handleLoad = useCallback(() => { + setTimeout(() => setLoaded(true), 300); + }, []); + + return ( +
+ {!loaded && ( +
+ +
+ )} +