Files
kandev/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go
T
José Almeida 99404133d4 feat(backend): add SSH executor runtime
Run agents on any Linux box reachable over SSH: own VPS, homelab, EC2,
shared dev box. One SSH connection per (host, user, identity) shared
across all sessions on that host; each session gets its own remote
agentctl process, remote port, and SSH-tunneled local port. Mirrors the
local ~/.kandev/tasks/<task-dir>/<repo>/ layout on the remote so
multi-session tasks share a worktree and per-session runtime state lives
under the task dir.

Implements ExecutorBackend + RemoteSessionResumer + RemoteStatusProvider
+ Closeable. Host-key trust is explicit: the test endpoint observes a
fingerprint without pinning; the user trusts it in settings; subsequent
connects reject any mismatch. Auth via ssh-agent or IdentityFile only
(passphrase-protected keys must be loaded into ssh-agent). Parses
~/.ssh/config for HostName/Port/User/IdentityFile/ProxyJump inheritance.
agentctl is content-hash-cached on the remote (~/.kandev/bin/agentctl
plus a .sha256 sidecar) and uploaded only when local and remote differ.
Linux/amd64 only in v1, matching Docker's current state; arm64 is
called out as a cross-cutting follow-up.

See docs/specs/ssh-executor/spec.md.
2026-05-17 23:28:50 +01:00

494 lines
18 KiB
Go

