From 3a95a439904c6a0052f0843a7262bfb722cef98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 24 Feb 2026 23:01:41 +0000 Subject: [PATCH] fix: vscode (#279) --- .../agentctl/server/api/vscode_proxy.go | 4 +- .../agentctl/server/api/vscode_proxy_test.go | 153 ++++++++++++++++++ .../server/process/testing_helpers.go | 12 ++ apps/cli/src/dev.ts | 3 +- apps/cli/src/start.ts | 16 +- .../task/dockview-desktop-layout.tsx | 6 + apps/web/components/task/editors-menu.tsx | 2 +- apps/web/components/task/vscode-panel.tsx | 144 ++--------------- 8 files changed, 200 insertions(+), 140 deletions(-) create mode 100644 apps/backend/internal/agentctl/server/api/vscode_proxy_test.go create mode 100644 apps/backend/internal/agentctl/server/process/testing_helpers.go diff --git a/apps/backend/internal/agentctl/server/api/vscode_proxy.go b/apps/backend/internal/agentctl/server/api/vscode_proxy.go index 22a03152ef..9bab1dfbe7 100644 --- a/apps/backend/internal/agentctl/server/api/vscode_proxy.go +++ b/apps/backend/internal/agentctl/server/api/vscode_proxy.go @@ -33,7 +33,7 @@ func (s *Server) handleVscodeProxy(c *gin.Context) { return } - proxy := httputil.NewSingleHostReverseProxy(target) + proxy := &httputil.ReverseProxy{} proxy.Rewrite = func(r *httputil.ProxyRequest) { r.SetURL(target) // Strip /api/v1/vscode/proxy prefix and forward remaining path. @@ -46,6 +46,8 @@ func (s *Server) handleVscodeProxy(c *gin.Context) { zap.String("forwarded_path", path)) r.Out.URL.Path = path r.Out.URL.RawPath = "" + // Rewrite Origin to match code-server's host so ensureOrigin check passes. + r.Out.Header.Set("Origin", target.String()) if r.Out.Header.Get("Upgrade") != "" { r.Out.Header.Set("Connection", "Upgrade") } diff --git a/apps/backend/internal/agentctl/server/api/vscode_proxy_test.go b/apps/backend/internal/agentctl/server/api/vscode_proxy_test.go new file mode 100644 index 0000000000..a0794fa690 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/vscode_proxy_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +func TestHandleVscodeProxy_ProxiesRequest(t *testing.T) { + var mu sync.Mutex + var receivedPath, receivedMethod string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedPath = r.URL.Path + receivedMethod = r.Method + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer mock.Close() + + port := mustParsePort(t, mock.URL) + + s := newTestServer(t) + s.procMgr.SetVscodeForTest(process.VscodeStatusRunning, port) + + // Use a real HTTP server so Gin's responseWriter gets a real http.ResponseWriter + ts := httptest.NewServer(s.router) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/vscode/proxy/some/path") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + mu.Lock() + defer mu.Unlock() + if receivedPath != "/some/path" { + t.Errorf("expected path /some/path, got %q", receivedPath) + } + if receivedMethod != http.MethodGet { + t.Errorf("expected GET, got %q", receivedMethod) + } +} + +func TestHandleVscodeProxy_RootPath(t *testing.T) { + var mu sync.Mutex + var receivedPath string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedPath = r.URL.Path + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer mock.Close() + + port := mustParsePort(t, mock.URL) + + s := newTestServer(t) + s.procMgr.SetVscodeForTest(process.VscodeStatusRunning, port) + + ts := httptest.NewServer(s.router) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/v1/vscode/proxy") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + mu.Lock() + defer mu.Unlock() + if receivedPath != "/" { + t.Errorf("expected root path /, got %q", receivedPath) + } +} + +func TestHandleVscodeProxy_OriginHeader(t *testing.T) { + var mu sync.Mutex + var receivedOrigin string + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + receivedOrigin = r.Header.Get("Origin") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer mock.Close() + + port := mustParsePort(t, mock.URL) + + s := newTestServer(t) + s.procMgr.SetVscodeForTest(process.VscodeStatusRunning, port) + + ts := httptest.NewServer(s.router) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/vscode/proxy/test", nil) + req.Header.Set("Origin", "http://different-host:9999") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + _, _ = io.Copy(io.Discard, resp.Body) + + mu.Lock() + defer mu.Unlock() + expected := "http://127.0.0.1:" + strconv.Itoa(port) + if receivedOrigin != expected { + t.Errorf("expected Origin %q, got %q", expected, receivedOrigin) + } +} + +func TestHandleVscodeProxy_NotRunning(t *testing.T) { + s := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/vscode/proxy/foo", nil) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func mustParsePort(t *testing.T, rawURL string) int { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse URL: %v", err) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("failed to parse port: %v", err) + } + return port +} diff --git a/apps/backend/internal/agentctl/server/process/testing_helpers.go b/apps/backend/internal/agentctl/server/process/testing_helpers.go new file mode 100644 index 0000000000..86e8f76e13 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/testing_helpers.go @@ -0,0 +1,12 @@ +package process + +// SetVscodeForTest injects a VscodeManager with the given status and port. +// Intended for use in tests where starting a real code-server is not feasible. +func (m *Manager) SetVscodeForTest(status VscodeStatus, port int) { + m.vscodeMu.Lock() + defer m.vscodeMu.Unlock() + m.vscode = &VscodeManager{ + status: status, + port: port, + } +} diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index 85059b229a..0ef8d460a4 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -28,7 +28,8 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr extra: { KANDEV_MOCK_AGENT: "true", KANDEV_DATABASE_PATH: dbPath }, }); const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); - const logLevel = process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; + const logLevel = + process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; logStartupInfo({ header: "dev mode: using local repo", diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index 803b5e0a9c..312aec2d0f 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -19,7 +19,13 @@ import { DATA_DIR, HEALTH_TIMEOUT_MS_RELEASE } from "./constants"; import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { getBinaryName } from "./platform"; import { createProcessSupervisor } from "./process"; -import { attachBackendExitHandler, buildBackendEnv, buildWebEnv, logStartupInfo, pickPorts } from "./shared"; +import { + attachBackendExitHandler, + buildBackendEnv, + buildWebEnv, + logStartupInfo, + pickPorts, +} from "./shared"; import { launchWebApp } from "./web"; export type StartOptions = { @@ -116,7 +122,13 @@ export async function runStart({ }); const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug }); - logStartupInfo({ header: "start mode: using local build", ports, dbPath, includeMcp: true, logLevel }); + logStartupInfo({ + header: "start mode: using local build", + ports, + dbPath, + includeMcp: true, + logLevel, + }); const supervisor = createProcessSupervisor(); supervisor.attachSignalHandlers(); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 8b07660954..580146febc 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -42,6 +42,7 @@ import { PreviewController } from "./preview/preview-controller"; import { ReviewDialog } from "@/components/review/review-dialog"; import { useCumulativeDiff } from "@/hooks/domains/session/use-cumulative-diff"; import { formatReviewCommentsAsMarkdown } from "@/components/task/chat/messages/review-comments-attachment"; +import { stopVscode } from "@/lib/api/domains/vscode-api"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; @@ -512,6 +513,11 @@ function setupLayoutPersistence( function setupPortalCleanup(api: DockviewReadyEvent["api"]) { api.onDidRemovePanel((panel) => { if (useDockviewStore.getState().isRestoringLayout) return; + // Stop code-server when the user explicitly closes the vscode tab + const entry = panelPortalManager.get(panel.id); + if (entry?.component === "vscode" && entry.sessionId) { + stopVscode(entry.sessionId); + } panelPortalManager.release(panel.id); }); } diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx index 9d591d07bd..78ba6412ae 100644 --- a/apps/web/components/task/editors-menu.tsx +++ b/apps/web/components/task/editors-menu.tsx @@ -66,7 +66,7 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { - {activeSessionId ? "Open in editor" : "Select a session to open its worktree"} + {activeSessionId ? "Open editor" : "Select a session to open its worktree"} diff --git a/apps/web/components/task/vscode-panel.tsx b/apps/web/components/task/vscode-panel.tsx index 5ff214b94c..16129f2758 100644 --- a/apps/web/components/task/vscode-panel.tsx +++ b/apps/web/components/task/vscode-panel.tsx @@ -1,25 +1,12 @@ "use client"; import { memo, useState, useCallback, useEffect, useRef } from "react"; -import { - IconRefresh, - IconExternalLink, - IconPlayerStop, - IconBrandVscode, - IconLoader2, - IconAlertCircle, -} from "@tabler/icons-react"; +import { 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 { PanelRoot, PanelBody } 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 { startVscode, getVscodeStatus, type VscodeStatus } from "@/lib/api/domains/vscode-api"; import { getBackendConfig } from "@/lib/config"; function VscodeProgress({ @@ -49,9 +36,7 @@ function VscodeProgress({ return (
-

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

+

Starting VS Code Server

{message &&

{message}

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