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.
This commit is contained in:
José Almeida
2026-05-17 10:16:19 +01:00
parent f1cb1fdb21
commit 99404133d4
15 changed files with 1796 additions and 17 deletions
+6
View File
@@ -76,6 +76,11 @@ func provideLifecycleManager(
executorRegistry.Register(spritesExec)
log.Info("Sprites runtime registered")
// Register SSH runtime (run an agent on any Linux box reachable over SSH).
sshExec := lifecycle.NewSSHExecutor(secretStore, agentRegistry, agentctlResolver, log)
executorRegistry.Register(sshExec)
log.Info("SSH runtime registered")
credsMgr := credentials.NewManager(log)
if secretStore != nil {
credsMgr.AddProvider(secrets.NewSecretStoreProvider(secretStore))
@@ -111,6 +116,7 @@ func provideLifecycleManager(
preparerRegistry.Register(models.ExecutorTypeMockRemote, localPreparer)
preparerRegistry.Register(models.ExecutorTypeLocalDocker, lifecycle.NewDockerPreparer(log))
preparerRegistry.Register(models.ExecutorTypeSprites, lifecycle.NewSpritesPreparer(log))
preparerRegistry.Register(models.ExecutorTypeSSH, lifecycle.NewSSHPreparer(log))
lifecycleMgr.SetPreparerRegistry(preparerRegistry)
lifecycleMgr.SetSecretStore(secretStore)
// Wire the agent_profiles reader so the launch-prep skill deploy hook
+8 -5
View File
@@ -15,9 +15,11 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.0
github.com/jmoiron/sqlx v1.4.0
github.com/kevinburke/ssh_config v1.6.0
github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-sqlite3 v1.14.33
github.com/nats-io/nats.go v1.31.0
github.com/pkg/sftp v1.13.10
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.11.1
github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b
@@ -27,8 +29,9 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.51.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/sys v0.44.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
@@ -67,6 +70,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -101,10 +105,9 @@ require (
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
+15 -10
View File
@@ -221,12 +221,15 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -282,6 +285,8 @@ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNc
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -376,8 +381,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -443,8 +448,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -507,11 +512,11 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -520,8 +525,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -18,6 +18,7 @@ const (
NameLocal Name = "local"
NameRemoteDocker = agentruntime.RuntimeRemoteDocker
NameSprites = agentruntime.RuntimeSprites
NameSSH = agentruntime.RuntimeSSH
)
// ExecutorTypeToBackend maps an ExecutorType to its corresponding executor Name.
@@ -33,6 +34,8 @@ func ExecutorTypeToBackend(execType models.ExecutorType) Name {
return NameRemoteDocker
case models.ExecutorTypeSprites:
return NameSprites
case models.ExecutorTypeSSH:
return NameSSH
case models.ExecutorTypeMockRemote:
return NameStandalone
default:
@@ -0,0 +1,50 @@
package lifecycle
import (
"context"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// SSHPreparer validates prerequisites for SSH-based execution. The heavy
// lifting (connect, agentctl upload, task dir provisioning, agentctl launch)
// happens in SSHExecutor.CreateInstance — this preparer just emits a single
// validation step so the launch UI has consistent progress feedback.
type SSHPreparer struct {
logger *logger.Logger
}
// NewSSHPreparer creates a new SSHPreparer.
func NewSSHPreparer(log *logger.Logger) *SSHPreparer {
return &SSHPreparer{logger: log.WithFields(zap.String("component", "ssh-preparer"))}
}
// Name implements EnvironmentPreparer.
func (p *SSHPreparer) Name() string { return "ssh" }
// Prepare implements EnvironmentPreparer. SSH executor configuration is
// validated at launch time (target / fingerprint / arch are checked inside
// CreateInstance). This step is intentionally minimal so the UI still sees
// the preparer running for every launch — preserves a uniform progress shape
// across executor types.
func (p *SSHPreparer) Prepare(_ context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) {
p.logger.Debug("preparing ssh environment",
zap.String("task", req.TaskID),
zap.String("session", req.SessionID))
started := time.Now()
step := beginStep("Validate SSH executor configuration")
reportProgress(onProgress, step, 0, 1)
completeStepSuccess(&step)
reportProgress(onProgress, step, 0, 1)
return &EnvPrepareResult{
Success: true,
Steps: []PrepareStep{step},
WorkspacePath: req.WorkspacePath,
Duration: time.Since(started),
WorktreeBranch: nonWorktreeTaskBranch(req),
}, nil
}
@@ -82,6 +82,22 @@ const (
// Office metadata keys
MetadataKeySkillManifestJSON = "skill_manifest_json"
// SSH runtime metadata keys (per-session, except SSHWorkdirRoot which is per-profile).
MetadataKeySSHHost = "ssh_host"
MetadataKeySSHPort = "ssh_port"
MetadataKeySSHUser = "ssh_user"
MetadataKeySSHHostFingerprint = "ssh_host_fingerprint"
MetadataKeySSHRemoteTaskDir = "ssh_remote_task_dir"
MetadataKeySSHRemoteSessionDir = "ssh_remote_session_dir"
MetadataKeySSHRemoteAgentctlPort = "ssh_remote_agentctl_port"
MetadataKeySSHRemoteAgentctlPID = "ssh_remote_agentctl_pid"
MetadataKeySSHLocalForwardPort = "ssh_local_forward_port"
MetadataKeySSHRemoteAgentctlURL = "ssh_remote_agentctl_url"
MetadataKeySSHWorkdirRoot = "ssh_workdir_root"
MetadataKeySSHProxyJump = "ssh_proxy_jump"
MetadataKeySSHIdentitySource = "ssh_identity_source"
MetadataKeySSHIdentityFile = "ssh_identity_file"
)
// persistentMetadataKeys lists metadata keys carried forward from a previous
@@ -95,6 +111,22 @@ var persistentMetadataKeys = map[string]bool{
MetadataKeySpriteCreatedAt: true,
MetadataKeyLocalPort: true,
// SSH runtime
MetadataKeySSHHost: true,
MetadataKeySSHPort: true,
MetadataKeySSHUser: true,
MetadataKeySSHHostFingerprint: true,
MetadataKeySSHRemoteTaskDir: true,
MetadataKeySSHRemoteSessionDir: true,
MetadataKeySSHRemoteAgentctlPort: true,
MetadataKeySSHRemoteAgentctlPID: true,
MetadataKeySSHLocalForwardPort: true,
MetadataKeySSHRemoteAgentctlURL: true,
MetadataKeySSHWorkdirRoot: true,
MetadataKeySSHProxyJump: true,
MetadataKeySSHIdentitySource: true,
MetadataKeySSHIdentityFile: true,
// Executor type marker
MetadataKeyIsRemote: true,
@@ -0,0 +1,493 @@
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)
}
@@ -0,0 +1,496 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/kevinburke/ssh_config"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"github.com/kandev/kandev/internal/common/logger"
)
const (
sshDialTimeout = 30 * time.Second
sshKeepaliveEvery = 30 * time.Second
sshKeepaliveTimeout = 15 * time.Second
)
// SSHIdentitySource enumerates how kandev obtains an SSH credential.
type SSHIdentitySource string
const (
SSHIdentitySourceAgent SSHIdentitySource = "agent" // use SSH_AUTH_SOCK
SSHIdentitySourceFile SSHIdentitySource = "file" // explicit IdentityFile path
)
// SSHTarget is a resolved connection target after ~/.ssh/config inheritance.
type SSHTarget struct {
Host string // resolved HostName
Port int // resolved Port
User string // resolved User
IdentitySource SSHIdentitySource // agent | file
IdentityFile string // path when IdentitySource=file
ProxyJump string // optional bastion (single hop)
// PinnedFingerprint is the SHA256 fingerprint the user trusted in settings.
// Empty means "first connect / test mode" — accept any key and return what we saw.
PinnedFingerprint string
// ObservedFingerprint is set by Dial after the handshake completes.
// In test-mode (PinnedFingerprint == "") this is the value to surface to the UI.
ObservedFingerprint string
}
// SSHConnConfig holds the raw form values used to build an SSHTarget.
// Either HostAlias is set (and we read ~/.ssh/config) or the explicit fields are.
type SSHConnConfig struct {
Name string
HostAlias string // optional, looked up in ~/.ssh/config
Host string
Port int
User string
IdentitySource SSHIdentitySource
IdentityFile string
ProxyJump string
PinnedFingerprint string
}
// ResolveSSHTarget merges form values with ~/.ssh/config Host blocks so a user
// who has `Host prod` in their config can paste `prod` into kandev and the
// HostName / Port / User / IdentityFile / ProxyJump fields inherit.
//
// Explicit form values always win over ~/.ssh/config values.
func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) {
t := initialTargetFromConfig(cfg)
if alias := strings.TrimSpace(cfg.HostAlias); alias != "" {
inheritFromSSHConfig(alias, t)
}
if err := applyTargetDefaults(t); err != nil {
return nil, err
}
t.PinnedFingerprint = cfg.PinnedFingerprint
return t, nil
}
// initialTargetFromConfig copies form values into a partially populated target.
// All fields are trimmed; SSH-config inheritance and final defaults are applied later.
func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget {
return &SSHTarget{
Host: strings.TrimSpace(cfg.Host),
Port: cfg.Port,
User: strings.TrimSpace(cfg.User),
IdentitySource: cfg.IdentitySource,
IdentityFile: strings.TrimSpace(cfg.IdentityFile),
ProxyJump: strings.TrimSpace(cfg.ProxyJump),
}
}
// inheritFromSSHConfig fills empty fields on target from the matching Host
// block in ~/.ssh/config. Explicit form values are never overwritten.
func inheritFromSSHConfig(alias string, t *SSHTarget) {
if t.Host == "" {
if v, _ := ssh_config.GetStrict(alias, "HostName"); v != "" {
t.Host = strings.TrimSpace(v)
}
}
if t.Host == "" {
t.Host = alias
}
if t.Port == 0 {
if v, _ := ssh_config.GetStrict(alias, "Port"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
t.Port = n
}
}
}
if t.User == "" {
if v, _ := ssh_config.GetStrict(alias, "User"); v != "" {
t.User = v
}
}
if t.IdentitySource == "" {
if v, _ := ssh_config.GetStrict(alias, "IdentityFile"); v != "" {
t.IdentitySource = SSHIdentitySourceFile
t.IdentityFile = expandHome(v)
}
}
if t.ProxyJump == "" {
if v, _ := ssh_config.GetStrict(alias, "ProxyJump"); v != "" {
t.ProxyJump = v
}
}
}
// applyTargetDefaults fills in port/user/identity defaults and validates that
// host is set. Identity defaults: ssh-agent if SSH_AUTH_SOCK is set, otherwise
// fall back to ~/.ssh/id_ed25519.
func applyTargetDefaults(t *SSHTarget) error {
if t.Host == "" {
return errors.New("ssh: host is required")
}
if t.Port == 0 {
t.Port = 22
}
if t.User == "" {
current := os.Getenv("USER")
if current == "" {
return errors.New("ssh: user is required (no $USER set)")
}
t.User = current
}
if t.IdentitySource == "" {
if os.Getenv("SSH_AUTH_SOCK") != "" {
t.IdentitySource = SSHIdentitySourceAgent
} else {
t.IdentitySource = SSHIdentitySourceFile
t.IdentityFile = expandHome("~/.ssh/id_ed25519")
}
}
if t.IdentitySource == SSHIdentitySourceFile {
t.IdentityFile = expandHome(t.IdentityFile)
}
return nil
}
func expandHome(p string) string {
p = strings.TrimSpace(p)
if !strings.HasPrefix(p, "~/") && p != "~" {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return p
}
if p == "~" {
return home
}
return filepath.Join(home, p[2:])
}
// SSHFingerprint returns the SHA256 fingerprint of a host key in the
// standard `SHA256:<base64>` format used by OpenSSH and the `ssh` CLI.
func SSHFingerprint(key ssh.PublicKey) string {
return ssh.FingerprintSHA256(key)
}
// errHostKeyMismatch is returned when the observed host key doesn't match the
// pinned fingerprint. The error message is shaped for direct surfacing in the UI.
type errHostKeyMismatch struct {
Expected string
Got string
}
func (e *errHostKeyMismatch) Error() string {
return fmt.Sprintf("host key changed: expected %s, got %s", e.Expected, e.Got)
}
// buildAuthMethods builds the SSH auth methods for the target.
// File identity sources do NOT support passphrase-protected keys — the user
// must load them into ssh-agent themselves (see spec).
func buildAuthMethods(target *SSHTarget) ([]ssh.AuthMethod, error) {
switch target.IdentitySource {
case SSHIdentitySourceAgent:
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
return nil, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set; start an agent and add your key (ssh-add)")
}
conn, err := net.Dial("unix", sock)
if err != nil {
return nil, fmt.Errorf("failed to connect to ssh-agent: %w", err)
}
ag := agent.NewClient(conn)
return []ssh.AuthMethod{ssh.PublicKeysCallback(ag.Signers)}, nil
case SSHIdentitySourceFile:
if target.IdentityFile == "" {
return nil, errors.New("ssh: identity file path is required")
}
data, err := os.ReadFile(target.IdentityFile)
if err != nil {
return nil, fmt.Errorf("failed to read identity file %s: %w", target.IdentityFile, err)
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
// passphrase-protected keys produce ssh.PassphraseMissingError — fail loudly.
var pmErr *ssh.PassphraseMissingError
if errors.As(err, &pmErr) {
return nil, fmt.Errorf("identity file %s is passphrase-protected; load it into ssh-agent (ssh-add) and switch identity source to ssh-agent — kandev does not store passphrases", target.IdentityFile)
}
return nil, fmt.Errorf("failed to parse identity file %s: %w", target.IdentityFile, err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
default:
return nil, fmt.Errorf("unsupported identity source: %q", target.IdentitySource)
}
}
// buildClientConfig builds an *ssh.ClientConfig with auth + host-key callback.
//
// When PinnedFingerprint is set, the callback rejects keys that don't match
// (returns errHostKeyMismatch). When PinnedFingerprint is empty, the callback
// records the observed fingerprint on target.ObservedFingerprint and accepts
// the connection — this is the "test connection" mode where the UI then asks
// the user to trust the fingerprint.
func buildClientConfig(target *SSHTarget) (*ssh.ClientConfig, error) {
auth, err := buildAuthMethods(target)
if err != nil {
return nil, err
}
return &ssh.ClientConfig{
User: target.User,
Auth: auth,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
fp := SSHFingerprint(key)
target.ObservedFingerprint = fp
if target.PinnedFingerprint == "" {
return nil
}
if fp != target.PinnedFingerprint {
return &errHostKeyMismatch{Expected: target.PinnedFingerprint, Got: fp}
}
return nil
},
Timeout: sshDialTimeout,
}, nil
}
// dial opens an SSH connection to target, optionally via a bastion if ProxyJump
// is set. Returns the live *ssh.Client and the observed fingerprint.
func dialSSH(ctx context.Context, target *SSHTarget) (*ssh.Client, error) {
cfg, err := buildClientConfig(target)
if err != nil {
return nil, err
}
addr := net.JoinHostPort(target.Host, strconv.Itoa(target.Port))
if target.ProxyJump == "" {
return dialDirect(ctx, addr, cfg)
}
return dialViaJump(ctx, target, addr, cfg)
}
func dialDirect(ctx context.Context, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
dialer := &net.Dialer{Timeout: cfg.Timeout}
tcpConn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("ssh: tcp dial %s: %w", addr, err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, cfg)
if err != nil {
_ = tcpConn.Close()
return nil, fmt.Errorf("ssh: handshake with %s: %w", addr, err)
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
// dialViaJump implements ProxyJump as a single bastion hop. The bastion is
// resolved from its own ~/.ssh/config Host block, defaulting to the same
// identity source as the target (passes the user's agent / key through).
func dialViaJump(ctx context.Context, target *SSHTarget, finalAddr string, finalCfg *ssh.ClientConfig) (*ssh.Client, error) {
bastion, err := ResolveSSHTarget(SSHConnConfig{
HostAlias: target.ProxyJump,
IdentitySource: target.IdentitySource,
IdentityFile: target.IdentityFile,
})
if err != nil {
return nil, fmt.Errorf("ssh: resolve bastion %q: %w", target.ProxyJump, err)
}
bastionCfg, err := buildClientConfig(bastion)
if err != nil {
return nil, fmt.Errorf("ssh: bastion config: %w", err)
}
bastionAddr := net.JoinHostPort(bastion.Host, strconv.Itoa(bastion.Port))
bastionClient, err := dialDirect(ctx, bastionAddr, bastionCfg)
if err != nil {
return nil, fmt.Errorf("ssh: bastion dial: %w", err)
}
// Tunnel through the bastion to the final host.
tunnel, err := bastionClient.DialContext(ctx, "tcp", finalAddr)
if err != nil {
_ = bastionClient.Close()
return nil, fmt.Errorf("ssh: bastion tunnel to %s: %w", finalAddr, err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(tunnel, finalAddr, finalCfg)
if err != nil {
_ = tunnel.Close()
_ = bastionClient.Close()
return nil, fmt.Errorf("ssh: handshake with %s via %s: %w", finalAddr, target.ProxyJump, err)
}
// Attach the bastion close to the final client lifetime.
final := ssh.NewClient(sshConn, chans, reqs)
go func() {
_ = final.Wait()
_ = bastionClient.Close()
}()
return final, nil
}
// sshConnKey identifies a pooled SSH connection. Connections are not shared
// across identity sources or proxy-jump configurations to avoid surprising
// behavior when a user changes auth methods mid-session.
type sshConnKey struct {
host string
port int
user string
identitySource SSHIdentitySource
identityFile string
proxyJump string
}
func keyForTarget(target *SSHTarget) sshConnKey {
return sshConnKey{
host: target.Host,
port: target.Port,
user: target.User,
identitySource: target.IdentitySource,
identityFile: target.IdentityFile,
proxyJump: target.ProxyJump,
}
}
// pooledConn is a refcounted live SSH client shared across an executor's
// sessions on the same host.
type pooledConn struct {
client *ssh.Client
target *SSHTarget
refs int
keepalive context.CancelFunc
}
// SSHConnPool multiplexes a single SSH client per (host, user, identity) tuple
// across all of the executor's live sessions on that host. Sessions acquire a
// connection via Get; the pool releases the underlying client when the last
// session releases.
type SSHConnPool struct {
mu sync.Mutex
entries map[sshConnKey]*pooledConn
logger *logger.Logger
}
func NewSSHConnPool(log *logger.Logger) *SSHConnPool {
return &SSHConnPool{
entries: make(map[sshConnKey]*pooledConn),
logger: log.WithFields(zap.String("component", "ssh-conn-pool")),
}
}
// Get returns a live *ssh.Client for the target, dialing if necessary. The
// caller must call Release when done. The same target asked for concurrently
// is dialed only once.
func (p *SSHConnPool) Get(ctx context.Context, target *SSHTarget) (*ssh.Client, error) {
key := keyForTarget(target)
p.mu.Lock()
if entry, ok := p.entries[key]; ok {
entry.refs++
p.mu.Unlock()
return entry.client, nil
}
p.mu.Unlock()
client, err := dialSSH(ctx, target)
if err != nil {
return nil, err
}
p.mu.Lock()
defer p.mu.Unlock()
// Another goroutine may have raced ahead.
if entry, ok := p.entries[key]; ok {
_ = client.Close()
entry.refs++
return entry.client, nil
}
kctx, kcancel := context.WithCancel(context.Background())
entry := &pooledConn{client: client, target: target, refs: 1, keepalive: kcancel}
p.entries[key] = entry
go p.runKeepalive(kctx, key, client)
return client, nil
}
// Release decrements the refcount; when it hits zero the underlying client is
// closed and removed from the pool.
func (p *SSHConnPool) Release(target *SSHTarget) {
key := keyForTarget(target)
p.mu.Lock()
defer p.mu.Unlock()
entry, ok := p.entries[key]
if !ok {
return
}
entry.refs--
if entry.refs > 0 {
return
}
entry.keepalive()
_ = entry.client.Close()
delete(p.entries, key)
}
// CloseAll terminates every pooled connection. Used at shutdown.
func (p *SSHConnPool) CloseAll() {
p.mu.Lock()
defer p.mu.Unlock()
for k, entry := range p.entries {
entry.keepalive()
_ = entry.client.Close()
delete(p.entries, k)
}
}
// runKeepalive sends an ssh keepalive every sshKeepaliveEvery so dropped
// connections surface within ~30s instead of waiting for the next operation.
// On any keepalive failure the pool entry is evicted so the next Get will
// re-dial.
func (p *SSHConnPool) runKeepalive(ctx context.Context, key sshConnKey, client *ssh.Client) {
t := time.NewTicker(sshKeepaliveEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
done := make(chan error, 1)
go func() {
_, _, err := client.SendRequest("keepalive@kandev", true, nil)
done <- err
}()
select {
case err := <-done:
if err != nil {
p.evictOnFailure(key, err)
return
}
case <-time.After(sshKeepaliveTimeout):
p.evictOnFailure(key, fmt.Errorf("keepalive timed out"))
return
}
}
}
}
func (p *SSHConnPool) evictOnFailure(key sshConnKey, err error) {
p.mu.Lock()
defer p.mu.Unlock()
entry, ok := p.entries[key]
if !ok {
return
}
p.logger.Warn("ssh connection lost — evicting from pool",
zap.String("host", key.host),
zap.String("user", key.user),
zap.Error(err))
_ = entry.client.Close()
delete(p.entries, key)
}
@@ -0,0 +1,148 @@
package lifecycle
import (
"os"
"strings"
"testing"
)
func TestResolveSSHTarget_ExplicitFields(t *testing.T) {
target, err := ResolveSSHTarget(SSHConnConfig{
Host: "example.com",
Port: 2200,
User: "alice",
IdentitySource: SSHIdentitySourceFile,
IdentityFile: "/home/alice/.ssh/id_ed25519",
PinnedFingerprint: "SHA256:abcdef",
})
if err != nil {
t.Fatalf("ResolveSSHTarget: %v", err)
}
if target.Host != "example.com" {
t.Errorf("Host = %q, want example.com", target.Host)
}
if target.Port != 2200 {
t.Errorf("Port = %d, want 2200", target.Port)
}
if target.User != "alice" {
t.Errorf("User = %q, want alice", target.User)
}
if target.IdentitySource != SSHIdentitySourceFile {
t.Errorf("IdentitySource = %q, want file", target.IdentitySource)
}
if target.IdentityFile != "/home/alice/.ssh/id_ed25519" {
t.Errorf("IdentityFile = %q", target.IdentityFile)
}
}
func TestResolveSSHTarget_DefaultsPort22(t *testing.T) {
target, err := ResolveSSHTarget(SSHConnConfig{
Host: "example.com",
User: "alice",
IdentitySource: SSHIdentitySourceAgent,
PinnedFingerprint: "SHA256:abcdef",
})
if err != nil {
t.Fatalf("ResolveSSHTarget: %v", err)
}
if target.Port != 22 {
t.Errorf("default Port = %d, want 22", target.Port)
}
}
func TestResolveSSHTarget_DefaultUserFromEnv(t *testing.T) {
t.Setenv("USER", "envuser")
target, err := ResolveSSHTarget(SSHConnConfig{
Host: "example.com",
IdentitySource: SSHIdentitySourceAgent,
})
if err != nil {
t.Fatalf("ResolveSSHTarget: %v", err)
}
if target.User != "envuser" {
t.Errorf("default User = %q, want envuser", target.User)
}
}
func TestResolveSSHTarget_HostRequired(t *testing.T) {
_, err := ResolveSSHTarget(SSHConnConfig{
User: "alice",
IdentitySource: SSHIdentitySourceAgent,
})
if err == nil {
t.Fatal("expected error when host is empty")
}
if !strings.Contains(err.Error(), "host is required") {
t.Errorf("unexpected error: %v", err)
}
}
func TestResolveSSHTarget_AliasInfersHostName(t *testing.T) {
// With no ~/.ssh/config Host block matching, the alias is used as the
// literal hostname. This is the "user typed something but has no
// matching block" fallback.
target, err := ResolveSSHTarget(SSHConnConfig{
HostAlias: "bare-alias",
User: "alice",
IdentitySource: SSHIdentitySourceAgent,
})
if err != nil {
t.Fatalf("ResolveSSHTarget: %v", err)
}
if target.Host != "bare-alias" {
t.Errorf("Host = %q, want bare-alias", target.Host)
}
}
func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("cannot determine home dir: %v", err)
}
if got := expandHome("~"); got != home {
t.Errorf("expandHome(~) = %q, want %q", got, home)
}
if got := expandHome("~/.ssh/id_ed25519"); !strings.HasPrefix(got, home+"/.ssh") {
t.Errorf("expandHome(~/.ssh/...) = %q, want prefix %q/.ssh", got, home)
}
if got := expandHome("/abs/path"); got != "/abs/path" {
t.Errorf("expandHome(/abs/path) = %q, want unchanged", got)
}
}
func TestRequireSupportedArch(t *testing.T) {
if err := requireSupportedArch("x86_64"); err != nil {
t.Errorf("x86_64 should be supported, got %v", err)
}
err := requireSupportedArch("aarch64")
if err == nil {
t.Fatal("aarch64 should not be supported")
}
if !strings.Contains(err.Error(), "aarch64") || !strings.Contains(err.Error(), "linux/amd64") {
t.Errorf("error should name the arch and the supported one: %v", err)
}
}
func TestErrHostKeyMismatchMessage(t *testing.T) {
e := &errHostKeyMismatch{Expected: "SHA256:aaa", Got: "SHA256:bbb"}
msg := e.Error()
for _, want := range []string{"host key changed", "expected SHA256:aaa", "got SHA256:bbb"} {
if !strings.Contains(msg, want) {
t.Errorf("error message %q missing %q", msg, want)
}
}
}
func TestShellQuote(t *testing.T) {
cases := map[string]string{
"simple": "'simple'",
"with space": "'with space'",
"don't": `'don'\''t'`,
"path/to/dir": "'path/to/dir'",
}
for in, want := range cases {
if got := shellQuote(in); got != want {
t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
}
}
}
@@ -0,0 +1,23 @@
package lifecycle
import (
"context"
"os"
"golang.org/x/crypto/ssh"
)
// sshFileUploader implements FileUploader for an SSH client by writing through
// SFTP. Reused by the existing credential-upload pipeline so SSH executor gets
// gh_cli_token / claude / codex / etc. credential propagation for free.
type sshFileUploader struct {
client *ssh.Client
}
func newSSHFileUploader(client *ssh.Client) *sshFileUploader {
return &sshFileUploader{client: client}
}
func (u *sshFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error {
return sftpWriteFile(u.client, path, data, mode)
}
@@ -0,0 +1,505 @@
package lifecycle
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/pkg/sftp"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
"github.com/kandev/kandev/internal/common/logger"
)
const (
sshDefaultWorkdir = "~/.kandev"
sshRemoteAgentctlPath = "~/.kandev/bin/agentctl"
sshRemoteAgentctlSha256 = "~/.kandev/bin/agentctl.sha256"
sshAgentctlReadyTimeout = 30 * time.Second
sshAgentctlReadyPoll = 500 * time.Millisecond
sshSupportedArch = "x86_64"
)
// SSHRemoteInfo describes the remote host detected during connection.
type SSHRemoteInfo struct {
UnameAll string // `uname -a`
Arch string // `uname -m`
GitVer string // `git --version`
AgentctlOK bool // true if the cached agentctl matches the local sha256
}
// SSHProbeRemote is the exported entry point for the test-connection endpoint
// to run a remote probe (uname / arch / git) over an already-dialed *ssh.Client.
func SSHProbeRemote(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, error) {
return detectRemoteInfo(ctx, client)
}
// SSHRequireSupportedArch is the exported arm64-not-yet-supported gate.
// Returns nil for the supported arch (x86_64) and a user-facing error otherwise.
func SSHRequireSupportedArch(arch string) error {
return requireSupportedArch(arch)
}
// SSHCheckAgentctlCached reports whether the remote already has an agentctl
// binary whose sha256 matches the local one. Used by the test-connection
// endpoint to inform the user whether the first launch will need to upload.
//
// Errors here are non-fatal at test time — the actual upload happens on
// CreateInstance — but they still bubble up so the UI can surface "agentctl
// not yet on remote" as a status row.
func SSHCheckAgentctlCached(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver) (bool, error) {
localSha, _, _, err := localAgentctlSha256(resolver)
if err != nil {
return false, err
}
remoteShaFile, err := expandRemoteHome(ctx, client, sshRemoteAgentctlSha256)
if err != nil {
return false, err
}
out, _, err := runSSHCommand(ctx, client, "cat "+shellQuote(remoteShaFile)+" 2>/dev/null")
if err != nil {
return false, nil
}
return strings.TrimSpace(out) == localSha, nil
}
// runSSHCommand executes a single command on the remote and returns its
// stdout, stderr, and any error. It is the workhorse for arch detection,
// remote mkdir, git clone, sha256 checks, and the like.
func runSSHCommand(ctx context.Context, client *ssh.Client, cmd string) (stdout, stderr string, err error) {
session, err := client.NewSession()
if err != nil {
return "", "", fmt.Errorf("ssh: new session: %w", err)
}
defer func() { _ = session.Close() }()
var outBuf, errBuf bytes.Buffer
session.Stdout = &outBuf
session.Stderr = &errBuf
done := make(chan error, 1)
go func() {
done <- session.Run(cmd)
}()
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGTERM)
return outBuf.String(), errBuf.String(), ctx.Err()
case err := <-done:
return outBuf.String(), errBuf.String(), err
}
}
// detectRemoteInfo runs a tiny probe to learn about the host. amd64-only gate
// happens at the caller — this function only reports.
func detectRemoteInfo(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, error) {
info := &SSHRemoteInfo{}
if out, _, err := runSSHCommand(ctx, client, "uname -a"); err == nil {
info.UnameAll = strings.TrimSpace(out)
}
out, _, err := runSSHCommand(ctx, client, "uname -m")
if err != nil {
return nil, fmt.Errorf("ssh: uname -m: %w", err)
}
info.Arch = strings.TrimSpace(out)
if out, _, err := runSSHCommand(ctx, client, "git --version"); err == nil {
info.GitVer = strings.TrimSpace(out)
}
return info, nil
}
// requireSupportedArch returns nil when the remote arch is supported, or a
// user-facing error otherwise. v1 = linux/amd64 only (mirrors Docker today).
func requireSupportedArch(arch string) error {
if arch == sshSupportedArch {
return nil
}
return fmt.Errorf(
"unsupported remote architecture %q — SSH executor v1 supports linux/amd64 only (arm64 lands when agentctl-linux-arm64 is added to the build)",
arch,
)
}
// expandRemoteHome rewrites a leading ~/ to the home directory reported by the
// remote (`echo $HOME`). The result is an absolute path. Called once per
// connection and cached by the caller.
func expandRemoteHome(ctx context.Context, client *ssh.Client, path string) (string, error) {
if !strings.HasPrefix(path, "~/") && path != "~" {
return path, nil
}
out, _, err := runSSHCommand(ctx, client, "printf %s \"$HOME\"")
if err != nil {
return "", fmt.Errorf("ssh: resolve $HOME: %w", err)
}
home := strings.TrimSpace(out)
if home == "" {
return "", errors.New("ssh: remote $HOME is empty")
}
if path == "~" {
return home, nil
}
return home + "/" + strings.TrimPrefix(path, "~/"), nil
}
// localAgentctlSha256 returns the hex sha256 of the local agentctl binary
// resolved via AgentctlResolver. Used to decide whether to re-upload.
func localAgentctlSha256(resolver *AgentctlResolver) (string, []byte, string, error) {
path, err := resolver.ResolveLinuxBinary()
if err != nil {
return "", nil, "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", nil, "", fmt.Errorf("read agentctl: %w", err)
}
h := sha256.Sum256(data)
return hex.EncodeToString(h[:]), data, path, nil
}
// ensureAgentctlOnHost uploads the agentctl binary if the remote's cached sha256
// differs from the local binary's sha256. Returns the absolute remote path.
func ensureAgentctlOnHost(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver, log *logger.Logger) (string, error) {
localSha, localData, localPath, err := localAgentctlSha256(resolver)
if err != nil {
return "", err
}
remoteBin, err := expandRemoteHome(ctx, client, sshRemoteAgentctlPath)
if err != nil {
return "", err
}
remoteShaFile, err := expandRemoteHome(ctx, client, sshRemoteAgentctlSha256)
if err != nil {
return "", err
}
// Compare existing remote sha256, if any.
if out, _, err := runSSHCommand(ctx, client, "cat "+remoteShaFile+" 2>/dev/null"); err == nil {
if strings.TrimSpace(out) == localSha {
// Verify the binary is also still there and executable.
if _, _, terr := runSSHCommand(ctx, client, "test -x "+remoteBin); terr == nil {
log.Debug("agentctl already up-to-date on remote", zap.String("sha256", localSha))
return remoteBin, nil
}
}
}
log.Info("uploading agentctl to remote",
zap.String("local_path", localPath),
zap.String("remote_path", remoteBin),
zap.String("sha256", localSha),
zap.Int("bytes", len(localData)))
if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+filepath.Dir(remoteBin)); err != nil {
return "", fmt.Errorf("ssh: mkdir for agentctl: %w", err)
}
if err := sftpUploadBytes(client, remoteBin, localData, 0o755); err != nil {
return "", fmt.Errorf("ssh: upload agentctl: %w", err)
}
if err := sftpUploadBytes(client, remoteShaFile, []byte(localSha+"\n"), 0o644); err != nil {
return "", fmt.Errorf("ssh: upload agentctl sha256: %w", err)
}
// Sanity check.
if _, _, err := runSSHCommand(ctx, client, "test -x "+remoteBin); err != nil {
return "", fmt.Errorf("ssh: agentctl not executable after upload: %w", err)
}
return remoteBin, nil
}
// sftpUploadBytes writes data to remotePath via SFTP with the given mode.
// Intermediate directories must already exist.
func sftpUploadBytes(client *ssh.Client, remotePath string, data []byte, mode os.FileMode) error {
c, err := sftp.NewClient(client)
if err != nil {
return fmt.Errorf("sftp: new client: %w", err)
}
defer func() { _ = c.Close() }()
// Stream to a temp path then rename, so a half-uploaded file never appears
// as the final name (matters most for the agentctl binary).
tmp := remotePath + ".tmp"
f, err := c.Create(tmp)
if err != nil {
return fmt.Errorf("sftp: create %s: %w", tmp, err)
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
_ = c.Remove(tmp)
return fmt.Errorf("sftp: write %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
_ = c.Remove(tmp)
return fmt.Errorf("sftp: close %s: %w", tmp, err)
}
if err := c.Chmod(tmp, mode); err != nil {
return fmt.Errorf("sftp: chmod %s: %w", tmp, err)
}
if err := c.PosixRename(tmp, remotePath); err != nil {
// Some servers don't support POSIX rename; fall back to a non-atomic rename.
if rerr := c.Rename(tmp, remotePath); rerr != nil {
return fmt.Errorf("sftp: rename %s -> %s: %w", tmp, remotePath, rerr)
}
}
return nil
}
// sftpWriteFile is the FileUploader.WriteFile implementation: ensures parent
// dirs exist and atomically writes the file.
func sftpWriteFile(client *ssh.Client, remotePath string, data []byte, mode os.FileMode) error {
if dir := filepath.Dir(remotePath); dir != "" && dir != "." {
if _, _, err := runSSHCommand(context.Background(), client, "mkdir -p "+shellQuote(dir)); err != nil {
return fmt.Errorf("ssh: mkdir %s: %w", dir, err)
}
}
return sftpUploadBytes(client, remotePath, data, mode)
}
// shellQuote is a minimal POSIX shell-safe single-quote wrapper.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// ensureRemoteTaskDir creates <workdirRoot>/<taskDirName> if missing and
// returns the absolute remote path. Repo clones happen via the prepare-script
// path (scriptengine), not here; this is just the parent dir.
func ensureRemoteTaskDir(ctx context.Context, client *ssh.Client, workdirRoot, taskDirName string) (string, error) {
if taskDirName == "" {
return "", errors.New("ssh: task dir name is empty")
}
root, err := expandRemoteHome(ctx, client, workdirRoot)
if err != nil {
return "", err
}
taskDir := root + "/tasks/" + taskDirName
if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+shellQuote(taskDir)); err != nil {
return "", fmt.Errorf("ssh: mkdir task dir %s: %w", taskDir, err)
}
return taskDir, nil
}
// ensureRemoteSessionDir creates <taskDir>/.kandev/sessions/<sessionID>/ and
// returns the absolute remote path. Per-session runtime data (PID file, logs,
// agentctl socket) lives here.
func ensureRemoteSessionDir(ctx context.Context, client *ssh.Client, taskDir, sessionID string) (string, error) {
if sessionID == "" {
return "", errors.New("ssh: session ID is empty")
}
sessionDir := taskDir + "/.kandev/sessions/" + sessionID
if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+shellQuote(sessionDir)); err != nil {
return "", fmt.Errorf("ssh: mkdir session dir: %w", err)
}
return sessionDir, nil
}
// startRemoteAgentctl launches an agentctl process on the remote bound to
// 127.0.0.1:0 and waits for it to write its chosen port + PID into the session
// runtime dir. Returns the chosen port and the process PID.
//
// The on-remote layout written by agentctl on startup:
//
// <sessionDir>/agentctl.port
// <sessionDir>/agentctl.pid
// <sessionDir>/agentctl.log
func startRemoteAgentctl(
ctx context.Context,
client *ssh.Client,
agentctlBin, workspacePath, sessionDir string,
log *logger.Logger,
) (port int, pid int, err error) {
// Build the launch command. We:
// - export the workspace
// - launch agentctl as a backgrounded, disowned process
// - have it bind to a free port; on startup we expect agentctl to either
// accept --port-file / --pid-file flags OR we fall back to grabbing a
// port ourselves and passing --port. To stay portable we always grab
// a port ourselves here using `python3 -c` or `ss -tln` is brittle, so
// we use a small inline shell helper.
//
// agentctl is invoked with KANDEV_AGENTCTL_PORT honored. We pick a port by
// asking the kernel to allocate one (via `bash` reading from /dev/tcp is
// unreliable for binding), so we use a tiny `python3` fallback if available,
// else awk-on-ss. To keep the dependency surface minimal we instead just
// rely on agentctl itself printing its bound port to a port-file when
// invoked with KANDEV_AGENTCTL_PORT=0 — the standalone agentctl already
// supports binding to :0 and printing the chosen port to stdout.
cmd := fmt.Sprintf(
`set -e
mkdir -p %[1]s
: > %[1]s/agentctl.log
nohup %[2]s serve \
--workspace %[3]s \
--port 0 \
--port-file %[1]s/agentctl.port \
--pid-file %[1]s/agentctl.pid \
>> %[1]s/agentctl.log 2>&1 < /dev/null &
disown $! 2>/dev/null || true
echo $!
`,
shellQuote(sessionDir),
shellQuote(agentctlBin),
shellQuote(workspacePath),
)
out, stderr, err := runSSHCommand(ctx, client, cmd)
if err != nil {
return 0, 0, fmt.Errorf("ssh: launch agentctl: %w (stderr: %s)", err, strings.TrimSpace(stderr))
}
_ = strings.TrimSpace(out) // immediate $! is informational; the on-disk pidfile is authoritative
// Poll for the port file + pid file to appear and be readable.
deadline := time.Now().Add(sshAgentctlReadyTimeout)
for time.Now().Before(deadline) {
portStr, _, perr := runSSHCommand(ctx, client, "cat "+shellQuote(sessionDir+"/agentctl.port")+" 2>/dev/null")
pidStr, _, _ := runSSHCommand(ctx, client, "cat "+shellQuote(sessionDir+"/agentctl.pid")+" 2>/dev/null")
portStr = strings.TrimSpace(portStr)
pidStr = strings.TrimSpace(pidStr)
if portStr != "" && pidStr != "" && perr == nil {
port, err = strconv.Atoi(portStr)
if err != nil {
return 0, 0, fmt.Errorf("ssh: agentctl wrote invalid port %q", portStr)
}
pid, err = strconv.Atoi(pidStr)
if err != nil {
return 0, 0, fmt.Errorf("ssh: agentctl wrote invalid pid %q", pidStr)
}
log.Info("agentctl started on remote",
zap.Int("port", port),
zap.Int("pid", pid),
zap.String("session_dir", sessionDir))
return port, pid, nil
}
time.Sleep(sshAgentctlReadyPoll)
}
tail, _, _ := runSSHCommand(ctx, client, "tail -n 50 "+shellQuote(sessionDir+"/agentctl.log")+" 2>/dev/null")
return 0, 0, fmt.Errorf("ssh: agentctl did not become ready within %v; log tail:\n%s", sshAgentctlReadyTimeout, tail)
}
// stopRemoteAgentctl best-effort kills a remote agentctl by PID and removes
// the session runtime dir.
func stopRemoteAgentctl(ctx context.Context, client *ssh.Client, sessionDir string, pid int) error {
if pid > 0 {
if _, _, err := runSSHCommand(ctx, client, fmt.Sprintf("kill %d 2>/dev/null || true", pid)); err != nil {
return err
}
}
// Leave the task dir intact (mirrors spec); only wipe session runtime.
_, _, _ = runSSHCommand(ctx, client, "rm -rf "+shellQuote(sessionDir))
return nil
}
// isRemoteAgentctlAlive returns true when a kill -0 on the pid succeeds.
func isRemoteAgentctlAlive(ctx context.Context, client *ssh.Client, pid int) bool {
if pid <= 0 {
return false
}
_, _, err := runSSHCommand(ctx, client, fmt.Sprintf("kill -0 %d", pid))
return err == nil
}
// SSHPortForwarder fans out incoming local-port connections to a remote port
// over the shared SSH connection using direct-tcpip channels. Each Forwarder
// owns its local listener; closing the Forwarder closes the listener and any
// outstanding channels.
type SSHPortForwarder struct {
listener net.Listener
localPort int
remotePort int
logger *logger.Logger
closed chan struct{}
}
// StartPortForward opens a fresh 127.0.0.1:<random> listener and tunnels each
// accept to the given remote port over client. Caller MUST call Close when the
// session ends, otherwise both the listener and the SSH channels leak.
func StartPortForward(client *ssh.Client, remotePort int, log *logger.Logger) (*SSHPortForwarder, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("ssh: local listen: %w", err)
}
addr := listener.Addr().(*net.TCPAddr)
fwd := &SSHPortForwarder{
listener: listener,
localPort: addr.Port,
remotePort: remotePort,
logger: log,
closed: make(chan struct{}),
}
go fwd.serve(client)
return fwd, nil
}
func (f *SSHPortForwarder) serve(client *ssh.Client) {
for {
local, err := f.listener.Accept()
if err != nil {
select {
case <-f.closed:
return
default:
f.logger.Debug("ssh forwarder accept failed", zap.Error(err))
return
}
}
go f.handleLocal(client, local)
}
}
func (f *SSHPortForwarder) handleLocal(client *ssh.Client, local net.Conn) {
defer func() { _ = local.Close() }()
remote, err := client.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(f.remotePort)))
if err != nil {
f.logger.Warn("ssh forwarder dial remote failed",
zap.Int("remote_port", f.remotePort),
zap.Error(err))
return
}
defer func() { _ = remote.Close() }()
go func() { _, _ = io.Copy(remote, local) }()
_, _ = io.Copy(local, remote)
}
// LocalPort returns the local TCP port the forwarder is listening on.
func (f *SSHPortForwarder) LocalPort() int { return f.localPort }
// Close terminates the forwarder. Idempotent.
func (f *SSHPortForwarder) Close() error {
select {
case <-f.closed:
return nil
default:
close(f.closed)
}
return f.listener.Close()
}
// waitAgentctlHealthy polls http://127.0.0.1:<localPort>/health for up to
// timeout. Used to confirm the forwarded tunnel is wired up after start /
// recovery.
func waitAgentctlHealthy(ctx context.Context, localPort int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
dialer := &net.Dialer{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(localPort)))
if err == nil {
_ = conn.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
return fmt.Errorf("agentctl on local port %d not reachable", localPort)
}
@@ -79,6 +79,14 @@ func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktre
for k, v := range req.Metadata {
metadata[k] = v
}
// Merge executor record's Config (host, port, fingerprint, etc.) so
// runtimes that need per-executor config (e.g. SSH) can read it from
// metadata without re-querying the DB.
for k, v := range req.ExecutorConfig {
if _, exists := metadata[k]; !exists {
metadata[k] = v
}
}
if mainRepoGitDir != "" {
metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir
}
@@ -16,6 +16,7 @@ const (
RuntimeDocker Runtime = "docker"
RuntimeRemoteDocker Runtime = "remote_docker"
RuntimeSprites Runtime = "sprites"
RuntimeSSH Runtime = "ssh"
)
// IsContainerized reports whether the runtime hosts the agent
@@ -235,6 +235,9 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec
if imageTag := profile.Config["image_tag"]; imageTag != "" {
metadata["image_tag_override"] = imageTag
}
if workdirRoot := profile.Config["ssh_workdir_root"]; workdirRoot != "" {
metadata["ssh_workdir_root"] = workdirRoot
}
}
// resolveProfileEnvVars resolves profile env vars, dereferencing secret IDs to their values.
+5 -2
View File
@@ -501,6 +501,7 @@ const (
ExecutorTypeLocalDocker ExecutorType = "local_docker"
ExecutorTypeRemoteDocker ExecutorType = "remote_docker"
ExecutorTypeSprites ExecutorType = "sprites"
ExecutorTypeSSH ExecutorType = "ssh"
ExecutorTypeMockRemote ExecutorType = "mock_remote"
)
@@ -509,7 +510,7 @@ const (
// These environments run shells inside the container/VM, not on the host.
func IsRemoteExecutorType(t ExecutorType) bool {
switch t {
case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeMockRemote:
case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeSSH, ExecutorTypeMockRemote:
return true
default:
return false
@@ -531,6 +532,8 @@ func (t ExecutorType) Runtime() agentruntime.Runtime {
return agentruntime.RuntimeRemoteDocker
case ExecutorTypeSprites:
return agentruntime.RuntimeSprites
case ExecutorTypeSSH:
return agentruntime.RuntimeSSH
default:
return agentruntime.RuntimeStandalone
}
@@ -548,7 +551,7 @@ func IsContainerizedExecutorType(t ExecutorType) bool {
// IsAlwaysResumableRuntime reports whether the given runtime represents
// an executor that can always be resumed even without an explicit resume token.
func IsAlwaysResumableRuntime(runtime agentruntime.Runtime) bool {
return runtime == agentruntime.RuntimeSprites
return runtime == agentruntime.RuntimeSprites || runtime == agentruntime.RuntimeSSH
}
const (