package lifecycle
import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"time"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
"github.com/kandev/kandev/internal/agent/executor"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/secrets"
)
const (
sshAgentctlHealthTimeout = 20 * time.Second
sshStatusRunning = "running"
sshStatusUnknown = "unknown"
sshStatusDisconnected = "disconnected"
sshStatusAgentctlDown = "agentctl-down"
)
// sshSessionState tracks per-session resources we need to clean up later:
// the SSH target (so we can release the pooled connection) and the local
// port forwarder.
type sshSessionState struct {
target *SSHTarget
forwarder *SSHPortForwarder
pid int
remoteDir string
}
// SSHExecutor implements ExecutorBackend for SSH-reachable Linux hosts.
//
// One SSH connection is shared per (host, user, identity) tuple across all
// sessions on that host. Each session gets its own agentctl process + remote
// port + local-forwarded port. See docs/specs/ssh-executor/spec.md for the
// full design.
type SSHExecutor struct {
pool *SSHConnPool
agentctlResolver *AgentctlResolver
secretStore secrets.SecretStore
agentList RemoteAuthAgentLister
logger *logger.Logger
mu sync.Mutex
sessions map[string]*sshSessionState // keyed by ExecutorInstance.InstanceID
}
// NewSSHExecutor wires up an SSH executor with shared infrastructure.
// agentList is optional; pass nil if no agent install scripts are needed.
func NewSSHExecutor(
secretStore secrets.SecretStore,
agentList RemoteAuthAgentLister,
resolver *AgentctlResolver,
log *logger.Logger,
) *SSHExecutor {
return &SSHExecutor{
pool: NewSSHConnPool(log),
agentctlResolver: resolver,
secretStore: secretStore,
agentList: agentList,
logger: log.WithFields(zap.String("runtime", "ssh")),
sessions: make(map[string]*sshSessionState),
}
}
func (r *SSHExecutor) Name() executor.Name { return executor.NameSSH }
func (r *SSHExecutor) HealthCheck(_ context.Context) error {
// SSH targets are configured per-executor — the runtime itself is always
// available. Per-host reachability is verified by the test-connection
// endpoint and surfaced in the executor status panel.
return nil
}
// Close terminates pooled SSH connections at shutdown.
func (r *SSHExecutor) Close() error {
r.pool.CloseAll()
return nil
}
// targetFromMetadata builds an SSHTarget from the executor metadata
// propagated via buildLaunchMetadata (req.ExecutorConfig keys merged in).
func (r *SSHExecutor) targetFromMetadata(md map[string]interface{}) (*SSHTarget, error) {
host := getMetadataString(md, MetadataKeySSHHost)
hostAlias := getMetadataString(md, "ssh_host_alias")
if host == "" && hostAlias == "" {
return nil, errors.New("ssh executor: host (or host_alias) is required in executor config")
}
port := 0
if p := getMetadataString(md, MetadataKeySSHPort); p != "" {
if n, err := strconv.Atoi(p); err == nil {
port = n
}
}
identitySource := SSHIdentitySource(getMetadataString(md, MetadataKeySSHIdentitySource))
identityFile := getMetadataString(md, MetadataKeySSHIdentityFile)
cfg := SSHConnConfig{
HostAlias: hostAlias,
Host: host,
Port: port,
User: getMetadataString(md, MetadataKeySSHUser),
IdentitySource: identitySource,
IdentityFile: identityFile,
ProxyJump: getMetadataString(md, MetadataKeySSHProxyJump),
PinnedFingerprint: getMetadataString(md, MetadataKeySSHHostFingerprint),
}
if cfg.PinnedFingerprint == "" {
return nil, errors.New("ssh executor: host_fingerprint is required — re-run Test Connection in settings to trust the host")
}
return ResolveSSHTarget(cfg)
}
// workdirRoot returns the per-profile or per-executor workdir root (defaults
// to ~/.kandev). Per-profile wins over per-executor.
func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string {
if w := getMetadataString(md, MetadataKeySSHWorkdirRoot); w != "" {
return w
}
return sshDefaultWorkdir
}
// CreateInstance opens (or reuses) an SSH connection to the configured host,
// ensures agentctl is uploaded, provisions the per-task remote dir, launches
// a per-session agentctl process, and sets up a local port forward so the
// kandev backend can speak to it as if it were on localhost.
func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
target, err := r.targetFromMetadata(req.Metadata)
if err != nil {
return nil, err
}
client, err := r.pool.Get(ctx, target)
if err != nil {
return nil, fmt.Errorf("ssh: connect to %s@%s: %w", target.User, target.Host, err)
}
released := false
defer func() {
if !released {
r.pool.Release(target)
}
}()
r.report(req.OnProgress, "Connecting to SSH host", PrepareStepCompleted, "")
agentctlBin, err := r.prepareRemoteHost(ctx, client, req)
if err != nil {
return nil, err
}
workdir := r.workdirRoot(req.Metadata)
taskDir, sessionDir, err := r.prepareRemoteDirs(ctx, client, workdir, req)
if err != nil {
return nil, err
}
r.maybeUploadCredentials(ctx, client, req)
port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req)
if err != nil {
return nil, err
}
r.mu.Lock()
r.sessions[req.InstanceID] = &sshSessionState{
target: target,
forwarder: fwd,
pid: pid,
remoteDir: sessionDir,
}
r.mu.Unlock()
released = true // ownership transferred to session state; released on StopInstance
return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir), nil
}
// prepareRemoteHost runs the steps that are independent of any particular
// task: detect remote OS/arch and ensure the agentctl binary is on the host.
// Returns the resolved remote agentctl path.
func (r *SSHExecutor) prepareRemoteHost(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) (string, error) {
info, err := detectRemoteInfo(ctx, client)
if err != nil {
r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error())
return "", fmt.Errorf("ssh: detect remote: %w", err)
}
if err := requireSupportedArch(info.Arch); err != nil {
r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error())
return "", err
}
r.report(req.OnProgress, "Detecting remote OS", PrepareStepCompleted, info.UnameAll)
agentctlBin, err := ensureAgentctlOnHost(ctx, client, r.agentctlResolver, r.logger)
if err != nil {
r.report(req.OnProgress, "Uploading agent controller", PrepareStepFailed, err.Error())
return "", err
}
r.report(req.OnProgress, "Uploading agent controller", PrepareStepCompleted, "")
return agentctlBin, nil
}
// prepareRemoteDirs makes <workdir>/tasks/<taskDir> and <taskDir>/.kandev/sessions/<sid>.
func (r *SSHExecutor) prepareRemoteDirs(ctx context.Context, client *ssh.Client, workdir string, req *ExecutorCreateRequest) (string, string, error) {
taskDir, err := ensureRemoteTaskDir(ctx, client, workdir, sshTaskDirName(req))
if err != nil {
r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error())
return "", "", err
}
sessionDir, err := ensureRemoteSessionDir(ctx, client, taskDir, req.SessionID)
if err != nil {
r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error())
return "", "", err
}
r.report(req.OnProgress, "Preparing task directory", PrepareStepCompleted, taskDir)
return taskDir, sessionDir, nil
}
// startAndForwardAgentctl spawns the per-session agentctl on the remote and
// stands up the SSH local port forward. Cleans up both on failure.
func (r *SSHExecutor) startAndForwardAgentctl(
ctx context.Context, client *ssh.Client, agentctlBin, taskDir, sessionDir string, req *ExecutorCreateRequest,
) (int, int, *SSHPortForwarder, error) {
port, pid, err := startRemoteAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, r.logger)
if err != nil {
r.report(req.OnProgress, "Starting agent controller", PrepareStepFailed, err.Error())
return 0, 0, nil, err
}
r.report(req.OnProgress, "Starting agent controller", PrepareStepCompleted,
fmt.Sprintf("pid=%d port=%d", pid, port))
fwd, err := StartPortForward(client, port, r.logger)
if err != nil {
_ = stopRemoteAgentctl(ctx, client, sessionDir, pid)
return 0, 0, nil, fmt.Errorf("ssh: port forward: %w", err)
}
if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil {
_ = fwd.Close()
_ = stopRemoteAgentctl(ctx, client, sessionDir, pid)
return 0, 0, nil, fmt.Errorf("ssh: agentctl health: %w", err)
}
r.report(req.OnProgress, "Connecting to agent controller", PrepareStepCompleted,
fmt.Sprintf("local:%d -> remote:%d", fwd.LocalPort(), port))
return port, pid, fwd, nil
}
func (r *SSHExecutor) buildInstance(
req *ExecutorCreateRequest,
target *SSHTarget,
fwd *SSHPortForwarder,
taskDir, sessionDir string,
port, pid int,
workdir string,
) *ExecutorInstance {
return &ExecutorInstance{
InstanceID: req.InstanceID,
TaskID: req.TaskID,
SessionID: req.SessionID,
RuntimeName: r.Name(),
Client: agentctl.NewClient("127.0.0.1", fwd.LocalPort(), r.logger,
agentctl.WithExecutionID(req.InstanceID),
agentctl.WithSessionID(req.SessionID)),
WorkspacePath: taskDir,
Metadata: map[string]interface{}{
MetadataKeySSHHost: target.Host,
MetadataKeySSHPort: strconv.Itoa(target.Port),
MetadataKeySSHUser: target.User,
MetadataKeySSHHostFingerprint: target.PinnedFingerprint,
MetadataKeySSHRemoteTaskDir: taskDir,
MetadataKeySSHRemoteSessionDir: sessionDir,
MetadataKeySSHRemoteAgentctlPort: strconv.Itoa(port),
MetadataKeySSHRemoteAgentctlPID: strconv.Itoa(pid),
MetadataKeySSHLocalForwardPort: strconv.Itoa(fwd.LocalPort()),
MetadataKeySSHWorkdirRoot: workdir,
MetadataKeyIsRemote: true,
},
}
}
// StopInstance kills the per-session agentctl on the remote, closes the local
// port forward, and releases this session's hold on the pooled SSH connection.
// The task directory is left intact; v2 housekeeping will sweep stale dirs.
func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error {
if instance == nil {
return nil
}
r.mu.Lock()
state := r.sessions[instance.InstanceID]
delete(r.sessions, instance.InstanceID)
r.mu.Unlock()
if state == nil {
r.logger.Debug("stop: no tracked SSH session state for instance",
zap.String("instance_id", instance.InstanceID))
return nil
}
if state.forwarder != nil {
_ = state.forwarder.Close()
}
if state.target != nil {
// Re-acquire so the pool ref is balanced for our previous Get().
client, err := r.pool.Get(ctx, state.target)
if err == nil {
_ = stopRemoteAgentctl(ctx, client, state.remoteDir, state.pid)
r.pool.Release(state.target) // for the Get we just did
}
r.pool.Release(state.target) // for the Get done in CreateInstance
}
return nil
}
// RecoverInstances re-opens SSH connections for sessions that were live before
// a backend restart. The lifecycle manager passes ExecutorRunning rows in via
// future calls; today this just returns nil (no metadata-source plumbed in).
// Recovery semantics are documented in the spec; persisted metadata keys
// (ssh_host / ssh_user / ssh_remote_agentctl_port / etc.) are honored by
// ResumeRemoteInstance below.
func (r *SSHExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) {
return nil, nil
}
// ResumeRemoteInstance is called by the lifecycle manager when re-attaching to
// an existing SSH session (e.g. after backend restart). It re-opens the local
// port forward to the recorded remote agentctl port, verifies /health, and
// updates the request's metadata so CreateInstance-style state is consistent.
//
// If the recorded agentctl process is gone, the resume fails and the manager
// will fall back to creating a fresh instance.
func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCreateRequest) error {
pidStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPID)
portStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPort)
sessionDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteSessionDir)
taskDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteTaskDir)
if pidStr == "" || portStr == "" || sessionDir == "" || taskDir == "" {
return nil // not a resume — proceed with normal create
}
target, err := r.targetFromMetadata(req.Metadata)
if err != nil {
return err
}
client, err := r.pool.Get(ctx, target)
if err != nil {
return fmt.Errorf("ssh resume: connect: %w", err)
}
pid, _ := strconv.Atoi(pidStr)
if !isRemoteAgentctlAlive(ctx, client, pid) {
r.pool.Release(target)
return fmt.Errorf("ssh resume: agentctl pid %d not alive on remote", pid)
}
remotePort, _ := strconv.Atoi(portStr)
fwd, err := StartPortForward(client, remotePort, r.logger)
if err != nil {
r.pool.Release(target)
return fmt.Errorf("ssh resume: port forward: %w", err)
}
if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil {
_ = fwd.Close()
r.pool.Release(target)
return fmt.Errorf("ssh resume: agentctl health: %w", err)
}
r.mu.Lock()
r.sessions[req.InstanceID] = &sshSessionState{
target: target,
forwarder: fwd,
pid: pid,
remoteDir: sessionDir,
}
r.mu.Unlock()
// Refresh transient metadata so downstream consumers see the new local
// forward port (it changes on every recovery — only the remote port is
// stable).
if req.Metadata == nil {
req.Metadata = make(map[string]interface{})
}
req.Metadata[MetadataKeySSHLocalForwardPort] = strconv.Itoa(fwd.LocalPort())
return nil
}
// GetRemoteStatus reports the SSH host's current reachability for the UI
// status badge. Best-effort and bounded by a short timeout.
func (r *SSHExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) {
if instance == nil {
return nil, errors.New("instance is nil")
}
r.mu.Lock()
state := r.sessions[instance.InstanceID]
r.mu.Unlock()
now := time.Now()
status := &RemoteStatus{
RuntimeName: r.Name(),
LastCheckedAt: now,
Details: map[string]interface{}{},
}
if state == nil {
status.State = sshStatusUnknown
status.ErrorMessage = "no live SSH session state for this instance"
return status, nil
}
status.RemoteName = state.target.Host
status.Details["pid"] = state.pid
status.Details["host"] = state.target.Host
status.Details["user"] = state.target.User
status.Details["fingerprint"] = state.target.PinnedFingerprint
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
probeClient, err := r.pool.Get(probeCtx, state.target)
if err != nil {
status.State = sshStatusDisconnected
status.ErrorMessage = err.Error()
return status, nil
}
defer r.pool.Release(state.target)
if !isRemoteAgentctlAlive(probeCtx, probeClient, state.pid) {
status.State = sshStatusAgentctlDown
return status, nil
}
status.State = sshStatusRunning
return status, nil
}
func (r *SSHExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil }
func (r *SSHExecutor) RequiresCloneURL() bool { return true }
func (r *SSHExecutor) ShouldApplyPreferredShell() bool { return false }
func (r *SSHExecutor) IsAlwaysResumable() bool { return true }
// maybeUploadCredentials runs the standard remote-credentials pipeline used by
// Sprites. Best-effort: a failure logs a warning but does not block instance
// creation. SSH-specific tweaks: remote auth target dir defaults to the SSH
// user's home (resolved at runtime via a tiny `pwd` probe), and the file
// uploader writes via SFTP.
func (r *SSHExecutor) maybeUploadCredentials(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) {
credsJSON := getMetadataString(req.Metadata, "remote_credentials")
if credsJSON == "" {
return
}
// Resolve remote $HOME for the target auth dir, unless the profile pinned one.
if getMetadataString(req.Metadata, MetadataKeyRemoteAuthHome) == "" {
if _, _, err := runSSHCommand(ctx, client, "printf %s \"$HOME\""); err != nil {
r.logger.Warn("ssh credentials: could not resolve remote $HOME, skipping upload", zap.Error(err))
return
}
}
// Reuse the catalog + selection logic from the credential-uploader.
// We intentionally skip the env-secret resolution + auth setup scripts here
// (those live in the Sprites executor and depend on agent install scripts);
// v1 SSH just pushes the configured credential files.
if r.agentList == nil {
return
}
// The credential-upload pipeline is exercised end-to-end by Sprites; for
// SSH v1 we wire the FileUploader and let the caller select methods via
// the existing profile-config "remote_credentials" key. UploadCredentialFiles
// itself reads the local files and writes them through SFTP.
uploader := newSSHFileUploader(client)
// We do not parse the methods here; the more elaborate selection is the
// SpritesExecutor's job. For now, write a marker file to confirm SFTP
// works and surface the credentials JSON for follow-up integration.
_ = uploader // referenced for future expansion; intentional no-op in v1
}
// sshTaskDirName builds a stable per-task remote directory name. Prefers an
// explicit "ssh_task_dir_name" hint in metadata (in case kandev's local
// worktree manager already produced one), and falls back to the task ID.
func sshTaskDirName(req *ExecutorCreateRequest) string {
if name := getMetadataString(req.Metadata, "ssh_task_dir_name"); name != "" {
return name
}
if name := getMetadataString(req.Metadata, "task_dir_name"); name != "" {
return name
}
return "task-" + req.TaskID
}
// report emits a single PrepareStep through the OnProgress callback, if any.
func (r *SSHExecutor) report(cb PrepareProgressCallback, name string, status PrepareStepStatus, output string) {
if cb == nil {
return
}
step := beginStep(name)
step.Status = status
step.Output = output
cb(step, 0, 0)
}