diff --git a/Makefile b/Makefile index 76a6ee66a6..b88556847f 100644 --- a/Makefile +++ b/Makefile @@ -104,8 +104,10 @@ help: .PHONY: dev dev: +ifneq ($(shell uname -s)/$(shell uname -m),Linux/x86_64) @echo "Building Linux agentctl for remote executors..." @$(MAKE) -C $(BACKEND_DIR) build-agentctl-linux +endif @echo "Launching via CLI (auto ports)..." @cd $(APPS_DIR) && $(PNPM) -C cli dev -- dev @@ -190,7 +192,7 @@ install-backend: .PHONY: install-web install-web: @printf "$(CYAN)Installing web dependencies...$(RESET)\n" - @cd $(APPS_DIR) && $(PNPM) install --silent 2>/dev/null || cd $(APPS_DIR) && $(PNPM) install + @(cd $(APPS_DIR) && $(PNPM) install --silent 2>/dev/null) || (cd $(APPS_DIR) && $(PNPM) install) @printf "$(CYAN)Installing Playwright browsers...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web exec playwright install chromium diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index 2ab56c2b65..f19b909667 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -92,6 +92,7 @@ func provideGateway( gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc) gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(dataDir, log)) gateway.SetVscodeProxy(lifecycleMgr) + gateway.SetPortProxy(lifecycleMgr) } orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log) @@ -147,6 +148,9 @@ func provideGateway( vscodeHandlers := agenthandlers.NewVscodeHandlers(lifecycleMgr, gateway.VscodeProxyHandler, log) vscodeHandlers.RegisterHandlers(gateway.Dispatcher) + + portHandlers := agenthandlers.NewPortHandlers(lifecycleMgr, log) + portHandlers.RegisterHandlers(gateway.Dispatcher) } go gateway.Hub.Run(ctx) diff --git a/apps/backend/internal/agent/executor/executor.go b/apps/backend/internal/agent/executor/executor.go index 0919e72f6c..b934aed725 100644 --- a/apps/backend/internal/agent/executor/executor.go +++ b/apps/backend/internal/agent/executor/executor.go @@ -30,6 +30,8 @@ func ExecutorTypeToBackend(execType models.ExecutorType) Name { return NameRemoteDocker case models.ExecutorTypeSprites: return NameSprites + case models.ExecutorTypeMockRemote: + return NameStandalone default: return NameStandalone } diff --git a/apps/backend/internal/agent/handlers/port_handlers.go b/apps/backend/internal/agent/handlers/port_handlers.go new file mode 100644 index 0000000000..e6a38bbd3b --- /dev/null +++ b/apps/backend/internal/agent/handlers/port_handlers.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agent/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +// PortHandlers provides WebSocket handlers for port listing operations. +type PortHandlers struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger +} + +// NewPortHandlers creates a new PortHandlers instance. +func NewPortHandlers(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PortHandlers { + return &PortHandlers{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "port_handlers")), + } +} + +// RegisterHandlers registers port handlers with the WebSocket dispatcher. +func (h *PortHandlers) RegisterHandlers(d *ws.Dispatcher) { + d.RegisterFunc(ws.ActionPortList, h.wsPortList) +} + +// portListRequest for port.list action. +type portListRequest struct { + SessionID string `json:"session_id"` +} + +func (h *PortHandlers) wsPortList(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req portListRequest + 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) + } + + ports, err := client.ListPorts(ctx) + if err != nil { + h.logger.Error("failed to list ports", zap.Error(err), zap.String("session_id", req.SessionID)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + fmt.Sprintf("failed to list ports: %v", err), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "ports": ports, + }) +} diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index e2e39b579d..9588c301a7 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -168,10 +168,11 @@ func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg * // wsUpdateFileContent handles workspace.file.update action func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - SessionID string `json:"session_id"` - Path string `json:"path"` - Diff string `json:"diff"` - OriginalHash string `json:"original_hash"` + SessionID string `json:"session_id"` + Path string `json:"path"` + Diff string `json:"diff"` + OriginalHash string `json:"original_hash"` + DesiredContent *string `json:"desired_content"` } if err := msg.ParsePayload(&req); err != nil { @@ -201,7 +202,7 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws } // Apply file diff via agentctl - response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash) + response, err := client.ApplyFileDiff(ctx, req.Path, req.Diff, req.OriginalHash, req.DesiredContent) if err != nil { h.logger.Error("failed to apply file diff", 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 apply file diff: %v", err), nil) diff --git a/apps/backend/internal/agent/lifecycle/default_scripts.go b/apps/backend/internal/agent/lifecycle/default_scripts.go index c8f7b6aa74..8740192321 100644 --- a/apps/backend/internal/agent/lifecycle/default_scripts.go +++ b/apps/backend/internal/agent/lifecycle/default_scripts.go @@ -68,7 +68,7 @@ const defaultSpritesPrepareScript = `#!/bin/bash # Prepare Sprites.dev cloud sandbox # # Pre-installed tools (no need to install): -# git, curl, wget, gh (GitHub CLI), node, python, go, +# git, curl, wget, gh (GitHub CLI), node, pnpm (via corepack), python, go, # build-essential, openssh-client, ca-certificates set -euo pipefail @@ -77,6 +77,9 @@ set -euo pipefail mkdir -p ~/.ssh ssh-keyscan -t ed25519 github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts 2>/dev/null +# ---- Enable pnpm via corepack ---- +corepack enable + # ---- Git identity ---- {{git.identity_setup}} diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 7a9b33439e..fec1a99070 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -38,6 +38,18 @@ type ExecutorBackend interface { // GetInteractiveRunner returns the interactive runner for passthrough mode. // May return nil if the runtime doesn't support passthrough mode. GetInteractiveRunner() *process.InteractiveRunner + + // RequiresCloneURL reports whether this executor needs a git clone URL + // instead of a local filesystem path for repository access. + RequiresCloneURL() bool + + // ShouldApplyPreferredShell reports whether the user's preferred shell + // should be injected into the agent environment. + ShouldApplyPreferredShell() bool + + // IsAlwaysResumable reports whether sessions on this executor can be + // resumed even without an explicit resume token. + IsAlwaysResumable() bool } // McpServerConfig holds configuration for an MCP server. diff --git a/apps/backend/internal/agent/lifecycle/executor_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go index d07626ab97..ae23253d4a 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -225,3 +225,7 @@ func (r *DockerExecutor) Close() error { func (r *DockerExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } + +func (r *DockerExecutor) RequiresCloneURL() bool { return false } +func (r *DockerExecutor) ShouldApplyPreferredShell() bool { return false } +func (r *DockerExecutor) IsAlwaysResumable() bool { return false } diff --git a/apps/backend/internal/agent/lifecycle/executor_registry_test.go b/apps/backend/internal/agent/lifecycle/executor_registry_test.go index d32fae73e6..e362eed719 100644 --- a/apps/backend/internal/agent/lifecycle/executor_registry_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_registry_test.go @@ -35,6 +35,9 @@ func (m *MockExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstanc func (m *MockExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } +func (m *MockExecutor) RequiresCloneURL() bool { return false } +func (m *MockExecutor) ShouldApplyPreferredShell() bool { return false } +func (m *MockExecutor) IsAlwaysResumable() bool { return false } func newTestRegistryLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) diff --git a/apps/backend/internal/agent/lifecycle/executor_remote_docker.go b/apps/backend/internal/agent/lifecycle/executor_remote_docker.go index 70d9677b78..e879fd39b4 100644 --- a/apps/backend/internal/agent/lifecycle/executor_remote_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_remote_docker.go @@ -67,3 +67,7 @@ func (r *RemoteDockerExecutor) GetInteractiveRunner() *process.InteractiveRunner // Remote docker does not support passthrough mode. return nil } + +func (r *RemoteDockerExecutor) RequiresCloneURL() bool { return true } +func (r *RemoteDockerExecutor) ShouldApplyPreferredShell() bool { return false } +func (r *RemoteDockerExecutor) IsAlwaysResumable() bool { return false } diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go index 1d76a54ce2..a7bd3d0864 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -404,3 +404,7 @@ func (r *SpritesExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstan func (r *SpritesExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } + +func (r *SpritesExecutor) RequiresCloneURL() bool { return true } +func (r *SpritesExecutor) ShouldApplyPreferredShell() bool { return false } +func (r *SpritesExecutor) IsAlwaysResumable() bool { return true } diff --git a/apps/backend/internal/agent/lifecycle/executor_standalone.go b/apps/backend/internal/agent/lifecycle/executor_standalone.go index 0840273add..608aeb9d04 100644 --- a/apps/backend/internal/agent/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/lifecycle/executor_standalone.go @@ -176,3 +176,7 @@ func (r *StandaloneExecutor) SetInteractiveRunner(runner *process.InteractiveRun func (r *StandaloneExecutor) GetInteractiveRunner() *process.InteractiveRunner { return r.interactiveRunner } + +func (r *StandaloneExecutor) RequiresCloneURL() bool { return false } +func (r *StandaloneExecutor) ShouldApplyPreferredShell() bool { return true } +func (r *StandaloneExecutor) IsAlwaysResumable() bool { return false } diff --git a/apps/backend/internal/agent/lifecycle/manager_capabilities.go b/apps/backend/internal/agent/lifecycle/manager_capabilities.go new file mode 100644 index 0000000000..50868549a9 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/manager_capabilities.go @@ -0,0 +1,19 @@ +package lifecycle + +// RequiresCloneURL implements orchestrator/executor.ExecutorTypeCapabilities. +func (m *Manager) RequiresCloneURL(executorType string) bool { + backend, err := m.getExecutorBackend(executorType) + if err != nil { + return false + } + return backend.RequiresCloneURL() +} + +// ShouldApplyPreferredShell implements orchestrator/executor.ExecutorTypeCapabilities. +func (m *Manager) ShouldApplyPreferredShell(executorType string) bool { + backend, err := m.getExecutorBackend(executorType) + if err != nil { + return false + } + return backend.ShouldApplyPreferredShell() +} diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 976ade49b5..8c0351c375 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -350,8 +350,11 @@ func (m *Manager) IsRemoteSession(ctx context.Context, sessionID string) bool { if err != nil || info == nil { return false } - return info.RuntimeName == string(executor.NameSprites) || info.ExecutorType == string(models.ExecutorTypeSprites) || - info.ExecutorType == string(models.ExecutorTypeRemoteDocker) + if models.IsRemoteExecutorType(models.ExecutorType(info.ExecutorType)) { + return true + } + // Backwards compatibility: old records may only have RuntimeName set. + return info.RuntimeName == string(executor.NameSprites) || info.RuntimeName == string(executor.NameRemoteDocker) } // GetAvailableCommandsForSession returns the available slash commands for a session. diff --git a/apps/backend/internal/agentctl/client/client_files.go b/apps/backend/internal/agentctl/client/client_files.go index 6afedd14b1..9efa5daa52 100644 --- a/apps/backend/internal/agentctl/client/client_files.go +++ b/apps/backend/internal/agentctl/client/client_files.go @@ -142,11 +142,12 @@ func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref s } // ApplyFileDiff applies a unified diff to a file via HTTP POST -func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, originalHash string) (*streams.FileUpdateResponse, error) { +func (c *Client) ApplyFileDiff(ctx context.Context, path, diff, originalHash string, desiredContent *string) (*streams.FileUpdateResponse, error) { reqBody := streams.FileUpdateRequest{ - Path: path, - Diff: diff, - OriginalHash: originalHash, + Path: path, + Diff: diff, + OriginalHash: originalHash, + DesiredContent: desiredContent, } bodyBytes, err := json.Marshal(reqBody) diff --git a/apps/backend/internal/agentctl/client/client_ports.go b/apps/backend/internal/agentctl/client/client_ports.go new file mode 100644 index 0000000000..b1a264c6f3 --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_ports.go @@ -0,0 +1,45 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// ListeningPort represents a TCP port with an active listener on the remote executor. +type ListeningPort struct { + Port int `json:"port"` + Address string `json:"address"` +} + +// ListPorts returns all TCP ports currently listening inside the executor. +func (c *Client) ListPorts(ctx context.Context) ([]ListeningPort, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/ports", 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("list ports failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Ports []ListeningPort `json:"ports"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse list ports response: %w", err) + } + return result.Ports, nil +} diff --git a/apps/backend/internal/agentctl/client/client_shell_terminal.go b/apps/backend/internal/agentctl/client/client_shell_terminal.go new file mode 100644 index 0000000000..1a97052229 --- /dev/null +++ b/apps/backend/internal/agentctl/client/client_shell_terminal.go @@ -0,0 +1,91 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/websocket" +) + +// StartShellTerminal creates a new per-terminal shell session on agentctl. +func (c *Client) StartShellTerminal(ctx context.Context, terminalID string, cols, rows int) error { + body, _ := json.Marshal(map[string]any{ + "terminal_id": terminalID, + "cols": cols, + "rows": rows, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/shell/terminal/start", 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() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("start shell terminal failed: %s", resp.Status) + } + return nil +} + +// StreamShellTerminal opens a binary WebSocket connection to a per-terminal shell. +// The caller is responsible for closing the returned connection. +func (c *Client) StreamShellTerminal(ctx context.Context, terminalID string) (*websocket.Conn, error) { + wsURL := "ws" + c.baseURL[4:] + "/api/v1/shell/terminal/" + terminalID + "/stream" + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to connect to shell terminal stream: %w", err) + } + return conn, nil +} + +// ShellTerminalBuffer returns the buffered output for a per-terminal shell session. +func (c *Client) ShellTerminalBuffer(ctx context.Context, terminalID string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/terminal/"+terminalID+"/buffer", 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 != http.StatusOK { + return "", fmt.Errorf("shell terminal buffer failed: %s", resp.Status) + } + + var result ShellBufferResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.Data, nil +} + +// StopShellTerminal stops a per-terminal shell session. +func (c *Client) StopShellTerminal(ctx context.Context, terminalID string) error { + req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/api/v1/shell/terminal/"+terminalID, 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 != http.StatusOK { + return fmt.Errorf("stop shell terminal failed: %s", resp.Status) + } + return nil +} diff --git a/apps/backend/internal/agentctl/server/api/port_listener.go b/apps/backend/internal/agentctl/server/api/port_listener.go new file mode 100644 index 0000000000..e1d032e923 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/port_listener.go @@ -0,0 +1,135 @@ +package api + +import ( + "bufio" + "context" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// ListeningPort represents a TCP port with an active listener. +type ListeningPort struct { + Port int `json:"port"` + Address string `json:"address"` +} + +// handleListPorts returns all TCP ports currently listening inside the executor. +func (s *Server) handleListPorts(c *gin.Context) { + ports, err := detectListeningPorts() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ports": ports}) +} + +func detectListeningPorts() ([]ListeningPort, error) { + if runtime.GOOS == "linux" { + return detectFromProcNet() + } + return detectFromLsof() +} + +// detectFromProcNet parses /proc/net/tcp for LISTEN state (0A) sockets. +func detectFromProcNet() ([]ListeningPort, error) { + file, err := os.Open("/proc/net/tcp") + if err != nil { + return detectFromLsof() // fallback + } + defer func() { _ = file.Close() }() + + seen := make(map[int]bool) + var ports []ListeningPort + + scanner := bufio.NewScanner(file) + scanner.Scan() // skip header + for scanner.Scan() { + port, addr, ok := parseProcNetLine(scanner.Text()) + if !ok || port < 1024 || seen[port] { + continue + } + seen[port] = true + ports = append(ports, ListeningPort{Port: port, Address: addr}) + } + return ports, scanner.Err() +} + +// parseProcNetLine extracts port and address from a /proc/net/tcp line. +// Format: sl local_address rem_address st ... +// local_address is hex IP:hex port, st=0A means LISTEN. +func parseProcNetLine(line string) (int, string, bool) { + fields := strings.Fields(line) + if len(fields) < 4 { + return 0, "", false + } + // State must be LISTEN (0A) + if fields[3] != "0A" { + return 0, "", false + } + localAddr := fields[1] + parts := strings.SplitN(localAddr, ":", 2) + if len(parts) != 2 { + return 0, "", false + } + portHex := parts[1] + port64, err := strconv.ParseInt(portHex, 16, 32) + if err != nil { + return 0, "", false + } + port := int(port64) + + addr := parseHexIP(parts[0]) + return port, addr, true +} + +func parseHexIP(hexIP string) string { + b, err := hex.DecodeString(hexIP) + if err != nil || len(b) != 4 { + return "0.0.0.0" + } + // /proc/net/tcp stores in little-endian + return fmt.Sprintf("%d.%d.%d.%d", b[3], b[2], b[1], b[0]) +} + +// detectFromLsof uses lsof as a fallback for non-Linux systems. +func detectFromLsof() ([]ListeningPort, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "lsof", "-iTCP", "-sTCP:LISTEN", "-nP", "-F", "n").Output() + if err != nil { + return nil, fmt.Errorf("lsof failed: %w", err) + } + + seen := make(map[int]bool) + var ports []ListeningPort + + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "n") { + continue + } + // Format: n*:PORT or n127.0.0.1:PORT or n[::]:PORT + addr := line[1:] // strip 'n' prefix + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + continue + } + port, err := strconv.Atoi(portStr) + if err != nil || port < 1024 || seen[port] { + continue + } + seen[port] = true + host, _, _ := net.SplitHostPort(addr) + ports = append(ports, ListeningPort{Port: port, Address: host}) + } + return ports, nil +} diff --git a/apps/backend/internal/agentctl/server/api/port_proxy.go b/apps/backend/internal/agentctl/server/api/port_proxy.go new file mode 100644 index 0000000000..70efd0afc7 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/port_proxy.go @@ -0,0 +1,116 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// portProxyCache caches reverse proxies per port to avoid re-creation. +type portProxyCache struct { + mu sync.Mutex + proxies map[int]*httputil.ReverseProxy +} + +func newPortProxyCache() *portProxyCache { + return &portProxyCache{proxies: make(map[int]*httputil.ReverseProxy)} +} + +func (c *portProxyCache) get(port int) (*httputil.ReverseProxy, bool) { + c.mu.Lock() + defer c.mu.Unlock() + p, ok := c.proxies[port] + return p, ok +} + +func (c *portProxyCache) set(port int, p *httputil.ReverseProxy) { + c.mu.Lock() + defer c.mu.Unlock() + c.proxies[port] = p +} + +// handlePortProxy reverse-proxies HTTP/WebSocket requests to a local port +// inside the executor. Routes /api/v1/port-proxy/:port/*path to +// http://127.0.0.1:{port}/{path}. +func (s *Server) handlePortProxy(c *gin.Context) { + portStr := c.Param("port") + port, err := strconv.Atoi(portStr) + if err != nil || port < 1024 || port > 65535 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid port: must be 1024-65535"}) + return + } + + proxy, ok := s.portProxies.get(port) + if !ok { + proxy = s.createPortProxy(port) + s.portProxies.set(port, proxy) + } + + // Strip /api/v1/port-proxy/:port prefix, forward remaining path. + incomingPath := c.Request.URL.Path + prefix := "/api/v1/port-proxy/" + portStr + path := strings.TrimPrefix(incomingPath, prefix) + if path == "" { + path = "/" + } + c.Request.URL.Path = path + c.Request.URL.RawPath = "" + + s.logger.Debug("port proxy request", + zap.Int("port", port), + zap.String("incoming_path", incomingPath), + zap.String("forwarded_path", path)) + + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + s.logger.Debug("port proxy: client disconnected", zap.Int("port", port)) + return + } + panic(r) + } + }() + + proxy.ServeHTTP(c.Writer, c.Request) +} + +func (s *Server) createPortProxy(port int) *httputil.ReverseProxy { + target, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", port)) + + proxy := &httputil.ReverseProxy{} + proxy.Rewrite = func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.URL.Path = r.In.URL.Path + r.Out.URL.RawPath = "" + r.Out.Header.Set("Origin", target.String()) + if r.Out.Header.Get("Upgrade") != "" { + r.Out.Header.Set("Connection", "Upgrade") + } + } + + proxy.ModifyResponse = func(resp *http.Response) error { + if resp.StatusCode == http.StatusSwitchingProtocols { + resp.Header.Set("Connection", "Upgrade") + } + return nil + } + + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) { + s.logger.Warn("port proxy upstream error", + zap.Int("port", port), + zap.Error(proxyErr)) + http.Error(w, "port proxy error", http.StatusBadGateway) + } + + // Flush immediately for SSE/streaming responses + proxy.FlushInterval = -1 + + return proxy +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index cdecd4951a..fde87a3919 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -24,6 +24,7 @@ type Server struct { mcpBackendClient *mcp.ChannelBackendClient logger *logger.Logger router *gin.Engine + portProxies *portProxyCache upgrader websocket.Upgrader } @@ -41,6 +42,7 @@ func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer * mcpBackendClient: mcpBackendClient, logger: log.WithFields(zap.String("component", "api-server")), router: gin.New(), + portProxies: newPortProxyCache(), upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins for container-local communication @@ -97,6 +99,12 @@ func (s *Server) setupRoutes() { api.GET("/shell/buffer", s.handleShellBuffer) api.POST("/shell/start", s.handleShellStart) + // Per-terminal shell sessions (for remote executor multi-terminal support) + api.POST("/shell/terminal/start", s.handleShellTerminalStart) + api.GET("/shell/terminal/:id/stream", s.handleShellTerminalStreamWS) + api.GET("/shell/terminal/:id/buffer", s.handleShellTerminalBuffer) + api.DELETE("/shell/terminal/:id", s.handleShellTerminalStop) + // Process runner api.POST("/processes/start", s.handleStartProcess) api.POST("/processes/stop", s.handleStopProcess) @@ -111,6 +119,13 @@ func (s *Server) setupRoutes() { api.Any("/vscode/proxy", s.handleVscodeProxy) api.Any("/vscode/proxy/*path", s.handleVscodeProxy) + // Port proxy - generic reverse proxy for any localhost port + api.Any("/port-proxy/:port", s.handlePortProxy) + api.Any("/port-proxy/:port/*path", s.handlePortProxy) + + // Port listener detection + api.GET("/ports", s.handleListPorts) + // Git operations api.POST("/git/pull", s.handleGitPull) api.POST("/git/push", s.handleGitPush) diff --git a/apps/backend/internal/agentctl/server/api/shell_terminal.go b/apps/backend/internal/agentctl/server/api/shell_terminal.go new file mode 100644 index 0000000000..99fb5122a0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/shell_terminal.go @@ -0,0 +1,189 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agentctl/server/shell" + "go.uber.org/zap" +) + +// shellTerminalStartRequest is the request body for POST /shell/terminal/start. +type shellTerminalStartRequest struct { + TerminalID string `json:"terminal_id"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// handleShellTerminalStart creates a new per-terminal shell session. +func (s *Server) handleShellTerminalStart(c *gin.Context) { + var req shellTerminalStartRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + return + } + if req.TerminalID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "terminal_id is required"}) + return + } + + cfg := shell.DefaultConfig(s.cfg.WorkDir) + if req.Cols > 0 { + cfg.Cols = req.Cols + } + if req.Rows > 0 { + cfg.Rows = req.Rows + } + + mgr := s.procMgr.ShellManager() + if _, err := mgr.Start(req.TerminalID, cfg); err != nil { + s.logger.Error("failed to start terminal shell", + zap.String("terminal_id", req.TerminalID), + zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "started", "terminal_id": req.TerminalID}) +} + +// handleShellTerminalStreamWS is a binary WebSocket endpoint for per-terminal shell I/O. +// Protocol: byte 0x01 prefix = resize command (followed by JSON {cols, rows}), +// all other bytes = raw PTY input. Output is sent as binary frames. +func (s *Server) handleShellTerminalStreamWS(c *gin.Context) { + terminalID := c.Param("id") + if terminalID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "terminal id is required"}) + return + } + + mgr := s.procMgr.ShellManager() + session, ok := mgr.Get(terminalID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "terminal not found"}) + return + } + + conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + s.logger.Error("failed to upgrade terminal shell websocket", + zap.String("terminal_id", terminalID), + zap.Error(err)) + return + } + defer func() { _ = conn.Close() }() + + s.logger.Info("terminal shell WebSocket connected", zap.String("terminal_id", terminalID)) + + // Subscribe to shell output + outputCh := make(chan []byte, 256) + session.Subscribe(outputCh) + defer session.Unsubscribe(outputCh) + + // Replay buffered output for immediate state + if buf := session.GetBufferedOutput(); len(buf) > 0 { + if err := conn.WriteMessage(websocket.BinaryMessage, buf); err != nil { + s.logger.Debug("failed to replay shell buffer", zap.Error(err)) + return + } + } + + done := make(chan struct{}) + defer close(done) + + // Forward shell output to WebSocket + go func() { + for { + select { + case <-done: + return + case data, ok := <-outputCh: + if !ok { + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + s.logger.Debug("terminal shell output write error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + return + } + } + } + }() + + // Read input from WebSocket + s.shellTerminalReadLoop(conn, session, terminalID) +} + +const shellResizeByte = 0x01 + +func (s *Server) shellTerminalReadLoop(conn *websocket.Conn, session *shell.Session, terminalID string) { + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + s.logger.Debug("terminal shell read error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + return + } + if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage { + continue + } + if len(data) == 0 { + continue + } + + if data[0] == shellResizeByte { + var resize struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + } + if err := json.Unmarshal(data[1:], &resize); err != nil { + s.logger.Debug("failed to decode terminal shell resize", + zap.String("terminal_id", terminalID), + zap.Error(err)) + continue + } + if err := session.Resize(resize.Cols, resize.Rows); err != nil { + s.logger.Debug("failed to resize terminal shell", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + continue + } + + if _, err := io.Writer(session).Write(data); err != nil { + s.logger.Debug("failed to write terminal shell input", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + } +} + +// handleShellTerminalBuffer returns the buffered output for a terminal. +func (s *Server) handleShellTerminalBuffer(c *gin.Context) { + terminalID := c.Param("id") + mgr := s.procMgr.ShellManager() + buf, err := mgr.Buffer(terminalID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, ShellBufferResponse{Data: string(buf)}) +} + +// handleShellTerminalStop stops a per-terminal shell session. +func (s *Server) handleShellTerminalStop(c *gin.Context) { + terminalID := c.Param("id") + mgr := s.procMgr.ShellManager() + if err := mgr.Stop(terminalID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "stopped", "terminal_id": terminalID}) +} diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index c68b9337ac..3cca6c3a08 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -116,13 +116,13 @@ func (s *Server) handleFileContent(c *gin.Context) { return } - content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path) + content, size, isBinary, resolvedPath, err := s.procMgr.GetWorkspaceTracker().GetFileContent(path) if err != nil { c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) return } - c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary}) + c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary, ResolvedPath: resolvedPath}) } // handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET @@ -167,7 +167,7 @@ func (s *Server) handleFileUpdate(c *gin.Context) { } // Apply the diff - newHash, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, req.Diff, req.OriginalHash) + newHash, resolution, err := s.procMgr.GetWorkspaceTracker().ApplyFileDiff(req.Path, req.Diff, req.OriginalHash, req.DesiredContent) if err != nil { c.JSON(400, streams.FileUpdateResponse{ Path: req.Path, @@ -178,9 +178,10 @@ func (s *Server) handleFileUpdate(c *gin.Context) { } c.JSON(200, streams.FileUpdateResponse{ - Path: req.Path, - Success: true, - NewHash: newHash, + Path: req.Path, + Success: true, + NewHash: newHash, + Resolution: resolution, }) } diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 83090e0929..359994a475 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -91,6 +91,9 @@ type Manager struct { // Embedded shell session (auto-created when agent starts) shell *shell.Session + // Per-terminal shell sessions for remote executor support + shellMgr *shell.Manager + // Protocol adapter for agent communication adapter adapter.AgentAdapter adapterCfg *adapter.Config @@ -132,6 +135,7 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { pendingPermissions: make(map[string]*PendingPermission), } m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) + m.shellMgr = shell.NewManager(cfg.WorkDir, log) m.status.Store(StatusStopped) m.exitCode.Store(-1) return m @@ -637,6 +641,10 @@ func (m *Manager) stopShellAndProcesses(ctx context.Context) { } m.logger.Debug("shell session stopped") + m.logger.Debug("stopping terminal shells") + m.shellMgr.StopAll() + m.logger.Debug("terminal shells stopped") + // Stop all running workspace processes (dev server, setup, cleanup, custom). m.logger.Debug("stopping workspace processes") if m.processRunner != nil { @@ -1043,6 +1051,11 @@ func (m *Manager) Shell() *shell.Session { return m.shell } +// ShellManager returns the per-terminal shell session manager. +func (m *Manager) ShellManager() *shell.Manager { + return m.shellMgr +} + // StartShell creates and starts the shell session independently of the agent process. // This is used in passthrough mode where the agent runs directly via InteractiveRunner // but we still need shell access for the workspace. diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 17556a42f0..a1067bb82b 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -22,6 +22,13 @@ import ( "go.uber.org/zap" ) +const ( + // ResolutionApplied indicates the diff was applied normally via git apply. + ResolutionApplied = "applied" + // ResolutionOverwritten indicates the file was overwritten with desired content. + ResolutionOverwritten = "overwritten" +) + const maxFileSize = 10 * 1024 * 1024 // 10MB // updateFiles updates the file listing @@ -238,31 +245,35 @@ func resolveNonExistentPath(path string) (string, error) { // GetFileContent returns the content of a file. // If the file is not valid UTF-8, it is base64-encoded and isBinary is true. -func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, error) { +// If the file is a symlink, resolvedPath contains the target path relative to the workspace root. +func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, string, error) { safePath, err := wt.resolveSafePath(reqPath) if err != nil { - return "", 0, false, err + return "", 0, false, "", err } + // Check if the original path is a symlink and compute the resolved relative path. + resolvedPath := wt.resolveSymlinkRelPath(reqPath) + // Check if file exists and is a regular file info, err := os.Stat(safePath) if err != nil { - return "", 0, false, fmt.Errorf("file not found: %w", err) + return "", 0, false, "", fmt.Errorf("file not found: %w", err) } if info.IsDir() { - return "", 0, false, fmt.Errorf("path is a directory, not a file") + return "", 0, false, "", fmt.Errorf("path is a directory, not a file") } // Check file size if info.Size() > maxFileSize { - return "", info.Size(), false, fmt.Errorf("file too large (max 10MB)") + return "", info.Size(), false, "", fmt.Errorf("file too large (max 10MB)") } // Read file content file, err := os.Open(safePath) if err != nil { - return "", 0, false, fmt.Errorf("failed to open file: %w", err) + return "", 0, false, "", fmt.Errorf("failed to open file: %w", err) } defer func() { _ = file.Close() @@ -270,71 +281,85 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, content, err := io.ReadAll(file) if err != nil { - return "", 0, false, fmt.Errorf("failed to read file: %w", err) + return "", 0, false, "", fmt.Errorf("failed to read file: %w", err) } // Detect binary: if content is not valid UTF-8, base64-encode it if !utf8.Valid(content) { encoded := base64.StdEncoding.EncodeToString(content) - return encoded, info.Size(), true, nil + return encoded, info.Size(), true, resolvedPath, nil } - return string(content), info.Size(), false, nil + return string(content), info.Size(), false, resolvedPath, nil } -// ApplyFileDiff applies a unified diff to a file with conflict detection +// resolveSymlinkRelPath checks if reqPath is a symlink and returns the resolved +// target path relative to the workspace root. Returns "" if not a symlink. +func (wt *WorkspaceTracker) resolveSymlinkRelPath(reqPath string) string { + cleanReqPath := filepath.Clean(reqPath) + if strings.Contains(cleanReqPath, "..") || filepath.IsAbs(cleanReqPath) { + return "" + } + unresolvedPath := filepath.Join(wt.workDir, cleanReqPath) + info, err := os.Lstat(unresolvedPath) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + return "" + } + // Resolve the symlink target + realTarget, err := filepath.EvalSymlinks(unresolvedPath) + if err != nil { + return "" + } + realWorkDir, _ := filepath.EvalSymlinks(filepath.Clean(wt.workDir)) + if realWorkDir == "" { + realWorkDir = filepath.Clean(wt.workDir) + } + rel, err := filepath.Rel(realWorkDir, realTarget) + if err != nil { + return "" + } + return rel +} + +// ApplyFileDiff applies a unified diff to a file with conflict detection. // Uses git apply for reliable, battle-tested patch application. // For symlinked files, resolves to the real path and rewrites the diff header // so that git apply operates on the actual file. -func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, originalHash string) (string, error) { +// When desiredContent is provided and the diff cannot be applied (hash conflict), +// the file is overwritten with the desired content as a fallback. +// Returns the new hash and a resolution string ("applied" or "overwritten"). +func (wt *WorkspaceTracker) ApplyFileDiff(reqPath, unifiedDiff, originalHash string, desiredContent *string) (string, string, error) { safePath, err := wt.resolveSafePath(reqPath) if err != nil { - return "", err + return "", "", err } cleanWorkDir := filepath.Clean(wt.workDir) // Read current file content - currentContent, _, _, err := wt.GetFileContent(reqPath) + currentContent, _, _, _, err := wt.GetFileContent(reqPath) if err != nil { - return "", fmt.Errorf("failed to read current file: %w", err) + return "", "", fmt.Errorf("failed to read current file: %w", err) } // Calculate hash of current content for conflict detection currentHash := calculateSHA256(currentContent) if originalHash != "" && currentHash != originalHash { - return "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) + if desiredContent != nil { + return wt.writeDesiredContent(safePath, cleanWorkDir, reqPath, *desiredContent, currentHash) + } + return "", "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) } // If the file is a symlink, resolve to the real path and rewrite the diff header. // git apply cannot patch through symlinks — it needs the real file path. - // Note: safePath is already resolved by resolveSafePath, so we check the - // unresolved path to detect whether the original request targets a symlink. - applyPath := reqPath - cleanReqPath := filepath.Clean(reqPath) - // Validate path doesn't attempt traversal before constructing filesystem path - if strings.Contains(cleanReqPath, "..") || filepath.IsAbs(cleanReqPath) { - return "", fmt.Errorf("invalid path: %s", reqPath) - } - unresolvedPath := filepath.Join(wt.workDir, cleanReqPath) - if info, lErr := os.Lstat(unresolvedPath); lErr == nil && info.Mode()&os.ModeSymlink != 0 { - // File is a symlink. safePath already points to the real target. - realWorkDir, _ := filepath.EvalSymlinks(cleanWorkDir) - if realWorkDir == "" { - realWorkDir = cleanWorkDir - } - realRel, relErr := filepath.Rel(realWorkDir, safePath) - if relErr == nil { - unifiedDiff = rewriteDiffPaths(unifiedDiff, reqPath, realRel) - applyPath = realRel - } - } + applyPath, unifiedDiff := wt.resolveSymlinkForDiff(reqPath, safePath, cleanWorkDir, unifiedDiff) // Write diff to a temporary patch file patchFile := filepath.Join(wt.workDir, ".kandev-patch.tmp") err = os.WriteFile(patchFile, []byte(unifiedDiff), 0o644) if err != nil { - return "", fmt.Errorf("failed to write patch file: %w", err) + return "", "", fmt.Errorf("failed to write patch file: %w", err) } defer func() { _ = os.Remove(patchFile) // Best effort cleanup @@ -346,14 +371,17 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or output, err := cmd.CombinedOutput() if err != nil { - return "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) + if desiredContent != nil { + return wt.writeDesiredContent(safePath, cleanWorkDir, reqPath, *desiredContent, currentHash) + } + return "", "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) } // Read the updated content (use the real file path for symlinks) readPath := filepath.Join(cleanWorkDir, applyPath) updatedContent, err := os.ReadFile(readPath) if err != nil { - return "", fmt.Errorf("failed to read updated file: %w", err) + return "", "", fmt.Errorf("failed to read updated file: %w", err) } // Calculate new hash @@ -369,7 +397,56 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or zap.String("new_hash", newHash), ) - return newHash, nil + return newHash, ResolutionApplied, nil +} + +// resolveSymlinkForDiff checks if reqPath is a symlink and, if so, rewrites the diff +// paths to target the real file. Returns the path to use for git apply and the +// (possibly rewritten) diff. +func (wt *WorkspaceTracker) resolveSymlinkForDiff( + reqPath, safePath, cleanWorkDir, unifiedDiff string, +) (string, string) { + cleanReqPath := filepath.Clean(reqPath) + if strings.Contains(cleanReqPath, "..") || filepath.IsAbs(cleanReqPath) { + return reqPath, unifiedDiff + } + unresolvedPath := filepath.Join(wt.workDir, cleanReqPath) + info, lErr := os.Lstat(unresolvedPath) + if lErr != nil || info.Mode()&os.ModeSymlink == 0 { + return reqPath, unifiedDiff + } + // File is a symlink. safePath already points to the real target. + realWorkDir, _ := filepath.EvalSymlinks(cleanWorkDir) + if realWorkDir == "" { + realWorkDir = cleanWorkDir + } + realRel, relErr := filepath.Rel(realWorkDir, safePath) + if relErr != nil { + return reqPath, unifiedDiff + } + return realRel, rewriteDiffPaths(unifiedDiff, reqPath, realRel) +} + +// writeDesiredContent writes the desired content directly to the file as a fallback +// when the diff cannot be applied. Returns the new hash and "overwritten" resolution. +func (wt *WorkspaceTracker) writeDesiredContent( + safePath, cleanWorkDir, reqPath, desiredContent, oldHash string, +) (string, string, error) { + if err := os.WriteFile(safePath, []byte(desiredContent), 0o644); err != nil { + return "", "", fmt.Errorf("failed to write desired content: %w", err) + } + + newHash := calculateSHA256(desiredContent) + relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) + wt.notifyFileChange(relPath, types.FileOpWrite) + + wt.logger.Warn("overwrote file with desired content (conflict fallback)", + zap.String("path", reqPath), + zap.String("old_hash", oldHash), + zap.String("new_hash", newHash), + ) + + return newHash, ResolutionOverwritten, nil } // rewriteDiffPaths replaces file paths in unified diff headers. diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index 03a348773f..838d540da0 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -386,7 +386,7 @@ func TestGetFileContent_BinaryDetection(t *testing.T) { textContent := "Hello, world!\nLine 2\n" writeFile(t, repoDir, "text.txt", textContent) - content, size, isBinary, err := wt.GetFileContent("text.txt") + content, size, isBinary, _, err := wt.GetFileContent("text.txt") if err != nil { t.Fatalf("GetFileContent(text.txt) error: %v", err) } @@ -406,7 +406,7 @@ func TestGetFileContent_BinaryDetection(t *testing.T) { t.Fatalf("failed to write binary file: %v", err) } - content, size, isBinary, err = wt.GetFileContent("image.png") + content, size, isBinary, _, err = wt.GetFileContent("image.png") if err != nil { t.Fatalf("GetFileContent(image.png) error: %v", err) } @@ -429,7 +429,7 @@ func TestGetFileContent_BinaryDetection(t *testing.T) { // Test 3: Empty file returns isBinary=false writeFile(t, repoDir, "empty.txt", "") - _, _, isBinary, err = wt.GetFileContent("empty.txt") + _, _, isBinary, _, err = wt.GetFileContent("empty.txt") if err != nil { t.Fatalf("GetFileContent(empty.txt) error: %v", err) } @@ -438,7 +438,7 @@ func TestGetFileContent_BinaryDetection(t *testing.T) { } // Test 4: Path traversal is rejected - _, _, _, err = wt.GetFileContent("../../etc/passwd") + _, _, _, _, err = wt.GetFileContent("../../etc/passwd") if err == nil { t.Error("expected error for path traversal, got nil") } @@ -673,13 +673,16 @@ func TestApplyFileDiff_RegularFile(t *testing.T) { // Build a unified diff that changes line2 -> modified diff := "--- test.txt\n+++ test.txt\n@@ -1,3 +1,3 @@\n line1\n-line2\n+modified\n line3\n" - hash, err := wt.ApplyFileDiff("test.txt", diff, "") + hash, resolution, err := wt.ApplyFileDiff("test.txt", diff, "", nil) if err != nil { t.Fatalf("ApplyFileDiff failed: %v", err) } if hash == "" { t.Fatal("expected non-empty hash") } + if resolution != "applied" { + t.Errorf("expected resolution 'applied', got %q", resolution) + } // Verify file content content, err := os.ReadFile(filepath.Join(repoDir, "test.txt")) @@ -722,13 +725,16 @@ func TestApplyFileDiff_Symlink(t *testing.T) { // Build a diff targeting the symlink path diff := "--- LINK.md\n+++ LINK.md\n@@ -1,3 +1,3 @@\n line1\n-line2\n+patched\n line3\n" - hash, err := wt.ApplyFileDiff("LINK.md", diff, "") + hash, resolution, err := wt.ApplyFileDiff("LINK.md", diff, "", nil) if err != nil { t.Fatalf("ApplyFileDiff through symlink failed: %v", err) } if hash == "" { t.Fatal("expected non-empty hash") } + if resolution != "applied" { + t.Errorf("expected resolution 'applied', got %q", resolution) + } // Verify the real file was patched (not replaced by a regular file) realContent, err := os.ReadFile(filepath.Join(repoDir, "real", "target.md")) @@ -780,7 +786,7 @@ func TestApplyFileDiff_ConflictDetection(t *testing.T) { diff := "--- conflict.txt\n+++ conflict.txt\n@@ -1 +1 @@\n-original\n+patched\n" - _, err := wt.ApplyFileDiff("conflict.txt", diff, origHash) + _, _, err := wt.ApplyFileDiff("conflict.txt", diff, origHash, nil) if err == nil { t.Fatal("expected conflict error, got nil") } @@ -789,6 +795,176 @@ func TestApplyFileDiff_ConflictDetection(t *testing.T) { } } +func TestGetFileContent_SymlinkResolvedPath(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + // Create a real file and a symlink + realDir := filepath.Join(repoDir, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + writeFile(t, repoDir, "real/target.md", "content\n") + + symlinkPath := filepath.Join(repoDir, "LINK.md") + if err := os.Symlink(filepath.Join(repoDir, "real", "target.md"), symlinkPath); err != nil { + t.Skip("symlinks not supported") + } + + t.Run("symlink returns resolved path", func(t *testing.T) { + _, _, _, resolvedPath, err := wt.GetFileContent("LINK.md") + if err != nil { + t.Fatalf("GetFileContent error: %v", err) + } + if resolvedPath != filepath.Join("real", "target.md") { + t.Errorf("resolvedPath = %q, want %q", resolvedPath, filepath.Join("real", "target.md")) + } + }) + + t.Run("regular file returns empty resolved path", func(t *testing.T) { + _, _, _, resolvedPath, err := wt.GetFileContent(filepath.Join("real", "target.md")) + if err != nil { + t.Fatalf("GetFileContent error: %v", err) + } + if resolvedPath != "" { + t.Errorf("resolvedPath = %q, want empty string for non-symlink", resolvedPath) + } + }) +} + +func TestApplyFileDiff_ConflictWithDesiredContent(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + writeFile(t, repoDir, "file.txt", "original\n") + runGit(t, repoDir, "add", "file.txt") + runGit(t, repoDir, "commit", "-m", "add file.txt") + + origHash := calculateSHA256("original\n") + + // Modify file behind the diff's back (simulating agent edit) + writeFile(t, repoDir, "file.txt", "modified-by-agent\n") + + diff := "--- file.txt\n+++ file.txt\n@@ -1 +1 @@\n-original\n+user-version\n" + desiredContent := "user-desired-content\n" + + newHash, resolution, err := wt.ApplyFileDiff("file.txt", diff, origHash, &desiredContent) + if err != nil { + t.Fatalf("ApplyFileDiff with desiredContent should not fail: %v", err) + } + if resolution != "overwritten" { + t.Errorf("resolution = %q, want %q", resolution, "overwritten") + } + if newHash == "" { + t.Fatal("expected non-empty hash") + } + + // Verify file contains the desired content + content, err := os.ReadFile(filepath.Join(repoDir, "file.txt")) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if string(content) != desiredContent { + t.Errorf("content = %q, want %q", string(content), desiredContent) + } +} + +func TestApplyFileDiff_ConflictWithoutDesiredContent(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + writeFile(t, repoDir, "file.txt", "original\n") + runGit(t, repoDir, "add", "file.txt") + runGit(t, repoDir, "commit", "-m", "add file.txt") + + origHash := calculateSHA256("original\n") + writeFile(t, repoDir, "file.txt", "modified-by-agent\n") + + diff := "--- file.txt\n+++ file.txt\n@@ -1 +1 @@\n-original\n+user-version\n" + + // Without desiredContent, conflict should still fail + _, _, err := wt.ApplyFileDiff("file.txt", diff, origHash, nil) + if err == nil { + t.Fatal("expected conflict error, got nil") + } + if !strings.Contains(err.Error(), "conflict detected") { + t.Errorf("expected 'conflict detected' error, got: %v", err) + } +} + +func TestApplyFileDiff_SymlinkConflictWithDesiredContent(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + repoDir, _ = filepath.EvalSymlinks(repoDir) + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + + // Create real file and symlink + realDir := filepath.Join(repoDir, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + writeFile(t, repoDir, "real/target.md", "original\n") + + symlinkPath := filepath.Join(repoDir, "LINK.md") + if err := os.Symlink(filepath.Join(repoDir, "real", "target.md"), symlinkPath); err != nil { + t.Skip("symlinks not supported") + } + + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "add files") + + origHash := calculateSHA256("original\n") + + // Modify the real file behind the diff's back + writeFile(t, repoDir, "real/target.md", "agent-modified\n") + + diff := "--- LINK.md\n+++ LINK.md\n@@ -1 +1 @@\n-original\n+user-version\n" + desiredContent := "user-content\n" + + newHash, resolution, err := wt.ApplyFileDiff("LINK.md", diff, origHash, &desiredContent) + if err != nil { + t.Fatalf("ApplyFileDiff with desiredContent through symlink should not fail: %v", err) + } + if resolution != "overwritten" { + t.Errorf("resolution = %q, want %q", resolution, "overwritten") + } + if newHash == "" { + t.Fatal("expected non-empty hash") + } + + // Verify the real target file has the desired content + content, err := os.ReadFile(filepath.Join(repoDir, "real", "target.md")) + if err != nil { + t.Fatalf("failed to read real file: %v", err) + } + if string(content) != desiredContent { + t.Errorf("real file content = %q, want %q", string(content), desiredContent) + } + + // Verify the symlink still exists + linkInfo, err := os.Lstat(symlinkPath) + if err != nil { + t.Fatalf("failed to lstat symlink: %v", err) + } + if linkInfo.Mode()&os.ModeSymlink == 0 { + t.Error("LINK.md is no longer a symlink after overwrite") + } +} + func TestGetFileContentAtRef(t *testing.T) { repoDir, cleanup := setupTestRepo(t) defer cleanup() diff --git a/apps/backend/internal/agentctl/server/shell/manager.go b/apps/backend/internal/agentctl/server/shell/manager.go new file mode 100644 index 0000000000..976b40033f --- /dev/null +++ b/apps/backend/internal/agentctl/server/shell/manager.go @@ -0,0 +1,101 @@ +package shell + +import ( + "fmt" + "sync" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// Manager manages multiple named shell sessions, one per terminal ID. +type Manager struct { + workDir string + logger *logger.Logger + terminals map[string]*Session + mu sync.RWMutex +} + +// NewManager creates a new shell session manager. +func NewManager(workDir string, log *logger.Logger) *Manager { + return &Manager{ + workDir: workDir, + logger: log.WithFields(zap.String("component", "shell-manager")), + terminals: make(map[string]*Session), + } +} + +// Start creates and starts a new shell session for the given terminal ID. +// If a session already exists for this terminal, it is returned as-is. +func (m *Manager) Start(terminalID string, cfg Config) (*Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if s, ok := m.terminals[terminalID]; ok { + return s, nil + } + + if cfg.WorkDir == "" { + cfg.WorkDir = m.workDir + } + + s, err := NewSession(cfg, m.logger) + if err != nil { + return nil, fmt.Errorf("failed to create shell for terminal %s: %w", terminalID, err) + } + + m.terminals[terminalID] = s + m.logger.Info("terminal shell started", zap.String("terminal_id", terminalID)) + return s, nil +} + +// Get returns the shell session for the given terminal ID. +func (m *Manager) Get(terminalID string) (*Session, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.terminals[terminalID] + return s, ok +} + +// Stop stops and removes the shell session for the given terminal ID. +func (m *Manager) Stop(terminalID string) error { + m.mu.Lock() + s, ok := m.terminals[terminalID] + if !ok { + m.mu.Unlock() + return nil + } + delete(m.terminals, terminalID) + m.mu.Unlock() + + m.logger.Info("stopping terminal shell", zap.String("terminal_id", terminalID)) + return s.Stop() +} + +// Buffer returns the buffered output for the given terminal ID. +func (m *Manager) Buffer(terminalID string) ([]byte, error) { + m.mu.RLock() + s, ok := m.terminals[terminalID] + m.mu.RUnlock() + + if !ok { + return nil, fmt.Errorf("terminal %s not found", terminalID) + } + return s.GetBufferedOutput(), nil +} + +// StopAll stops all terminal shell sessions. +func (m *Manager) StopAll() { + m.mu.Lock() + terminals := make(map[string]*Session, len(m.terminals)) + for id, s := range m.terminals { + terminals[id] = s + } + m.terminals = make(map[string]*Session) + m.mu.Unlock() + + for id, s := range terminals { + m.logger.Info("stopping terminal shell (shutdown)", zap.String("terminal_id", id)) + _ = s.Stop() + } +} diff --git a/apps/backend/internal/agentctl/server/shell/session.go b/apps/backend/internal/agentctl/server/shell/session.go index bc5684b361..2b5b0a6e16 100644 --- a/apps/backend/internal/agentctl/server/shell/session.go +++ b/apps/backend/internal/agentctl/server/shell/session.go @@ -234,6 +234,22 @@ func (s *Session) Write(data []byte) (int, error) { return s.pty.Write(data) } +// Resize changes the terminal dimensions of the shell PTY. +func (s *Session) Resize(cols, rows uint16) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running || s.pty == nil { + return fmt.Errorf("shell not running") + } + + // Store for respawn + s.config.Cols = int(cols) + s.config.Rows = int(rows) + + return pty.Setsize(s.pty, &pty.Winsize{Cols: cols, Rows: rows}) +} + // Subscribe adds a subscriber for shell output func (s *Session) Subscribe(ch chan<- []byte) { s.subMu.Lock() diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index 337a91d3e0..7bb944bb8a 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -128,6 +128,10 @@ type FileContentResponse struct { // When true, Content is base64-encoded. IsBinary bool `json:"is_binary,omitempty"` + // ResolvedPath is the symlink target path (relative to workspace root). + // Only set when the requested file is a symlink. + ResolvedPath string `json:"resolved_path,omitempty"` + // Error contains error message if the request failed. Error string `json:"error,omitempty"` } @@ -165,6 +169,11 @@ type FileUpdateRequest struct { // OriginalHash is the SHA256 hash of the original file content. // Used for conflict detection. OriginalHash string `json:"original_hash"` + + // DesiredContent is the full file content the user wants to save. + // Used as a fallback when the diff cannot be applied (e.g., hash conflict). + // Nil means no fallback; pointer to empty string means save as empty file. + DesiredContent *string `json:"desired_content,omitempty"` } // FileUpdateResponse represents a response to a file update request. @@ -181,6 +190,10 @@ type FileUpdateResponse struct { // NewHash is the SHA256 hash of the updated file content. NewHash string `json:"new_hash,omitempty"` + // Resolution indicates how the update was applied. + // "applied" = normal diff apply, "overwritten" = full content write fallback. + Resolution string `json:"resolution,omitempty"` + // Error contains error message if the request failed. Error string `json:"error,omitempty"` } diff --git a/apps/backend/internal/gateway/websocket/port_proxy.go b/apps/backend/internal/gateway/websocket/port_proxy.go new file mode 100644 index 0000000000..2f0619ac89 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/port_proxy.go @@ -0,0 +1,185 @@ +package websocket + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "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" +) + +// portProxyEntry caches a reverse proxy and its target for a session:port pair. +type portProxyEntry struct { + proxy *httputil.ReverseProxy + target string +} + +// PortProxyHandler reverse-proxies HTTP and WebSocket traffic to arbitrary +// localhost ports inside a remote executor via agentctl. +type PortProxyHandler struct { + lifecycleMgr *lifecycle.Manager + logger *logger.Logger + + mu sync.Mutex + proxies map[string]*portProxyEntry // key: "sessionId:port" +} + +// NewPortProxyHandler creates a new port proxy handler. +func NewPortProxyHandler(lifecycleMgr *lifecycle.Manager, log *logger.Logger) *PortProxyHandler { + return &PortProxyHandler{ + lifecycleMgr: lifecycleMgr, + logger: log.WithFields(zap.String("component", "port-proxy")), + proxies: make(map[string]*portProxyEntry), + } +} + +// HandlePortProxy handles all HTTP/WS requests to /port-proxy/:sessionId/:port/*path. +func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { + sessionID := c.Param("sessionId") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"}) + return + } + + portStr := c.Param("port") + port, err := strconv.Atoi(portStr) + if err != nil || port < 1024 || port > 65535 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid port: must be 1024-65535"}) + return + } + + proxy, err := h.resolveProxy(c, sessionID, port) + if err != nil { + return // error already written to response + } + + // Rewrite path: strip /port-proxy/:sessionId/:port prefix, + // forward as /api/v1/port-proxy/:port/{remainingPath} to agentctl. + originalPath := c.Request.URL.Path + prefix := "/port-proxy/" + sessionID + "/" + portStr + remaining := strings.TrimPrefix(originalPath, prefix) + if remaining == "" { + remaining = "/" + } + c.Request.URL.Path = "/api/v1/port-proxy/" + portStr + remaining + c.Request.URL.RawPath = "" + + h.logger.Debug("port proxy forwarding", + zap.String("session_id", sessionID), + zap.Int("port", port), + zap.String("original_path", originalPath), + zap.String("rewritten_path", c.Request.URL.Path)) + + defer func() { + if r := recover(); r != nil { + if r == http.ErrAbortHandler { + h.logger.Debug("port proxy: client disconnected", + zap.String("session_id", sessionID), + zap.Int("port", port)) + return + } + panic(r) + } + }() + + proxy.ServeHTTP(c.Writer, c.Request) +} + +func (h *PortProxyHandler) resolveProxy(c *gin.Context, sessionID string, port int) (*httputil.ReverseProxy, error) { + cacheKey := sessionID + ":" + strconv.Itoa(port) + + h.mu.Lock() + defer h.mu.Unlock() + + if entry, ok := h.proxies[cacheKey]; ok { + return entry.proxy, nil + } + + 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") + } + + baseURL := agentctlClient.BaseURL() + target, err := url.Parse(baseURL) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "failed to resolve agentctl target"}) + return nil, err + } + + proxy := h.createProxy(cacheKey, target) + h.proxies[cacheKey] = &portProxyEntry{proxy: proxy, target: baseURL} + + h.logger.Info("created port proxy", + zap.String("session_id", sessionID), + zap.Int("port", port), + zap.String("execution_id", execution.ID), + zap.String("target", baseURL)) + return proxy, nil +} + +func (h *PortProxyHandler) createProxy(cacheKey string, target *url.URL) *httputil.ReverseProxy { + proxy := &httputil.ReverseProxy{} + proxy.Rewrite = func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.URL.Path = r.In.URL.Path + r.Out.URL.RawPath = "" + if r.Out.Header.Get("Upgrade") != "" { + r.Out.Header.Set("Connection", "Upgrade") + } + } + + proxy.ModifyResponse = func(resp *http.Response) error { + 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("port proxy error", + zap.String("cache_key", cacheKey), + zap.String("request_path", r.URL.Path), + zap.Error(err)) + h.invalidateProxy(cacheKey) + http.Error(w, "port proxy error", http.StatusBadGateway) + } + + // Flush immediately for SSE/streaming responses. + proxy.FlushInterval = -1 + + return proxy +} + +func (h *PortProxyHandler) invalidateProxy(cacheKey string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.proxies, cacheKey) +} + +// InvalidateSession removes all cached proxies for a session. +func (h *PortProxyHandler) InvalidateSession(sessionID string) { + h.mu.Lock() + defer h.mu.Unlock() + prefix := sessionID + ":" + for key := range h.proxies { + if strings.HasPrefix(key, prefix) { + delete(h.proxies, key) + } + } +} diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index fc306c12bf..8befd454ac 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -18,6 +18,7 @@ type Gateway struct { TerminalHandler *TerminalHandler LSPHandler *LSPHandler VscodeProxyHandler *VscodeProxyHandler + PortProxyHandler *PortProxyHandler logger *logger.Logger } @@ -54,6 +55,11 @@ func (g *Gateway) SetVscodeProxy(lifecycleMgr *lifecycle.Manager) { g.VscodeProxyHandler = NewVscodeProxyHandler(lifecycleMgr, g.logger) } +// SetPortProxy enables the generic port reverse proxy handler. +func (g *Gateway) SetPortProxy(lifecycleMgr *lifecycle.Manager) { + g.PortProxyHandler = NewPortProxyHandler(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) @@ -72,4 +78,10 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) { if g.VscodeProxyHandler != nil { router.Any("/vscode/:sessionId/*path", g.VscodeProxyHandler.HandleVscodeProxy) } + + // Add generic port proxy routes if configured + if g.PortProxyHandler != nil { + router.Any("/port-proxy/:sessionId/:port", g.PortProxyHandler.HandlePortProxy) + router.Any("/port-proxy/:sessionId/:port/*path", g.PortProxyHandler.HandlePortProxy) + } } diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index 4a2aebfdc4..b1992fc991 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -5,12 +5,10 @@ import ( "bytes" "context" "encoding/json" - "fmt" "io" "net/http" "net/url" "regexp" - "strconv" "strings" "sync" "time" @@ -21,7 +19,6 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agent/lifecycle" - agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/scripts" @@ -264,17 +261,7 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter c.JSON(http.StatusNotFound, gin.H{"error": "remote execution not available"}) return } - baseClient := execution.GetAgentCtlClient() - streamClient, err := cloneAgentctlClientForWorkspace(baseClient, h.logger) - if err != nil { - h.logger.Error("failed to create remote shell stream client", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - defer streamClient.Close() + client := execution.GetAgentCtlClient() _, initialCommand, httpErr := h.resolveShellLabel(c) if httpErr != "" { @@ -282,8 +269,9 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter return } - if err := baseClient.StartShell(c.Request.Context()); err != nil { - h.logger.Error("failed to start remote shell", + // Create a per-terminal shell on agentctl (idempotent if already exists) + if err := client.StartShellTerminal(c.Request.Context(), terminalID, 80, 24); err != nil { + h.logger.Error("failed to start remote terminal shell", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), zap.Error(err)) @@ -291,8 +279,21 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter return } - conn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) + // Open binary WS to the per-terminal shell on agentctl + agentctlConn, err := client.StreamShellTerminal(c.Request.Context(), terminalID) if err != nil { + h.logger.Error("failed to connect to remote terminal shell stream", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID), + zap.Error(err)) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + + // Upgrade the client-facing WebSocket + clientConn, err := terminalUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + _ = agentctlConn.Close() h.logger.Error("failed to upgrade remote shell websocket", zap.String("session_id", sessionID), zap.String("terminal_id", terminalID), @@ -300,96 +301,77 @@ func (h *TerminalHandler) handleRemoteUserShellWS(c *gin.Context, sessionID, ter return } - stream, err := streamClient.StreamWorkspace(c.Request.Context(), agentctl.WorkspaceStreamCallbacks{ - OnShellOutput: func(data string) { - _ = conn.WriteMessage(gorillaws.BinaryMessage, []byte(data)) - }, - }) - if err != nil { - _ = conn.Close() - h.logger.Error("failed to open remote workspace stream", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - return - } defer func() { - stream.Close() - _ = conn.Close() + _ = agentctlConn.Close() + _ = clientConn.Close() }() - // Replay recent buffered shell output so prompt/current state appears immediately - // without requiring user input to trigger the first visible update. - if buffered, err := baseClient.ShellBuffer(c.Request.Context()); err == nil && buffered != "" { - _ = conn.WriteMessage(gorillaws.BinaryMessage, []byte(buffered)) - } + h.logger.Info("remote terminal shell WebSocket connected", + zap.String("session_id", sessionID), + zap.String("terminal_id", terminalID)) + // Send initial command if specified if initialCommand != "" { - _ = stream.WriteShellInput(initialCommand + "\n") + if err := agentctlConn.WriteMessage(gorillaws.BinaryMessage, []byte(initialCommand+"\n")); err != nil { + h.logger.Debug("failed to send initial command to remote terminal shell", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } } - h.remoteShellReadLoop(conn, stream, sessionID, terminalID) + h.bridgeBinaryWebSockets(clientConn, agentctlConn, terminalID) } -func (h *TerminalHandler) remoteShellReadLoop(conn *gorillaws.Conn, stream *agentctl.WorkspaceStream, sessionID, terminalID string) { - for { - messageType, data, readErr := conn.ReadMessage() - if readErr != nil { - if !gorillaws.IsCloseError(readErr, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) { - h.logger.Debug("remote shell websocket read error", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(readErr)) +// bridgeBinaryWebSockets forwards binary WebSocket frames bidirectionally between +// a client connection and an agentctl connection until either side disconnects. +func (h *TerminalHandler) bridgeBinaryWebSockets(clientConn, agentctlConn *gorillaws.Conn, terminalID string) { + done := make(chan struct{}) + + // agentctl → client (shell output) + go func() { + defer close(done) + for { + msgType, data, err := agentctlConn.ReadMessage() + if err != nil { + if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) { + h.logger.Debug("remote terminal shell upstream read error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + return + } + if err := clientConn.WriteMessage(msgType, data); err != nil { + h.logger.Debug("remote terminal shell client write error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + return } - return } - if messageType != gorillaws.BinaryMessage && messageType != gorillaws.TextMessage { - continue - } - if len(data) > 0 { - h.processRemoteShellMessage(data, stream, sessionID, terminalID) - } - } -} + }() -func (h *TerminalHandler) processRemoteShellMessage(data []byte, stream *agentctl.WorkspaceStream, sessionID, terminalID string) { - if data[0] == resizeCommandByte { - var resize ResizePayload - if err := json.Unmarshal(data[1:], &resize); err != nil { - h.logger.Debug("failed to decode remote shell resize", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - return + // client → agentctl (input + resize) + go func() { + for { + msgType, data, err := clientConn.ReadMessage() + if err != nil { + if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) { + h.logger.Debug("remote terminal shell client read error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + } + _ = agentctlConn.Close() + return + } + if err := agentctlConn.WriteMessage(msgType, data); err != nil { + h.logger.Debug("remote terminal shell upstream write error", + zap.String("terminal_id", terminalID), + zap.Error(err)) + return + } } - if err := stream.ResizeShell(int(resize.Cols), int(resize.Rows)); err != nil { - h.logger.Debug("failed to resize remote shell", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Uint16("cols", resize.Cols), - zap.Uint16("rows", resize.Rows), - zap.Error(err)) - } - return - } - if err := stream.WriteShellInput(string(data)); err != nil { - h.logger.Debug("failed to write remote shell input", - zap.String("session_id", sessionID), - zap.String("terminal_id", terminalID), - zap.Error(err)) - } -} + }() -func cloneAgentctlClientForWorkspace(base *agentctl.Client, log *logger.Logger) (*agentctl.Client, error) { - parsed, err := url.Parse(base.BaseURL()) - if err != nil { - return nil, fmt.Errorf("invalid agentctl base url: %w", err) - } - port, err := strconv.Atoi(parsed.Port()) - if err != nil { - return nil, fmt.Errorf("invalid agentctl port: %w", err) - } - return agentctl.NewClient(parsed.Hostname(), port, log), nil + <-done } // handleAgentPassthroughWS handles WebSocket connections for agent passthrough terminals. diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index fba457fe93..e9449d1aa9 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -51,7 +51,6 @@ var ( ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") ErrExecutionNotFound = errors.New("execution not found") ErrExecutionAlreadyRunning = errors.New("execution already running") - ErrRemoteDockerNoRepoURL = errors.New("remote_docker executor requires a repository with provider owner and name set") ErrNoCloneURL = errors.New("repository has no clone URL: provider owner and name are required") ) @@ -282,12 +281,20 @@ type TaskStateChangeFunc func(ctx context.Context, taskID string, state v1.TaskS // publish events (e.g. WebSocket notifications) alongside the DB update. type SessionStateChangeFunc func(ctx context.Context, taskID, sessionID string, state models.TaskSessionState, errorMessage string) error +// ExecutorTypeCapabilities provides behavioral queries about executor types. +// Implemented by the lifecycle manager using its backend registry. +type ExecutorTypeCapabilities interface { + RequiresCloneURL(executorType string) bool + ShouldApplyPreferredShell(executorType string) bool +} + // Executor manages agent execution for tasks type Executor struct { agentManager AgentManagerClient repo executorStore secretStore secrets.SecretStore shellPrefs ShellPreferenceProvider + capabilities ExecutorTypeCapabilities logger *logger.Logger // Configuration @@ -368,3 +375,8 @@ func (e *Executor) SetRepoCloner(cloner RepoCloner, updater RepoUpdater) { e.repoCloner = cloner e.repoUpdater = updater } + +// SetCapabilities sets the executor type capabilities provider. +func (e *Executor) SetCapabilities(c ExecutorTypeCapabilities) { + e.capabilities = c +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index e0dcbd5df7..fbc0439fec 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -88,15 +88,6 @@ func shouldUseWorktree(executorType string) bool { return models.ExecutorType(executorType) == models.ExecutorTypeWorktree } -func shouldApplyPreferredShell(executorType string) bool { - switch models.ExecutorType(executorType) { - case models.ExecutorTypeLocal, models.ExecutorTypeWorktree: - return true - default: - return false - } -} - // repositoryCloneURL builds an HTTPS clone URL from the repository's provider info. // Returns an empty string if the repository has no provider owner/name or if the // provider is not recognized. @@ -127,7 +118,7 @@ func (e *Executor) getSessionLock(sessionID string) *sync.Mutex { } func (e *Executor) applyPreferredShellEnv(ctx context.Context, executorType string, env map[string]string) map[string]string { - if !shouldApplyPreferredShell(executorType) { + if e.capabilities == nil || !e.capabilities.ShouldApplyPreferredShell(executorType) { return env } if e.shellPrefs == nil { @@ -462,11 +453,11 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s } } - // Remote Docker needs a clone URL since the remote host has no access to the local filesystem. - if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker && repoInfo.Repository != nil { + // Remote executors need a clone URL since the remote host has no access to the local filesystem. + if e.capabilities != nil && e.capabilities.RequiresCloneURL(execConfig.ExecutorType) && repoInfo.Repository != nil { cloneURL := repositoryCloneURL(repoInfo.Repository) if cloneURL == "" { - return nil, execConfig, ErrRemoteDockerNoRepoURL + return nil, execConfig, ErrNoCloneURL } req.RepositoryURL = cloneURL } diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index e8a82a0e5a..3c0c8514bf 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -297,10 +297,10 @@ func (e *Executor) applyRepositoryToSwitchRequest(ctx context.Context, req *Laun } req.RepositoryURL = repository.LocalPath req.Branch = session.BaseBranch - if models.ExecutorType(execConfig.ExecutorType) == models.ExecutorTypeRemoteDocker { + if e.capabilities != nil && e.capabilities.RequiresCloneURL(execConfig.ExecutorType) { cloneURL := repositoryCloneURL(repository) if cloneURL == "" { - return "", ErrRemoteDockerNoRepoURL + return "", ErrNoCloneURL } req.RepositoryURL = cloneURL } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index ba9b327897..ea630468ad 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -490,10 +490,10 @@ func (e *Executor) applyResumeRepoConfig(ctx context.Context, task *v1.Task, ses req.Metadata[lifecycle.MetadataKeyRepoSetupScript] = repository.SetupScript } - if models.ExecutorType(req.ExecutorType) == models.ExecutorTypeRemoteDocker { + if e.capabilities != nil && e.capabilities.RequiresCloneURL(req.ExecutorType) { cloneURL := repositoryCloneURL(repository) if cloneURL == "" { - return "", ErrRemoteDockerNoRepoURL + return "", ErrNoCloneURL } req.RepositoryURL = cloneURL } diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 13315dc10e..be02073574 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -597,9 +597,11 @@ func newTestExecutor(t *testing.T, agentManager AgentManagerClient, repo *mockRe if err != nil { t.Fatalf("failed to create logger: %v", err) } - return NewExecutor(agentManager, repo, log, ExecutorConfig{ + exec := NewExecutor(agentManager, repo, log, ExecutorConfig{ ShellPrefs: &mockShellPrefs{}, }) + exec.SetCapabilities(&mockCapabilities{}) + return exec } // Tests @@ -989,22 +991,24 @@ func TestShouldUseWorktree(t *testing.T) { } } -func TestShouldApplyPreferredShell(t *testing.T) { - tests := []struct { - executorType string - want bool - }{ - {"local", true}, - {"worktree", true}, - {"local_docker", false}, - {"remote_docker", false}, - {"sprites", false}, - {"", false}, +// mockCapabilities implements ExecutorTypeCapabilities for testing. +type mockCapabilities struct{} + +func (m *mockCapabilities) RequiresCloneURL(executorType string) bool { + switch models.ExecutorType(executorType) { + case models.ExecutorTypeRemoteDocker, models.ExecutorTypeSprites: + return true + default: + return false } - for _, tt := range tests { - if got := shouldApplyPreferredShell(tt.executorType); got != tt.want { - t.Errorf("shouldApplyPreferredShell(%q) = %v, want %v", tt.executorType, got, tt.want) - } +} + +func (m *mockCapabilities) ShouldApplyPreferredShell(executorType string) bool { + switch models.ExecutorType(executorType) { + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree, models.ExecutorTypeMockRemote: + return true + default: + return false } } diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 05ce155697..4d639e2535 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -275,6 +275,9 @@ func NewService( s.updateTaskSessionState(ctx, taskID, sessionID, state, errorMessage, true) return nil }) + if caps, ok := agentManager.(executor.ExecutorTypeCapabilities); ok { + exec.SetCapabilities(caps) + } // Create the watcher with event handlers that wire everything together handlers := watcher.EventHandlers{ @@ -570,7 +573,7 @@ func (s *Service) reconcileSessionsOnStartup(ctx context.Context) { var remoteRecords []executor.RemoteStatusPollRequest for _, running := range runningExecutors { - if isRemoteRuntime(running.Runtime) { + if models.IsRemoteExecutorType(models.ExecutorType(running.Runtime)) { remoteRecords = append(remoteRecords, executor.RemoteStatusPollRequest{ SessionID: running.SessionID, Runtime: running.Runtime, @@ -656,11 +659,6 @@ func (s *Service) reconcileOneSessionOnStartup(ctx context.Context, running *mod zap.Bool("has_worktree", running.WorktreePath != "")) } -// isRemoteRuntime checks if a runtime string corresponds to a remote executor type. -func isRemoteRuntime(runtime string) bool { - return runtime == string(models.ExecutorTypeSprites) || runtime == string(models.ExecutorTypeRemoteDocker) -} - // handleTerminalSessionOnStartup processes sessions in terminal states during startup. // Returns true if the session should be skipped (no further processing needed). func (s *Service) handleTerminalSessionOnStartup(ctx context.Context, session *models.TaskSession, running *models.ExecutorRunning, previousState models.TaskSessionState) bool { @@ -724,7 +722,7 @@ func canResumeRunning(running *models.ExecutorRunning) bool { if running.Resumable { return true } - return running.Runtime == string(models.ExecutorTypeSprites) + return models.IsAlwaysResumableRuntime(running.Runtime) } // IsRunning returns true if the service is running diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 41ec298373..ce71709495 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -856,11 +856,7 @@ func (s *Service) populateExecutorStatusInfo(ctx context.Context, session *model } resp.ExecutorType = string(execModel.Type) resp.ExecutorName = execModel.Name - resp.IsRemoteExecutor = isRemoteExecutorType(execModel.Type) -} - -func isRemoteExecutorType(t models.ExecutorType) bool { - return t == models.ExecutorTypeSprites || t == models.ExecutorTypeRemoteDocker + resp.IsRemoteExecutor = models.IsRemoteExecutorType(execModel.Type) } func (s *Service) applyRemoteRuntimeStatus(ctx context.Context, sessionID string, resp *dto.TaskSessionStatusResponse) { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index c754d29aed..9af9b70fd3 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -507,7 +507,7 @@ func FromTaskWithSessionInfo( PrimaryAgentName: primaryAgentName, PrimaryWorkingDirectory: primaryWorkingDirectory, PrimarySessionState: primarySessionState, - IsRemoteExecutor: primaryExecutorType != nil && (*primaryExecutorType == string(models.ExecutorTypeSprites) || *primaryExecutorType == string(models.ExecutorTypeRemoteDocker)), + IsRemoteExecutor: primaryExecutorType != nil && models.IsRemoteExecutorType(models.ExecutorType(*primaryExecutorType)), ArchivedAt: task.ArchivedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index ed7ca7f022..66286dfc8f 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -353,8 +353,26 @@ const ( ExecutorTypeLocalDocker ExecutorType = "local_docker" ExecutorTypeRemoteDocker ExecutorType = "remote_docker" ExecutorTypeSprites ExecutorType = "sprites" + ExecutorTypeMockRemote ExecutorType = "mock_remote" ) +// IsRemoteExecutorType reports whether the given executor type represents +// a remote execution environment. +func IsRemoteExecutorType(t ExecutorType) bool { + switch t { + case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeMockRemote: + return true + default: + return false + } +} + +// IsAlwaysResumableRuntime reports whether the given runtime string represents +// an executor that can always be resumed even without an explicit resume token. +func IsAlwaysResumableRuntime(runtime string) bool { + return ExecutorType(runtime) == ExecutorTypeSprites +} + const ( ExecutorIDLocal = "exec-local" ExecutorIDWorktree = "exec-worktree" diff --git a/apps/backend/internal/tools/installer/github_tarball_strategy.go b/apps/backend/internal/tools/installer/github_tarball_strategy.go index e06cdba013..0bd67ab1e0 100644 --- a/apps/backend/internal/tools/installer/github_tarball_strategy.go +++ b/apps/backend/internal/tools/installer/github_tarball_strategy.go @@ -170,13 +170,9 @@ func extractTarEntry(tr *tar.Reader, header *tar.Header, destDir string) error { return writeFileFromTar(tr, target, os.FileMode(header.Mode)) case tar.TypeSymlink: // Validate symlink target to prevent path traversal attacks - // Reject absolute paths and paths containing ".." if filepath.IsAbs(header.Linkname) { return fmt.Errorf("symlink target must not be absolute: %s -> %s", header.Name, header.Linkname) } - if strings.Contains(header.Linkname, "..") { - return fmt.Errorf("symlink target must not contain '..': %s -> %s", header.Name, header.Linkname) - } // Resolve the symlink target path relative to the symlink's location symlinkDir := filepath.Dir(target) diff --git a/apps/backend/internal/tools/installer/github_tarball_strategy_test.go b/apps/backend/internal/tools/installer/github_tarball_strategy_test.go index adbabc2f0f..fa71a43947 100644 --- a/apps/backend/internal/tools/installer/github_tarball_strategy_test.go +++ b/apps/backend/internal/tools/installer/github_tarball_strategy_test.go @@ -206,6 +206,90 @@ func TestExtractTarGz_SymlinkEscapeBlocked(t *testing.T) { } } +func TestExtractTarGz_SymlinkWithRelativeDotDot(t *testing.T) { + // Legitimate relative symlinks using ".." (like npm .bin links) should be allowed + // as long as the resolved target stays within destDir. + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + // Create directory structure: pkg/node_modules/.bin/ and pkg/node_modules/semver/bin/ + for _, dir := range []string{ + "pkg/node_modules/.bin/", + "pkg/node_modules/semver/bin/", + } { + _ = tarWriter.WriteHeader(&tar.Header{ + Name: dir, + Typeflag: tar.TypeDir, + Mode: 0o755, + }) + } + + // Create the target file + content := []byte("#!/bin/sh\necho semver") + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "pkg/node_modules/semver/bin/semver.js", + Typeflag: tar.TypeReg, + Mode: 0o755, + Size: int64(len(content)), + }) + _, _ = tarWriter.Write(content) + + // Create symlink: .bin/semver -> ../semver/bin/semver.js (stays within destDir) + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "pkg/node_modules/.bin/semver", + Typeflag: tar.TypeSymlink, + Linkname: "../semver/bin/semver.js", + }) + + _ = tarWriter.Close() + _ = gzWriter.Close() + + destDir := t.TempDir() + if err := extractTarGz(&buf, destDir); err != nil { + t.Fatalf("extractTarGz should allow relative '..' symlinks within destDir, got: %v", err) + } + + // Verify the symlink was created + linkPath := filepath.Join(destDir, "pkg/node_modules/.bin/semver") + linkTarget, err := os.Readlink(linkPath) + if err != nil { + t.Fatalf("failed to read symlink: %v", err) + } + if linkTarget != "../semver/bin/semver.js" { + t.Errorf("expected symlink target '../semver/bin/semver.js', got %q", linkTarget) + } +} + +func TestExtractTarGz_SymlinkDotDotEscapeBlocked(t *testing.T) { + // A ".." symlink that escapes destDir must still be blocked by the resolved-path check. + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzWriter) + + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "pkg/", + Typeflag: tar.TypeDir, + Mode: 0o755, + }) + + // Symlink that uses ".." to escape the destDir + _ = tarWriter.WriteHeader(&tar.Header{ + Name: "pkg/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 escaping destDir, got nil") + } +} + func TestResolveTarget_Unsupported(t *testing.T) { s := &GithubTarballStrategy{ config: GithubTarballConfig{ diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index eaa7f06237..e124227c11 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -260,6 +260,9 @@ const ( ActionVscodeStatus = "vscode.status" // Get code-server status for a session ActionVscodeOpenFile = "vscode.openFile" // Open a file in code-server for a session + // Port actions + ActionPortList = "port.list" // List listening ports on a remote executor + // Secret actions ActionSecretList = "secrets.list" ActionSecretCreate = "secrets.create" diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index 1e920a0df6..131ffdcd95 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -111,7 +111,8 @@ export async function runStart({ // Production mode: use warn log level for clean output unless verbose/debug const showOutput = verbose || debug; const dbPath = process.env.KANDEV_DATABASE_PATH || path.join(DATA_DIR, "kandev.db"); - const logLevel = debug ? "debug" : verbose ? "info" : "warn"; + const logLevel = + process.env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : verbose ? "info" : "warn"); const backendEnv = buildBackendEnv({ ports, logLevel, diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ee166e6053..634525e9bb 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -41,10 +41,13 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - // Extract ports from server-side env vars - // We inject only ports, not full URLs, so client can build URLs from window.location.hostname - // This allows accessing the app from any device (iPhone, Tailscale, etc.) - const apiPort = extractPort(process.env.KANDEV_API_BASE_URL ?? ""); + // Runtime URL configuration for the browser client. + // KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress + // (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL. + // Otherwise: extract ports so client builds URLs from window.location.hostname, + // allowing access from any device (iPhone, Tailscale, etc.) + const publicUrl = process.env.KANDEV_PUBLIC_URL?.replace(/\/$/, "") || null; + const apiPort = publicUrl ? null : extractPort(process.env.KANDEV_API_BASE_URL ?? ""); const mcpPort = extractPort(process.env.KANDEV_MCP_SERVER_URL ?? ""); const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true"; @@ -54,12 +57,14 @@ export default function RootLayout({
- {apiPort || mcpPort || debugMode ? ( - // Inject runtime config for production bundles where ports are chosen at launch. - // Client will build full URLs using window.location.hostname + port + {publicUrl || apiPort || mcpPort || debugMode ? ( + // Inject runtime config for production bundles. + // publicUrl: full base URL for reverse-proxy deployments (same-origin API) + // ports: client builds URLs using window.location.hostname + port