feat: add port proxy, symlink file save fix, and remote executor improvements (#358)

This commit is contained in:
Carlos Florêncio
2026-03-07 14:53:21 +00:00
committed by GitHub
parent 74a8d7563e
commit 0da44478d1
67 changed files with 2465 additions and 264 deletions
+3 -1
View File
@@ -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
+4
View File
@@ -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)
@@ -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
}
@@ -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,
})
}
@@ -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)
@@ -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}}
@@ -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.
@@ -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 }
@@ -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"})
@@ -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 }
@@ -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 }
@@ -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 }
@@ -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()
}
@@ -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.
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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)
@@ -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})
}
@@ -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,
})
}
@@ -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.
@@ -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.
@@ -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()
@@ -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()
}
}
@@ -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()
@@ -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"`
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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.
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
}
@@ -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
@@ -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) {
+1 -1
View File
@@ -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,
@@ -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"
@@ -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)
@@ -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{
+3
View File
@@ -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"
+2 -1
View File
@@ -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,
+12 -7
View File
@@ -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({
<meta name="apple-mobile-web-app-title" content="KanDev" />
</head>
<body className="antialiased font-sans">
{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
<script
dangerouslySetInnerHTML={{
__html: [
publicUrl ? `window.__KANDEV_API_BASE_URL = ${JSON.stringify(publicUrl)};` : "",
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
mcpPort ? `window.__KANDEV_MCP_PORT = ${JSON.stringify(mcpPort)};` : "",
debugMode ? `window.__KANDEV_DEBUG = true;` : "",
+25 -22
View File
@@ -242,6 +242,30 @@ function FileDiffHeader({
);
}
async function revertBlock(sessionId: string, filePath: string, info: RevertBlockInfo) {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, filePath);
if (response.error) return;
const currentContent = response.content;
const hash = await calculateHash(currentContent);
const lines = currentContent.split("\n");
lines.splice(info.addStart - 1, info.addCount, ...info.oldLines);
const nextContent = lines.join("\n");
if (nextContent === currentContent) return;
const patch = generateUnifiedDiff(currentContent, nextContent, filePath);
if (!patch || !/^@@/m.test(patch)) return;
await updateFileContent(client, sessionId, {
path: filePath,
diff: patch,
originalHash: hash,
});
} catch (err) {
console.error("Failed to revert change block:", err);
}
}
function FileDiffSection({
file,
isReviewed,
@@ -283,28 +307,7 @@ function FileDiffSection({
}, [file.path, onDiscard]);
const handleRevertBlock = useCallback(
async (filePath: string, info: RevertBlockInfo) => {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, filePath);
if (response.error) return;
const currentContent = response.content;
const hash = await calculateHash(currentContent);
const lines = currentContent.split("\n");
lines.splice(info.addStart - 1, info.addCount, ...info.oldLines);
const nextContent = lines.join("\n");
if (nextContent === currentContent) return;
const patch = generateUnifiedDiff(currentContent, nextContent, filePath);
// Guard against no-op diffs ("No valid patches in input") from createTwoFilesPatch.
if (!patch || !/^@@/m.test(patch)) return;
await updateFileContent(client, sessionId, filePath, patch, hash);
} catch (err) {
console.error("Failed to revert change block:", err);
}
},
(filePath: string, info: RevertBlockInfo) => revertBlock(sessionId, filePath, info),
[sessionId],
);
@@ -87,6 +87,9 @@ function StepRow({ step }: { step: PrepareStepInfo }) {
function useEffectivePrepareStatus(sessionId: string) {
const prepareState = useAppStore((state) => state.prepareProgress.bySessionId[sessionId] ?? null);
const sessionState = useAppStore((state) => state.taskSessions.items[sessionId]?.state);
const agentctlStatus = useAppStore(
(state) => state.sessionAgentctl.itemsBySessionId[sessionId]?.status,
);
const profileLabel = useAppStore((state) => {
const session = state.taskSessions.items[sessionId];
if (!session?.agent_profile_id) return null;
@@ -121,6 +124,11 @@ function useEffectivePrepareStatus(sessionId: string) {
hasWarnings: true,
} as const;
// Agentctl ready implies environment preparation succeeded —
// dismiss even if the completed event hasn't arrived yet.
if (agentctlStatus === "ready")
return { visible: false, status: "completed", prepareState, profileLabel } as const;
// If session reached a terminal state but prepare status is still "preparing",
// treat it as failed (the completed event may not have arrived)
const isSessionTerminal =
@@ -0,0 +1,295 @@
"use client";
import { useState, useCallback } from "react";
import {
IconNetwork,
IconExternalLink,
IconCopy,
IconCheck,
IconRefresh,
IconPlus,
IconLoader2,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Badge } from "@kandev/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@kandev/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { listPorts, type ListeningPort } from "@/lib/api/domains/port-api";
import { getBackendConfig } from "@/lib/config";
import { toast } from "sonner";
function buildPortProxyUrl(sessionId: string, port: number): string {
const backendUrl = getBackendConfig().apiBaseUrl;
return `${backendUrl}/port-proxy/${sessionId}/${port}/`;
}
function CopyUrlButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
navigator.clipboard?.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [url]);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="cursor-pointer h-7 w-7 p-0"
onClick={handleCopy}
>
{copied ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconCopy className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy URL</TooltipContent>
</Tooltip>
);
}
type PortRowProps = {
port: number;
address?: string;
sessionId: string;
badge: "Detected" | "Manual";
};
function PortRow({ port, address, sessionId, badge }: PortRowProps) {
const proxyUrl = buildPortProxyUrl(sessionId, port);
return (
<div
data-testid={`port-forward-row-${port}`}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 hover:bg-muted/60 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-mono font-medium">{port}</span>
{address && address !== "0.0.0.0" && address !== "*" && (
<span className="text-xs text-muted-foreground">{address}</span>
)}
<Badge
variant={badge === "Detected" ? "secondary" : "outline"}
className="text-[10px] px-1.5 py-0"
>
{badge}
</Badge>
</div>
<div className="flex items-center gap-0.5">
<CopyUrlButton url={proxyUrl} />
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="ghost" className="cursor-pointer h-7 w-7 p-0" asChild>
<a href={proxyUrl} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open in new tab</TooltipContent>
</Tooltip>
</div>
</div>
);
}
function PortListSection({
detectedPorts,
manualPorts,
sessionId,
loading,
loaded,
onRefresh,
}: {
detectedPorts: ListeningPort[];
manualPorts: number[];
sessionId: string;
loading: boolean;
loaded: boolean;
onRefresh: () => void;
}) {
const detectedPortNumbers = new Set(detectedPorts.map((p) => p.port));
const uniqueManualPorts = manualPorts.filter((p) => !detectedPortNumbers.has(p));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Listening Ports</span>
<Button
size="sm"
variant="ghost"
data-testid="port-forward-refresh"
className="cursor-pointer h-7 gap-1 text-xs"
onClick={onRefresh}
disabled={loading}
>
{loading ? (
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<IconRefresh className="h-3.5 w-3.5" />
)}
Refresh
</Button>
</div>
{!loaded && !loading && (
<p className="text-xs text-muted-foreground">Click refresh to detect listening ports.</p>
)}
{loaded && detectedPorts.length === 0 && !loading && (
<p className="text-xs text-muted-foreground">No listening ports detected.</p>
)}
<div className="space-y-1">
{detectedPorts.map((p) => (
<PortRow
key={`d-${p.port}`}
port={p.port}
address={p.address}
sessionId={sessionId}
badge="Detected"
/>
))}
{uniqueManualPorts.map((port) => (
<PortRow key={`m-${port}`} port={port} sessionId={sessionId} badge="Manual" />
))}
</div>
</div>
);
}
function ManualPortInput({ onAdd }: { onAdd: (port: number) => void }) {
const [value, setValue] = useState("");
const handleAdd = useCallback(() => {
const port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
toast.error("Enter a valid port (1-65535)");
return;
}
onAdd(port);
setValue("");
}, [value, onAdd]);
return (
<div className="space-y-2">
<span className="text-sm font-medium">Add Port Manually</span>
<div className="flex gap-2">
<Input
data-testid="port-forward-port-input"
type="number"
placeholder="Port number"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAdd())}
className="h-8"
min={1}
max={65535}
/>
<Button
size="sm"
variant="outline"
data-testid="port-forward-add-button"
className="cursor-pointer h-8 gap-1"
onClick={handleAdd}
>
<IconPlus className="h-3.5 w-3.5" />
Add
</Button>
</div>
</div>
);
}
function PortForwardDialogContent({ sessionId }: { sessionId: string }) {
const [detectedPorts, setDetectedPorts] = useState<ListeningPort[]>([]);
const [manualPorts, setManualPorts] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const ports = await listPorts(sessionId);
setDetectedPorts(ports);
setLoaded(true);
} finally {
setLoading(false);
}
}, [sessionId]);
const handleAddManual = useCallback(
(port: number) => {
if (manualPorts.includes(port)) {
toast.error("Port already added");
return;
}
setManualPorts((prev) => [...prev, port]);
},
[manualPorts],
);
return (
<DialogContent
data-testid="port-forward-dialog"
className="sm:max-w-md"
onOpenAutoFocus={() => !loaded && refresh()}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconNetwork className="h-5 w-5" />
Port Forwarding
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<PortListSection
detectedPorts={detectedPorts}
manualPorts={manualPorts}
sessionId={sessionId}
loading={loading}
loaded={loaded}
onRefresh={refresh}
/>
<ManualPortInput onAdd={handleAddManual} />
</div>
</DialogContent>
);
}
export function PortForwardButton({
isRemoteExecutor,
sessionId,
isAgentctlReady,
}: {
isRemoteExecutor?: boolean;
sessionId?: string | null;
isAgentctlReady?: boolean;
}) {
if (!isRemoteExecutor || !sessionId || !isAgentctlReady) return null;
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
data-testid="port-forward-button"
size="sm"
variant="outline"
className="cursor-pointer px-2"
>
<IconNetwork className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Port Forwarding</TooltipContent>
</Tooltip>
<PortForwardDialogContent sessionId={sessionId} />
</Dialog>
);
}
@@ -9,6 +9,7 @@ import { usePreviewPanel } from "@/hooks/use-preview-panel";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { ShellTerminal } from "@/components/task/shell-terminal";
import { getLocalStorage } from "@/lib/local-storage";
import { rewritePreviewUrlForProxy } from "@/lib/preview-url-detector";
import type { PreviewViewMode, PreviewStage } from "@/lib/state/slices";
type PreviewContentProps = {
@@ -174,6 +175,7 @@ type PreviewActions = {
previewUrl: string;
previewUrlDraft: string;
devProcessId: string | undefined;
isRemote: boolean;
};
function usePreviewActions(sessionId: string | null, actions: PreviewActions) {
@@ -188,6 +190,7 @@ function usePreviewActions(sessionId: string | null, actions: PreviewActions) {
previewUrl,
previewUrlDraft,
devProcessId,
isRemote,
} = actions;
const handleStopClick = useCallback(async () => {
@@ -214,8 +217,10 @@ function usePreviewActions(sessionId: string | null, actions: PreviewActions) {
const handleUrlSubmit = useCallback(() => {
if (!sessionId) return;
const trimmed = previewUrlDraft.trim();
if (trimmed) setPreviewUrl(sessionId, trimmed);
}, [sessionId, previewUrlDraft, setPreviewUrl]);
if (!trimmed) return;
const rewritten = rewritePreviewUrlForProxy(trimmed, sessionId, isRemote);
setPreviewUrl(sessionId, rewritten ?? trimmed);
}, [sessionId, previewUrlDraft, setPreviewUrl, isRemote]);
const handleOpenInTab = useCallback(() => {
if (!sessionId || !previewUrl) return;
@@ -406,6 +411,7 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
handleStop,
detectedUrl,
isRunning,
isRemote,
} = panelState;
const [refreshKey, setRefreshKey] = useState(0);
@@ -431,6 +437,7 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
previewUrl,
previewUrlDraft,
devProcessId,
isRemote: isRemote ?? false,
});
if (!sessionId) return <PreviewPlaceholder message="Select a session to enable preview." />;
@@ -184,13 +184,12 @@ export function useFileSaveDelete({
setSavingFiles((prev) => new Set(prev).add(path));
try {
const diff = generateUnifiedDiff(tab.originalContent, tab.content, tab.path);
const response = await updateFileContent(
client,
activeSessionId,
const response = await updateFileContent(client, activeSessionId, {
path,
diff,
tab.originalHash,
);
originalHash: tab.originalHash,
desiredContent: tab.content,
});
if (response.success && response.new_hash) {
setOpenFileTabs((prev) =>
prev.map((t) =>
@@ -251,6 +251,7 @@ function buildTaskTopBarProps(params: {
effectiveSessionId: string | null;
remote: ReturnType<typeof resolveRemoteExecutor>;
sessionWorkflowStepId: string | null;
agentctlReady: boolean;
}) {
const { taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay } =
params;
@@ -274,6 +275,7 @@ function buildTaskTopBarProps(params: {
workflowId: taskProps.workflowId,
isArchived: taskProps.isArchived,
isRemoteExecutor: params.remote.isRemoteExecutor,
isAgentctlReady: params.agentctlReady,
remoteExecutorType: params.remote.remoteExecutorType,
remoteExecutorName: params.remote.remoteExecutorName,
remoteState: params.remote.remoteState,
@@ -387,6 +389,7 @@ function TaskPageInner({
effectiveSessionId,
remote,
sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
agentctlReady: agentctlStatus.isReady,
});
const layoutProps = buildTaskLayoutProps({
taskProps,
+14
View File
@@ -31,6 +31,7 @@ import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { VcsSplitButton } from "@/components/vcs-split-button";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { DEBUG_UI } from "@/lib/config";
@@ -56,6 +57,7 @@ type TaskTopBarProps = {
workflowId?: string | null;
isArchived?: boolean;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
remoteExecutorName?: string | null;
remoteExecutorType?: string | null;
remoteState?: string | null;
@@ -80,6 +82,7 @@ const TaskTopBar = memo(function TaskTopBar({
workflowId,
isArchived,
isRemoteExecutor,
isAgentctlReady,
remoteExecutorName,
remoteExecutorType,
remoteState,
@@ -141,6 +144,8 @@ const TaskTopBar = memo(function TaskTopBar({
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
/>
</header>
);
@@ -541,6 +546,8 @@ function TopBarRight({
showDebugOverlay,
onToggleDebugOverlay,
isArchived,
isRemoteExecutor,
isAgentctlReady,
}: {
activeSessionId?: string | null;
baseBranch?: string;
@@ -548,6 +555,8 @@ function TopBarRight({
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
}) {
return (
<div className="flex items-center gap-2 justify-end">
@@ -572,6 +581,11 @@ function TopBarRight({
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<PortForwardButton
isRemoteExecutor={isRemoteExecutor}
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} />
<LayoutPresetSelector />
+16
View File
@@ -167,6 +167,7 @@ export class ApiClient {
workflow_id?: string;
workflow_step_id?: string;
repository_ids?: string[];
executor_id?: string;
},
): Promise<CreateTaskResponse> {
return this.request("POST", "/api/v1/tasks", {
@@ -180,6 +181,7 @@ export class ApiClient {
...(opts?.repository_ids
? { repositories: opts.repository_ids.map((id) => ({ repository_id: id })) }
: {}),
...(opts?.executor_id ? { executor_id: opts.executor_id } : {}),
});
}
@@ -227,6 +229,20 @@ export class ApiClient {
});
}
async createExecutor(
name: string,
type: string,
): Promise<{ id: string; name: string; type: string }> {
return this.request("POST", "/api/v1/executors", { name, type });
}
async updateWorkspace(
workspaceId: string,
updates: { default_executor_id?: string },
): Promise<void> {
await this.request("PATCH", `/api/v1/workspaces/${workspaceId}`, updates);
}
async listExecutors(): Promise<{
executors: Array<{
id: string;
+20
View File
@@ -21,6 +21,26 @@ export class SessionPage {
this.passthroughTerminal = page.getByTestId("passthrough-terminal");
}
// Port forward dialog locators
get portForwardButton() {
return this.page.getByTestId("port-forward-button");
}
get portForwardDialog() {
return this.page.getByTestId("port-forward-dialog");
}
get portForwardRefresh() {
return this.page.getByTestId("port-forward-refresh");
}
get portForwardInput() {
return this.page.getByTestId("port-forward-port-input");
}
get portForwardAddButton() {
return this.page.getByTestId("port-forward-add-button");
}
portForwardRow(port: number) {
return this.page.getByTestId(`port-forward-row-${port}`);
}
async waitForLoad(timeout = 15_000) {
await this.chat.waitFor({ state: "visible", timeout });
}
@@ -0,0 +1,191 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "../helpers/api-client";
import { SessionPage } from "../pages/session-page";
/**
* Seed a task + session with a mock_remote executor and navigate to the session page.
* Sets the workspace default executor to mock_remote so the session picks it up.
*/
async function seedRemoteSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ session: SessionPage; sessionId: string }> {
// Create a mock_remote executor and set it as workspace default
const executor = await apiClient.createExecutor("E2E Mock Remote", "mock_remote");
await apiClient.updateWorkspace(seedData.workspaceId, {
default_executor_id: executor.id,
});
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/s/${task.session_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// Reset workspace default executor so other tests aren't affected
await apiClient.updateWorkspace(seedData.workspaceId, {
default_executor_id: "",
});
return { session, sessionId: task.session_id };
}
/**
* Seed a task + session with the default (local) executor.
*/
async function seedLocalSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ session: SessionPage; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/s/${task.session_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return { session, sessionId: task.session_id };
}
test.describe("Port Forward Dialog", () => {
test("button is hidden for local executor session", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedLocalSession(testPage, apiClient, seedData, "Local Port Test");
await expect(session.portForwardButton).not.toBeVisible();
});
test("button is visible for mock remote executor session", async ({
testPage,
apiClient,
seedData,
}) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Remote Port Test");
await expect(session.portForwardButton).toBeVisible();
});
test("dialog opens on button click", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Dialog Open Test");
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
});
test("refresh shows no listening ports message", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Refresh Test");
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
// Dialog auto-refreshes on open; wait for the "no ports" message
await expect(session.portForwardDialog.getByText("No listening ports detected")).toBeVisible({
timeout: 10_000,
});
});
test("add manual port shows row with Manual badge", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Manual Port Test");
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("3000");
await session.portForwardAddButton.click();
const row = session.portForwardRow(3000);
await expect(row).toBeVisible();
await expect(row.getByText("Manual")).toBeVisible();
});
test("rejects invalid port number", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Invalid Port Test");
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("99999");
await session.portForwardAddButton.click();
// Invalid port should not create a row
await expect(session.portForwardRow(99999)).not.toBeVisible();
});
test("rejects duplicate port", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Duplicate Port Test",
);
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("8080");
await session.portForwardAddButton.click();
await expect(session.portForwardRow(8080)).toBeVisible();
// Try adding the same port again — should still have only one row
await session.portForwardInput.fill("8080");
await session.portForwardAddButton.click();
const rows = session.portForwardDialog.locator('[data-testid="port-forward-row-8080"]');
await expect(rows).toHaveCount(1);
});
test("manual port row has correct proxy URL", async ({ testPage, apiClient, seedData }) => {
const { session, sessionId } = await seedRemoteSession(
testPage,
apiClient,
seedData,
"Proxy URL Test",
);
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("5000");
await session.portForwardAddButton.click();
const row = session.portForwardRow(5000);
await expect(row).toBeVisible();
const link = row.locator("a[target='_blank']");
const href = await link.getAttribute("href");
expect(href).toContain(`/port-proxy/${sessionId}/5000/`);
});
test("Enter key submits port", async ({ testPage, apiClient, seedData }) => {
const { session } = await seedRemoteSession(testPage, apiClient, seedData, "Enter Key Test");
await session.portForwardButton.click();
await expect(session.portForwardDialog).toBeVisible();
await session.portForwardInput.fill("4000");
await session.portForwardInput.press("Enter");
await expect(session.portForwardRow(4000)).toBeVisible();
});
});
@@ -56,6 +56,7 @@ type ResumeStateSetter = {
started_at: string;
updated_at: string;
}) => void;
setAgentctlReady?: (sessionId: string) => void;
};
type SessionLike = { started_at?: string; updated_at?: string } | null;
@@ -168,6 +169,11 @@ async function checkAndResume({
return;
}
applyStatusToState(status, taskId, sessionId, session, setters);
// Seed agentctl readiness from session status — the WS event may have
// already been sent before we subscribed (page reload on running session).
if (status.is_agent_running && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
if (status.is_agent_running) {
setters.setResumptionState("running");
} else if (status.needs_resume && status.is_resumable) {
@@ -212,6 +218,7 @@ export function useSessionResumption(
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
);
const setTaskSession = useAppStore((state) => state.setTaskSession);
const setSessionAgentctlStatus = useAppStore((state) => state.setSessionAgentctlStatus);
const hasAttemptedResume = useRef(false);
const remoteStatusRetryCount = useRef(0);
@@ -221,6 +228,7 @@ export function useSessionResumption(
setWorktreePath,
setWorktreeBranch,
setTaskSession,
setAgentctlReady: (sid: string) => setSessionAgentctlStatus(sid, { status: "ready" }),
};
useEffect(() => {
+17 -7
View File
@@ -57,6 +57,7 @@ async function buildFileEditorState(
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
resolvedPath: response.resolved_path,
};
}
@@ -294,8 +295,11 @@ function useOpenFileWorkspaceSync({
const gitFiles = gitStatus?.files ?? {};
const sigMap = gitFileSignaturesRef.current;
for (const path of openFiles.keys()) {
const nextSignature = buildGitFileSignature(gitFiles[path] as FileInfo | undefined);
for (const [path, file] of openFiles.entries()) {
// For symlinks, also check the resolved target path in git status
const gitFileInfo = (gitFiles[path] ??
(file.resolvedPath ? gitFiles[file.resolvedPath] : undefined)) as FileInfo | undefined;
const nextSignature = buildGitFileSignature(gitFileInfo);
const prevSignature = sigMap.get(path);
if (prevSignature === undefined) {
sigMap.set(path, nextSignature);
@@ -334,13 +338,12 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
params.setSavingFiles((prev) => new Set(prev).add(path));
try {
const diff = generateUnifiedDiff(file.originalContent, file.content, file.path);
const response = await updateFileContent(
client,
currentSessionId,
const response = await updateFileContent(client, currentSessionId, {
path,
diff,
file.originalHash,
);
originalHash: file.originalHash,
desiredContent: file.content,
});
if (response.success && response.new_hash) {
params.updateFileState(path, {
originalContent: file.content,
@@ -351,6 +354,13 @@ async function performSaveFile(path: string, params: SaveDeleteParams) {
remoteOriginalHash: undefined,
});
updatePanelAfterSave(path, file.name);
if (response.resolution === "overwritten") {
params.toast({
title: "File saved (overwritten)",
description: "The file was modified externally. Your version was saved.",
variant: "default",
});
}
} else {
params.toast({
title: "Save failed",
+24 -2
View File
@@ -8,7 +8,7 @@ import type { ProcessInfo } from "@/lib/types/http";
import type { ProcessStatusEntry } from "@/lib/state/store";
import type { PreviewStage, PreviewViewMode } from "@/lib/state/slices";
import { getLocalStorage } from "@/lib/local-storage";
import { detectPreviewUrlFromOutput } from "@/lib/preview-url-detector";
import { detectPreviewUrlFromOutput, rewritePreviewUrlForProxy } from "@/lib/preview-url-detector";
type UsePreviewPanelParams = {
sessionId: string | null;
@@ -222,6 +222,18 @@ function usePreviewStateRestore({
]);
}
function useIsRemoteExecutor(): boolean {
return useAppStore((state) => {
const taskId = state.tasks.activeTaskId;
if (!taskId) return false;
for (const snapshot of Object.values(state.kanbanMulti.snapshots)) {
const task = snapshot.tasks.find((t) => t.id === taskId);
if (task) return task.isRemoteExecutor ?? false;
}
return false;
});
}
function usePreviewPanelStore(sessionId: string | null) {
const processState = useAppStore((state) => state.processes);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
@@ -233,13 +245,21 @@ function usePreviewPanelStore(sessionId: string | null) {
sessionId ? state.columnsBySessionId[sessionId] : null,
);
const previewStore = usePreviewStore(sessionId);
const isRemote = useIsRemoteExecutor();
const devProcessId = useMemo(
() => (sessionId ? processState.devProcessBySessionId[sessionId] : undefined),
[processState.devProcessBySessionId, sessionId],
);
const devProcess = devProcessId ? (processState.processesById[devProcessId] ?? null) : null;
const devOutput = devProcessId ? (processState.outputsByProcessId[devProcessId] ?? "") : "";
const detectedUrl = useMemo(() => detectPreviewUrlFromOutput(devOutput), [devOutput]);
const rawDetectedUrl = useMemo(() => detectPreviewUrlFromOutput(devOutput), [devOutput]);
const detectedUrl = useMemo(
() =>
rawDetectedUrl && sessionId
? rewritePreviewUrlForProxy(rawDetectedUrl, sessionId, isRemote)
: null,
[rawDetectedUrl, sessionId, isRemote],
);
return {
...previewStore,
processState,
@@ -253,6 +273,7 @@ function usePreviewPanelStore(sessionId: string | null) {
devProcess,
devOutput,
detectedUrl,
isRemote,
};
}
@@ -319,6 +340,7 @@ export function usePreviewPanel({ sessionId, hasDevScript = false }: UsePreviewP
detectedUrl: s.detectedUrl,
isRunning,
isStopping,
isRemote: s.isRemote,
handleStop,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { getWebSocketClient } from "@/lib/ws/connection";
export type ListeningPort = { port: number; address: string };
export async function listPorts(sessionId: string): Promise<ListeningPort[]> {
const client = getWebSocketClient();
if (!client) return [];
try {
const response = (await client.request("port.list", {
session_id: sessionId,
})) as { ports: ListeningPort[] };
return response.ports ?? [];
} catch {
return [];
}
}
+67 -2
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { detectPreviewUrl, detectPreviewUrlFromOutput } from "./preview-url-detector";
import { describe, expect, it, vi } from "vitest";
import {
detectPreviewUrl,
detectPreviewUrlFromOutput,
rewritePreviewUrlForProxy,
} from "./preview-url-detector";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({
apiBaseUrl: "http://localhost:8080",
mcpServerUrl: "http://localhost:9090/sse",
}),
}));
const LOCALHOST_3000_URL = "http://localhost:3000/";
@@ -217,3 +228,57 @@ Ready!
expect(detectPreviewUrlFromOutput(output)).toBe("http://localhost:3000");
});
});
describe("rewritePreviewUrlForProxy", () => {
const SESSION_ID = "test-session-123";
const proxyPath = (port: number, path: string) =>
`http://localhost:8080/port-proxy/${SESSION_ID}/${port}${path}`;
it("returns URL unchanged for local executors", () => {
expect(rewritePreviewUrlForProxy(LOCALHOST_3000_URL, SESSION_ID, false)).toBe(
LOCALHOST_3000_URL,
);
});
it("rewrites localhost URL for remote executors", () => {
expect(rewritePreviewUrlForProxy(LOCALHOST_3000_URL, SESSION_ID, true)).toBe(
proxyPath(3000, "/"),
);
});
it("preserves path and query string", () => {
expect(
rewritePreviewUrlForProxy("http://localhost:8080/api/test?debug=true", SESSION_ID, true),
).toBe(proxyPath(8080, "/api/test?debug=true"));
});
it("preserves hash fragment", () => {
expect(rewritePreviewUrlForProxy("http://localhost:3000/app#/route", SESSION_ID, true)).toBe(
proxyPath(3000, "/app#/route"),
);
});
it("handles 127.0.0.1 URLs", () => {
expect(rewritePreviewUrlForProxy("http://127.0.0.1:5000/", SESSION_ID, true)).toBe(
proxyPath(5000, "/"),
);
});
it("handles 0.0.0.0 URLs", () => {
expect(rewritePreviewUrlForProxy("http://0.0.0.0:4000/", SESSION_ID, true)).toBe(
proxyPath(4000, "/"),
);
});
it("returns null for URLs without port on remote", () => {
expect(rewritePreviewUrlForProxy("http://localhost/", SESSION_ID, true)).toBeNull();
});
it("returns null for non-localhost URLs on remote", () => {
expect(rewritePreviewUrlForProxy("https://example.com:443/", SESSION_ID, true)).toBeNull();
});
it("returns null for invalid URLs on remote", () => {
expect(rewritePreviewUrlForProxy("not-a-url", SESSION_ID, true)).toBeNull();
});
});
+30
View File
@@ -3,6 +3,8 @@
* Handles various formats including full URLs and host:port patterns.
*/
import { getBackendConfig } from "@/lib/config";
export interface PreviewUrlInfo {
url: string;
port?: number;
@@ -67,6 +69,34 @@ export function detectPreviewUrl(line: string): PreviewUrlInfo | null {
return null;
}
/**
* Rewrites a detected localhost URL to route through the port proxy for remote executors.
* For local executors, returns the URL unchanged.
*
* @param detectedUrl - The detected localhost URL (e.g., http://localhost:3000/path)
* @param sessionId - The session ID to include in the proxy path
* @param isRemote - Whether the session runs on a remote executor
* @returns The proxy URL (e.g., /port-proxy/{sessionId}/3000/path) or the original URL
*/
export function rewritePreviewUrlForProxy(
detectedUrl: string,
sessionId: string,
isRemote: boolean,
): string | null {
if (!isRemote) return detectedUrl;
try {
const parsed = new URL(detectedUrl);
if (!LOCALHOST_HOSTS.has(parsed.hostname)) return null;
if (!parsed.port) return null;
const path = parsed.pathname + parsed.search + parsed.hash;
const backendUrl = getBackendConfig().apiBaseUrl;
return `${backendUrl}/port-proxy/${sessionId}/${parsed.port}${path}`;
} catch {
return null;
}
}
/**
* Scans process output for dev server URLs.
* Returns the last valid URL found.
+1
View File
@@ -49,6 +49,7 @@ export type FileEditorState = {
originalHash: string;
isDirty: boolean;
isBinary?: boolean;
resolvedPath?: string;
hasRemoteUpdate?: boolean;
remoteContent?: string;
remoteOriginalHash?: string;
+1
View File
@@ -538,6 +538,7 @@ export type FileContentResponse = {
content: string;
size: number;
is_binary?: boolean;
resolved_path?: string;
error?: string;
};
+8 -6
View File
@@ -75,6 +75,7 @@ export type FileUpdateResponse = {
path: string;
success: boolean;
new_hash?: string;
resolution?: "applied" | "overwritten";
error?: string;
};
@@ -84,15 +85,16 @@ export type FileUpdateResponse = {
export async function updateFileContent(
client: WebSocketClient,
sessionId: string,
path: string,
diff: string,
originalHash: string,
params: { path: string; diff: string; originalHash: string; desiredContent?: string },
): Promise<FileUpdateResponse> {
return client.request<FileUpdateResponse>("workspace.file.update", {
session_id: sessionId,
path,
diff,
original_hash: originalHash,
path: params.path,
diff: params.diff,
original_hash: params.originalHash,
...(params.desiredContent !== undefined && {
desired_content: params.desiredContent,
}),
});
}
+64
View File
@@ -63,6 +63,7 @@ docker run -p 8080:8080 -p 3000:3000 \
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
@@ -167,6 +168,69 @@ volumes:
postgres-data:
```
## Reverse Proxy
When running Kandev behind a reverse proxy (nginx, Traefik, Caddy) that serves both the API and web UI on the same domain, set `KANDEV_PUBLIC_URL` so the frontend routes API calls through the proxy instead of trying to reach port 8080 directly:
```bash
docker run -p 8080:8080 -p 3000:3000 \
-v kandev-data:/data \
-e KANDEV_PUBLIC_URL=https://kandev.example.com \
ghcr.io/kdlbs/kandev:latest
```
Your reverse proxy should route:
- `/api/*` and `/ws` → `http://kandev:8080` (backend)
- `/*` → `http://kandev:3000` (web UI)
With WebSocket support enabled (for `/ws`).
### Docker Compose with Caddy
```yaml
services:
kandev:
image: ghcr.io/kdlbs/kandev:latest
volumes:
- kandev-data:/data
environment:
KANDEV_PUBLIC_URL: "https://kandev.example.com"
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- caddy-data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
restart: unless-stopped
volumes:
kandev-data:
caddy-data:
```
Example `Caddyfile`:
```
kandev.example.com {
handle /api/* {
reverse_proxy kandev:8080
}
handle /ws {
reverse_proxy kandev:8080
}
handle /health {
reverse_proxy kandev:8080
}
handle {
reverse_proxy kandev:3000
}
}
```
## Docker-in-Docker (Agent Containers)
By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
+12
View File
@@ -72,6 +72,17 @@ kubectl port-forward svc/kandev 3000:3000 8080:8080
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes `/api` and `/ws` to the backend (port 8080) and everything else to the web UI (port 3000).
### Custom Domain / Reverse Proxy
When serving Kandev behind an ingress with a custom domain (e.g., `https://kandev.example.com`), set `KANDEV_PUBLIC_URL` so the frontend knows to use the ingress URL instead of trying to reach the backend on port 8080 directly:
```yaml
# In k8s/configmap.yaml
KANDEV_PUBLIC_URL: "https://kandev.example.com"
```
Without this, the browser tries to connect to `https://kandev.example.com:8080/api/...` which doesn't exist — only the ingress port (443) is exposed externally.
## Configuration
Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper). Set these in `k8s/configmap.yaml` or as environment variables in the deployment.
@@ -80,6 +91,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
| Env Var | Default | Description |
|---------|---------|-------------|
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
+5
View File
@@ -16,3 +16,8 @@ data:
# Logging — auto-detects K8s and uses JSON format
KANDEV_LOG_LEVEL: info
# Public URL — set when behind a reverse proxy / ingress that serves both
# web and API on the same domain. Without this, the UI tries to reach the
# backend API on port 8080 directly, which fails behind a proxy.
# KANDEV_PUBLIC_URL: "https://kandev.example.com"