feat: add port proxy, symlink file save fix, and remote executor improvements (#358)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;` : "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -49,6 +49,7 @@ export type FileEditorState = {
|
||||
originalHash: string;
|
||||
isDirty: boolean;
|
||||
isBinary?: boolean;
|
||||
resolvedPath?: string;
|
||||
hasRemoteUpdate?: boolean;
|
||||
remoteContent?: string;
|
||||
remoteOriginalHash?: string;
|
||||
|
||||
@@ -538,6 +538,7 @@ export type FileContentResponse = {
|
||||
content: string;
|
||||
size: number;
|
||||
is_binary?: boolean;
|
||||
resolved_path?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user