diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go index 93e2a44097..39e52564c8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "os" + "os/user" "path/filepath" "strconv" "strings" @@ -19,11 +20,13 @@ import ( const sshDialTimeout = 30 * time.Second +var sshSystemConfigPath = filepath.Join(string(filepath.Separator), "etc", "ssh", "ssh_config") + // SSHIdentitySource enumerates how kandev obtains an SSH credential. type SSHIdentitySource string const ( - SSHIdentitySourceAgent SSHIdentitySource = "agent" // use SSH_AUTH_SOCK + SSHIdentitySourceAgent SSHIdentitySource = "agent" // use IdentityAgent or SSH_AUTH_SOCK SSHIdentitySourceFile SSHIdentitySource = "file" // explicit IdentityFile path ) @@ -34,7 +37,9 @@ type SSHTarget struct { User string // resolved User IdentitySource SSHIdentitySource // agent | file IdentityFile string // path when IdentitySource=file + IdentityAgent string // resolved agent socket override from ssh_config ProxyJump string // optional bastion (single hop) + OriginalHost string // original alias/host, for OpenSSH %n/%k expansion // PinnedFingerprint is the SHA256 fingerprint the user trusted in settings. // Empty means "first connect / test mode" — accept any key and return what we saw. @@ -67,7 +72,9 @@ type SSHConnConfig struct { func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) { t := initialTargetFromConfig(cfg) if alias := strings.TrimSpace(cfg.HostAlias); alias != "" { - inheritFromSSHConfig(alias, t) + if err := inheritFromSSHConfig(alias, t); err != nil { + return nil, err + } } if err := applyTargetDefaults(t); err != nil { return nil, err @@ -79,6 +86,10 @@ func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) { // 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 { + originalHost := strings.TrimSpace(cfg.HostAlias) + if originalHost == "" { + originalHost = strings.TrimSpace(cfg.Host) + } return &SSHTarget{ Host: strings.TrimSpace(cfg.Host), Port: cfg.Port, @@ -86,6 +97,7 @@ func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget { IdentitySource: cfg.IdentitySource, IdentityFile: strings.TrimSpace(cfg.IdentityFile), ProxyJump: strings.TrimSpace(cfg.ProxyJump), + OriginalHost: originalHost, } } @@ -96,8 +108,8 @@ func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget { // package's package-level Get/GetStrict use a sync.Once to load the config once // per process, which breaks tests (and any user who edits their config) — each // resolve should see the current on-disk state. -func inheritFromSSHConfig(alias string, t *SSHTarget) { - cfg := loadUserSSHConfig() +func inheritFromSSHConfig(alias string, t *SSHTarget) error { + cfg := loadEffectiveSSHConfig() if t.Host == "" { if v := lookupSSHConfig(cfg, alias, "HostName"); v != "" { t.Host = strings.TrimSpace(v) @@ -129,17 +141,35 @@ func inheritFromSSHConfig(alias string, t *SSHTarget) { t.ProxyJump = v } } + // IdentityAgent refines agent selection and therefore applies even when + // the create form explicitly selected "agent". An explicit file source + // remains authoritative. + if t.IdentitySource != SSHIdentitySourceFile { + t.IdentityAgent = strings.TrimSpace(lookupSSHConfig(cfg, alias, "IdentityAgent")) + } + return nil } -// loadUserSSHConfig parses $HOME/.ssh/config on demand. Returns nil if the -// file is absent or unreadable; lookupSSHConfig handles a nil config by -// returning the empty string. -func loadUserSSHConfig() *ssh_config.Config { - home, err := os.UserHomeDir() - if err != nil || home == "" { +type effectiveSSHConfig struct { + user *ssh_config.Config + system *ssh_config.Config +} + +// loadEffectiveSSHConfig parses both user and system config on every resolve. +// User values win, matching OpenSSH's first-obtained-value behavior. +func loadEffectiveSSHConfig() effectiveSSHConfig { + home, _ := os.UserHomeDir() + return effectiveSSHConfig{ + user: loadSSHConfigFile(filepath.Join(home, ".ssh", "config")), + system: loadSSHConfigFile(sshSystemConfigPath), + } +} + +func loadSSHConfigFile(path string) *ssh_config.Config { + if path == "" { return nil } - f, err := os.Open(filepath.Join(home, ".ssh", "config")) + f, err := os.Open(path) if err != nil { return nil } @@ -151,20 +181,22 @@ func loadUserSSHConfig() *ssh_config.Config { return cfg } -func lookupSSHConfig(cfg *ssh_config.Config, alias, key string) string { - if cfg == nil { - return "" +func lookupSSHConfig(cfg effectiveSSHConfig, alias, key string) string { + for _, candidate := range []*ssh_config.Config{cfg.user, cfg.system} { + if candidate == nil { + continue + } + v, err := candidate.Get(alias, key) + if err == nil && v != "" { + return v + } } - v, err := cfg.Get(alias, key) - if err != nil { - return "" - } - return v + return "" } // 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. +// host is set. Identity defaults: ssh-agent if IdentityAgent or SSH_AUTH_SOCK +// selects a socket, otherwise fall back to ~/.ssh/id_ed25519. func applyTargetDefaults(t *SSHTarget) error { if t.Host == "" { return errors.New("ssh: host is required") @@ -180,7 +212,7 @@ func applyTargetDefaults(t *SSHTarget) error { t.User = current } if t.IdentitySource == "" { - if os.Getenv("SSH_AUTH_SOCK") != "" { + if t.IdentityAgent != "" || os.Getenv("SSH_AUTH_SOCK") != "" { t.IdentitySource = SSHIdentitySourceAgent } else { t.IdentitySource = SSHIdentitySourceFile @@ -189,6 +221,12 @@ func applyTargetDefaults(t *SSHTarget) error { } if t.IdentitySource == SSHIdentitySourceFile { t.IdentityFile = expandHome(t.IdentityFile) + } else if t.IdentityAgent != "" && !strings.EqualFold(t.IdentityAgent, "none") { + expanded, err := expandIdentityAgent(t.IdentityAgent, t) + if err != nil { + return fmt.Errorf("ssh: expand IdentityAgent %q: %w", t.IdentityAgent, err) + } + t.IdentityAgent = expanded } return nil } @@ -208,6 +246,109 @@ func expandHome(p string) string { return filepath.Join(home, p[2:]) } +// expandIdentityAgent implements OpenSSH IdentityAgent tilde, percent-token, +// ${VAR}, and legacy whole-value $VAR expansion. +func expandIdentityAgent(value string, t *SSHTarget) (string, error) { + tokens, err := identityAgentTokens(t) + if err != nil { + return "", err + } + value, err = expandIdentityAgentPercentTokens(expandHome(value), tokens) + if err != nil { + return "", err + } + value, err = expandIdentityAgentEnvironment(value) + if err != nil { + return "", err + } + if strings.HasPrefix(value, "$") { + name := value[1:] + if name == "" || strings.ContainsAny(name, "/{}") { + return "", fmt.Errorf("invalid legacy environment variable %q", value) + } + socket, ok := os.LookupEnv(name) + if !ok || socket == "" { + return "", fmt.Errorf("environment variable %s is not set", name) + } + return socket, nil + } + if value == "SSH_AUTH_SOCK" { + return os.Getenv("SSH_AUTH_SOCK"), nil + } + return value, nil +} + +func identityAgentTokens(t *SSHTarget) (map[byte]string, error) { + localUser, err := user.Current() + if err != nil { + return nil, fmt.Errorf("local user: %w", err) + } + fullHost, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("local hostname: %w", err) + } + original := t.OriginalHost + if original == "" { + original = t.Host + } + return map[byte]string{ + '%': "%", 'd': localUser.HomeDir, 'h': t.Host, 'i': localUser.Uid, + 'j': t.ProxyJump, 'k': original, 'L': strings.SplitN(fullHost, ".", 2)[0], 'l': fullHost, + 'n': original, 'p': strconv.Itoa(t.Port), 'r': t.User, 'u': localUser.Username, + }, nil +} + +func expandIdentityAgentPercentTokens(value string, tokens map[byte]string) (string, error) { + var out strings.Builder + for i := 0; i < len(value); i++ { + if value[i] != '%' { + out.WriteByte(value[i]) + continue + } + if i+1 == len(value) { + return "", errors.New("trailing % token") + } + i++ + replacement, ok := tokens[value[i]] + if !ok { + return "", fmt.Errorf("unsupported token %%%c", value[i]) + } + out.WriteString(replacement) + } + return out.String(), nil +} + +func expandIdentityAgentEnvironment(value string) (string, error) { + // Scan the original value only. Environment contents can contain `${...}` + // text, but that text is replacement data, not another expression to expand. + var out strings.Builder + for cursor := 0; cursor < len(value); { + relativeStart := strings.Index(value[cursor:], "${") + if relativeStart == -1 { + out.WriteString(value[cursor:]) + break + } + start := cursor + relativeStart + out.WriteString(value[cursor:start]) + endRel := strings.IndexByte(value[start+2:], '}') + if endRel == -1 { + return "", errors.New("unterminated environment variable") + } + end := start + 2 + endRel + name := value[start+2 : end] + if name == "" { + return "", errors.New("empty environment variable name") + } + env, ok := os.LookupEnv(name) + if !ok { + return "", fmt.Errorf("environment variable %s is not set", name) + } + out.WriteString(env) + cursor = end + 1 + } + return out.String(), nil +} + // SSHFingerprint returns the SHA256 fingerprint of a host key in the // standard `SHA256:` format used by OpenSSH and the `ssh` CLI. func SSHFingerprint(key ssh.PublicKey) string { @@ -237,13 +378,19 @@ func buildAuthMethods(target *SSHTarget) ([]ssh.AuthMethod, func(), error) { noop := func() {} switch target.IdentitySource { case SSHIdentitySourceAgent: - sock := os.Getenv("SSH_AUTH_SOCK") + if strings.EqualFold(target.IdentityAgent, "none") { + return nil, noop, errors.New("ssh-agent identity source selected but IdentityAgent is set to none") + } + sock := target.IdentityAgent if sock == "" { - return nil, noop, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set; start an agent and add your key (ssh-add)") + sock = os.Getenv("SSH_AUTH_SOCK") + } + if sock == "" { + return nil, noop, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set and IdentityAgent did not resolve to a socket; start an agent and add your key (ssh-add)") } conn, err := net.Dial("unix", sock) if err != nil { - return nil, noop, fmt.Errorf("failed to connect to ssh-agent: %w", err) + return nil, noop, fmt.Errorf("failed to connect to ssh-agent at %s: %w", sock, err) } ag := agent.NewClient(conn) cleanup := func() { _ = conn.Close() } @@ -455,7 +602,7 @@ func tofuLogOnlyCallback(proxyJump, _ string) ssh.HostKeyCallback { // resolveBastion turns the target's ProxyJump value into a connection // SSHTarget. Two accepted shapes: // -// - alias: looked up in ~/.ssh/config (HostName / Port / User / IdentityFile) +// - alias: resolved from effective OpenSSH config (HostName / Port / User / IdentityAgent / IdentityFile) // - literal "[user@]host[:port]": parsed inline, no config lookup // // The literal form has no Host block to inherit from, so passing it straight @@ -464,19 +611,34 @@ func tofuLogOnlyCallback(proxyJump, _ string) ssh.HostKeyCallback { // Identity defaults flow from the target so the same key reaches the bastion. func resolveBastion(target *SSHTarget) (*SSHTarget, error) { if user, host, port, ok := parseLiteralProxyJump(target.ProxyJump); ok { - return ResolveSSHTarget(SSHConnConfig{ + bastion, err := ResolveSSHTarget(SSHConnConfig{ Host: host, Port: port, User: user, IdentitySource: target.IdentitySource, IdentityFile: target.IdentityFile, }) + if err == nil && bastion.IdentitySource == SSHIdentitySourceAgent { + bastion.IdentityAgent = target.IdentityAgent + } + return bastion, err } - return ResolveSSHTarget(SSHConnConfig{ - HostAlias: target.ProxyJump, - IdentitySource: target.IdentitySource, - IdentityFile: target.IdentityFile, - }) + + // A jump alias owns its explicitly configured auth. If it has no auth + // directive, retain Kandev's existing target-auth fallback behavior. + effective := loadEffectiveSSHConfig() + hasJumpIdentity := lookupSSHConfig(effective, target.ProxyJump, "IdentityFile") != "" || + lookupSSHConfig(effective, target.ProxyJump, "IdentityAgent") != "" + cfg := SSHConnConfig{HostAlias: target.ProxyJump} + if !hasJumpIdentity { + cfg.IdentitySource = target.IdentitySource + cfg.IdentityFile = target.IdentityFile + } + bastion, err := ResolveSSHTarget(cfg) + if err == nil && !hasJumpIdentity && bastion.IdentitySource == SSHIdentitySourceAgent { + bastion.IdentityAgent = target.IdentityAgent + } + return bastion, err } // parseLiteralProxyJump recognizes a `[user@]host[:port]` ProxyJump literal, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go index 9ccf81c487..6307c6ba95 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/common/logger" @@ -766,3 +767,92 @@ func TestSSHManagedBrokerResumeForcesFreshAgentctlWithNewLease(t *testing.T) { t.Fatal("connection metadata required for fresh SSH launch was removed") } } + +func TestExpandIdentityAgentOpenSSHSyntax(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AGENT_DIR", filepath.Join(home, "agents")) + t.Setenv("LEGACY_AGENT", filepath.Join(home, "legacy.sock")) + t.Setenv("SSH_AUTH_SOCK", filepath.Join(home, "env.sock")) + target := &SSHTarget{ + Host: "resolved.internal", Port: 2222, User: "deploy", + ProxyJump: "jump-alias", OriginalHost: "prod", + } + + cases := map[string]string{ + "~/.ssh/agent.sock": filepath.Join(home, ".ssh", "agent.sock"), + "${AGENT_DIR}/%n-%h-%p-%r-%j-%%.sock": filepath.Join(home, "agents", "prod-resolved.internal-2222-deploy-jump-alias-%.sock"), + "$LEGACY_AGENT": filepath.Join(home, "legacy.sock"), + "SSH_AUTH_SOCK": filepath.Join(home, "env.sock"), + "@abstract-agent": "@abstract-agent", + } + for input, want := range cases { + got, err := expandIdentityAgent(input, target) + if err != nil { + t.Errorf("expandIdentityAgent(%q): %v", input, err) + } else if got != want { + t.Errorf("expandIdentityAgent(%q) = %q, want %q", input, got, want) + } + } + + allTokens, err := expandIdentityAgent("%d-%i-%k-%L-%l-%u", target) + if err != nil || allTokens == "" || !strings.Contains(allTokens, "prod") { + t.Fatalf("all token expansion = %q, %v", allTokens, err) + } + for _, input := range []string{"${MISSING_IDENTITY_AGENT}", "%C", "%Z", "trailing%"} { + if _, err := expandIdentityAgent(input, target); err == nil { + t.Errorf("expandIdentityAgent(%q) unexpectedly succeeded", input) + } + } +} + +func TestExpandIdentityAgentRejectsUnsetLegacyVariable(t *testing.T) { + const variable = "MISSING_IDENTITY_AGENT" + previous, wasSet := os.LookupEnv(variable) + if err := os.Unsetenv(variable); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if wasSet { + _ = os.Setenv(variable, previous) + return + } + _ = os.Unsetenv(variable) + }) + t.Setenv("SSH_AUTH_SOCK", "/tmp/fallback.sock") + + got, err := expandIdentityAgent("$"+variable, &SSHTarget{Host: "example.com"}) + if err == nil { + t.Fatalf("expandIdentityAgent() returned %q without an error", got) + } + if !strings.Contains(err.Error(), variable) { + t.Fatalf("error = %v, want missing variable name %q", err, variable) + } +} + +func TestExpandIdentityAgentEnvironmentDoesNotReprocessSubstitutedTokens(t *testing.T) { + const childEnv = "KANDEV_TEST_EXPAND_IDENTITY_AGENT_ENV" + if os.Getenv(childEnv) == "1" { + t.Setenv("AGENT", "${AGENT}") + got, err := expandIdentityAgentEnvironment("${AGENT}") + if err != nil { + t.Fatal(err) + } + if got != "${AGENT}" { + t.Fatalf("expanded value = %q, want the substituted token to remain literal", got) + } + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run", "^TestExpandIdentityAgentEnvironmentDoesNotReprocessSubstitutedTokens$") + cmd.Env = append(os.Environ(), childEnv+"=1") + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("environment expansion did not terminate") + } + if err != nil { + t.Fatalf("child test failed: %v\n%s", err, output) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_dial_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_dial_test.go index 8189a62184..d54954107b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_dial_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_dial_test.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "testing" + "time" "golang.org/x/crypto/ssh" ) @@ -23,6 +24,9 @@ func newTestHomeDir(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("HOME", home) + previousSystemConfig := sshSystemConfigPath + sshSystemConfigPath = filepath.Join(home, "missing-system-ssh-config") + t.Cleanup(func() { sshSystemConfigPath = previousSystemConfig }) if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { t.Fatalf("create fake ~/.ssh: %v", err) } @@ -653,3 +657,198 @@ func writeKnownHosts(t *testing.T, home, host string, key ssh.PublicKey) { t.Fatalf("write known_hosts: %v", err) } } + +func TestIdentityAgentInheritanceAndPrecedence(t *testing.T) { + home := newTestHomeDir(t) + writeSSHConfig(t, home, `Host agent-host + HostName target.internal + User deploy + IdentityAgent ~/.agents/%n.sock +Host file-host + HostName target.internal + User deploy + IdentityAgent /tmp/config-agent.sock +`) + + t.Run("explicit agent selection still inherits IdentityAgent", func(t *testing.T) { + target, err := ResolveSSHTarget(SSHConnConfig{ + HostAlias: "agent-host", IdentitySource: SSHIdentitySourceAgent, + }) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".agents", "agent-host.sock") + if target.IdentityAgent != want { + t.Fatalf("IdentityAgent = %q, want %q", target.IdentityAgent, want) + } + }) + + t.Run("configured IdentityAgent selects agent without global socket", func(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "") + target, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "agent-host"}) + if err != nil { + t.Fatal(err) + } + if target.IdentitySource != SSHIdentitySourceAgent { + t.Fatalf("IdentitySource = %q, want agent", target.IdentitySource) + } + }) + + t.Run("explicit file source ignores IdentityAgent", func(t *testing.T) { + key := writeTestIdentityFile(t) + target, err := ResolveSSHTarget(SSHConnConfig{ + HostAlias: "file-host", IdentitySource: SSHIdentitySourceFile, IdentityFile: key, + }) + if err != nil { + t.Fatal(err) + } + if target.IdentityAgent != "" || target.IdentityFile != key { + t.Fatalf("target auth = %+v", target) + } + }) +} + +func TestBuildAuthMethodsIdentityAgentOverridesEnvironment(t *testing.T) { + dir := t.TempDir() + configuredPath := filepath.Join(dir, "configured.sock") + envPath := filepath.Join(dir, "env.sock") + configured, err := net.Listen("unix", configuredPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = configured.Close() }) + envListener, err := net.Listen("unix", envPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = envListener.Close() }) + t.Setenv("SSH_AUTH_SOCK", envPath) + + _, cleanup, err := buildAuthMethods(&SSHTarget{ + IdentitySource: SSHIdentitySourceAgent, + IdentityAgent: configuredPath, + }) + if err != nil { + t.Fatal(err) + } + cleanup() + + _ = configured.(*net.UnixListener).SetDeadline(time.Now().Add(time.Second)) + conn, err := configured.Accept() + if err != nil { + t.Fatalf("configured IdentityAgent was not dialed: %v", err) + } + _ = conn.Close() + _ = envListener.(*net.UnixListener).SetDeadline(time.Now().Add(20 * time.Millisecond)) + if conn, err := envListener.Accept(); err == nil { + _ = conn.Close() + t.Fatal("SSH_AUTH_SOCK was dialed instead of IdentityAgent") + } +} + +func TestIdentityAgentNoneDisablesEnvironmentFallback(t *testing.T) { + t.Setenv("SSH_AUTH_SOCK", "/tmp/should-not-be-used.sock") + _, cleanup, err := buildAuthMethods(&SSHTarget{ + IdentitySource: SSHIdentitySourceAgent, + IdentityAgent: "none", + }) + cleanup() + if err == nil || !strings.Contains(err.Error(), "IdentityAgent is set to none") { + t.Fatalf("error = %v", err) + } +} + +func TestResolveBastionAuthPrecedence(t *testing.T) { + home := newTestHomeDir(t) + jumpKey := writeTestIdentityFile(t) + writeSSHConfig(t, home, fmt.Sprintf(`Host jump-agent + HostName jump.internal + User jumper + IdentityAgent ~/.agents/jump.sock +Host jump-file + HostName jump.internal + User jumper + IdentityFile %s +Host jump-fallback + HostName jump.internal + User jumper +`, jumpKey)) + + t.Run("jump alias IdentityAgent overrides target socket", func(t *testing.T) { + bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-agent", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"}) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".agents", "jump.sock") + if bastion.IdentitySource != SSHIdentitySourceAgent || bastion.IdentityAgent != want { + t.Fatalf("bastion auth = %+v, want agent %q", bastion, want) + } + }) + + t.Run("jump alias IdentityFile overrides target agent", func(t *testing.T) { + bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-file", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"}) + if err != nil { + t.Fatal(err) + } + if bastion.IdentitySource != SSHIdentitySourceFile || bastion.IdentityFile != jumpKey { + t.Fatalf("bastion auth = %+v", bastion) + } + }) + + t.Run("alias without auth falls back to target", func(t *testing.T) { + bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-fallback", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"}) + if err != nil { + t.Fatal(err) + } + if bastion.IdentityAgent != "/target.sock" { + t.Fatalf("bastion auth = %+v", bastion) + } + }) + + t.Run("literal jump falls back to target", func(t *testing.T) { + bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jumper@jump.internal:22", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"}) + if err != nil { + t.Fatal(err) + } + if bastion.IdentityAgent != "/target.sock" { + t.Fatalf("bastion auth = %+v", bastion) + } + }) +} + +func TestEffectiveSSHConfigReadsUserAndSystemFresh(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + systemPath := filepath.Join(t.TempDir(), "ssh_config") + previousSystemConfig := sshSystemConfigPath + sshSystemConfigPath = systemPath + t.Cleanup(func() { sshSystemConfigPath = previousSystemConfig }) + if err := os.WriteFile(systemPath, []byte("Host system-only\n HostName from-system.internal\n User system-user\n IdentityAgent /system-agent.sock\n"), 0o600); err != nil { + t.Fatal(err) + } + writeSSHConfig(t, home, "Host shared\n HostName first-user.internal\n User user-one\n") + + systemTarget, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "system-only", IdentitySource: SSHIdentitySourceAgent}) + if err != nil { + t.Fatal(err) + } + if systemTarget.Host != "from-system.internal" || systemTarget.User != "system-user" || systemTarget.IdentityAgent != "/system-agent.sock" { + t.Fatalf("system target = %+v", systemTarget) + } + + first, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "shared", IdentitySource: SSHIdentitySourceAgent}) + if err != nil { + t.Fatal(err) + } + writeSSHConfig(t, home, "Host shared\n HostName second-user.internal\n User user-two\n") + second, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "shared", IdentitySource: SSHIdentitySourceAgent}) + if err != nil { + t.Fatal(err) + } + if first.Host != "first-user.internal" || second.Host != "second-user.internal" || second.User != "user-two" { + t.Fatalf("fresh config results = first:%+v second:%+v", first, second) + } +} diff --git a/apps/web/src/locales/en/executors.json b/apps/web/src/locales/en/executors.json index f556016139..c4ca743694 100644 --- a/apps/web/src/locales/en/executors.json +++ b/apps/web/src/locales/en/executors.json @@ -219,7 +219,7 @@ "sshFailedToSaveShell": "Failed to save shell", "sshFingerprintChanged": "Warning: this fingerprint differs from the one currently pinned (<1>{{fingerprint}}). Trusting it overwrites the pinned key. If you didn’t expect a host re-key, stop here.", "sshFingerprintObserved": "Host fingerprint observed:", - "sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config.", + "sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump from your config.", "sshHostAliasLabel": "Host alias from {{path}} (optional)", "sshIdentityFileHint": "Passphrase-protected keys must be loaded into ssh-agent first.", "sshIdentityFilePath": "Identity file path", diff --git a/apps/web/src/locales/pseudo/executors.json b/apps/web/src/locales/pseudo/executors.json index 17e135ffca..83bc002ab6 100644 --- a/apps/web/src/locales/pseudo/executors.json +++ b/apps/web/src/locales/pseudo/executors.json @@ -219,7 +219,7 @@ "sshFailedToSaveShell": "Ƒàĩĺēď ţō śàvē śĥēĺĺ", "sshFingerprintChanged": "Ŵàŕńĩńĝ: ţĥĩś ƒĩńĝēŕƥŕĩńţ ďĩƒƒēŕś ƒŕōḿ ţĥē ōńē ćũŕŕēńţĺŷ ƥĩńńēď (<1>{{fingerprint}}). Ţŕũśţĩńĝ ĩţ ōvēŕŵŕĩţēś ţĥē ƥĩńńēď ķēŷ. Ĩƒ ŷōũ ďĩďń’ţ ēxƥēćţ à ĥōśţ ŕē-ķēŷ, śţōƥ ĥēŕē.", "sshFingerprintObserved": "Ĥōśţ ƒĩńĝēŕƥŕĩńţ ōƀśēŕvēď:", - "sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.", + "sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷÀĝēńţ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.", "sshHostAliasLabel": "Ĥōśţ àĺĩàś ƒŕōḿ {{path}} (ōƥţĩōńàĺ)", "sshIdentityFileHint": "Ƥàśśƥĥŕàśē-ƥŕōţēćţēď ķēŷś ḿũśţ ƀē ĺōàďēď ĩńţō śśĥ-àĝēńţ ƒĩŕśţ.", "sshIdentityFilePath": "Ĩďēńţĩţŷ ƒĩĺē ƥàţĥ", diff --git a/apps/web/src/locales/pt-pt/executors.json b/apps/web/src/locales/pt-pt/executors.json index 6f70956d47..4f57ce75d7 100644 --- a/apps/web/src/locales/pt-pt/executors.json +++ b/apps/web/src/locales/pt-pt/executors.json @@ -203,7 +203,7 @@ "sshFailedToSaveShell": "Não foi possível guardar a shell", "sshFingerprintChanged": "Aviso: esta impressão digital difere da que está atualmente afixada (<1>{{fingerprint}}). Confiar nela substitui a chave afixada. Se não estava à espera de uma mudança de chave do anfitrião, pare aqui.", "sshFingerprintObserved": "Impressão digital do anfitrião observada:", - "sshHostAliasHint": "Se for definido, herda HostName / Port / User / IdentityFile / ProxyJump da sua configuração.", + "sshHostAliasHint": "Se for definido, herda HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump da sua configuração.", "sshHostAliasLabel": "Alias de anfitrião de {{path}} (opcional)", "sshIdentityFileHint": "As chaves protegidas por frase-passe têm de ser carregadas primeiro no ssh-agent.", "sshIdentityFilePath": "Caminho do ficheiro de identidade", diff --git a/apps/web/src/locales/zh-cn/executors.json b/apps/web/src/locales/zh-cn/executors.json index b6601ca31e..6c42684c77 100644 --- a/apps/web/src/locales/zh-cn/executors.json +++ b/apps/web/src/locales/zh-cn/executors.json @@ -203,7 +203,7 @@ "sshFailedToSaveShell": "保存 shell 失败", "sshFingerprintChanged": "警告:此指纹与当前固定的指纹不同(<1>{{fingerprint}})。信任后将覆盖已固定的密钥。若你未预期主机重新生成密钥,请停止操作。", "sshFingerprintObserved": "观察到的主机指纹:", - "sshHostAliasHint": "若已设置,将从配置继承 HostName / Port / User / IdentityFile / ProxyJump。", + "sshHostAliasHint": "若已设置,将从配置继承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。", "sshHostAliasLabel": "来自 {{path}} 的主机别名(可选)", "sshIdentityFileHint": "受密码保护的密钥须先加载到 ssh-agent。", "sshIdentityFilePath": "身份文件路径", diff --git a/apps/web/src/locales/zh-hk/executors.json b/apps/web/src/locales/zh-hk/executors.json index e9a3de555f..168a7fabd1 100644 --- a/apps/web/src/locales/zh-hk/executors.json +++ b/apps/web/src/locales/zh-hk/executors.json @@ -203,7 +203,7 @@ "sshFailedToSaveShell": "儲存 shell 失敗", "sshFingerprintChanged": "警告:此指紋與目前固定的指紋不同(<1>{{fingerprint}})。信任後將覆蓋已固定的密鑰。若你未預期主機重新產生密鑰,請停止操作。", "sshFingerprintObserved": "觀察到的主機指紋:", - "sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityFile / ProxyJump。", + "sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。", "sshHostAliasLabel": "來自 {{path}} 的主機別名(可選)", "sshIdentityFileHint": "受密碼保護的密鑰須先載入到 ssh-agent。", "sshIdentityFilePath": "身份檔案路徑", diff --git a/apps/web/src/locales/zh-tw/executors.json b/apps/web/src/locales/zh-tw/executors.json index d571722271..7ab8e03214 100644 --- a/apps/web/src/locales/zh-tw/executors.json +++ b/apps/web/src/locales/zh-tw/executors.json @@ -203,7 +203,7 @@ "sshFailedToSaveShell": "儲存 shell 失敗", "sshFingerprintChanged": "警告:此指紋與目前固定的指紋不同(<1>{{fingerprint}})。信任後將覆蓋已固定的金鑰。若你未預期主機重新產生金鑰,請停止操作。", "sshFingerprintObserved": "觀察到的主機指紋:", - "sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityFile / ProxyJump。", + "sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。", "sshHostAliasLabel": "來自 {{path}} 的主機別名(可選)", "sshIdentityFileHint": "受密碼保護的金鑰須先載入到 ssh-agent。", "sshIdentityFilePath": "身分檔案路徑", diff --git a/docs/public/executors.md b/docs/public/executors.md index b3e6b0d287..192e6e03f3 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -309,14 +309,14 @@ Released Kandev bundles include helpers for all four platform combinations. The ### Create the connection -Choose **Settings > Executors > SSH**. Enter a name plus either a Host or a host alias from `~/.ssh/config`. The backend resolver can inherit `HostName`, `Port`, `User`, `IdentityFile`, and one `ProxyJump`; explicit form values win. The current create form defaults and persists Port `22` and identity source `ssh-agent`, so enter a non-22 alias port and desired identity source explicitly instead of assuming those two values inherit. `IdentitiesOnly` and arbitrary OpenSSH directives are not consumed by Kandev. +Choose **Settings > Executors > SSH**. Enter a name plus either a Host or a host alias from your OpenSSH client configuration. The backend resolver can inherit `HostName`, `Port`, `User`, `IdentityAgent`, `IdentityFile`, and one `ProxyJump`; explicit form values win. The current create form defaults and persists Port `22` and identity source `ssh-agent`, so enter a non-22 alias port and desired identity source explicitly instead of assuming those two values inherit. `IdentitiesOnly` and arbitrary OpenSSH directives are not consumed by Kandev. Authentication choices are: -- `ssh-agent (SSH_AUTH_SOCK)`; or +- ssh-agent, using the host's expanded `IdentityAgent` when configured and otherwise `$SSH_AUTH_SOCK`; or - an unencrypted private-key file. -Password and keyboard-interactive authentication are not supported. A passphrase-protected key file must first be loaded with `ssh-add`, then used through ssh-agent. +`IdentityAgent none` disables agent authentication for that host. Kandev expands `~`, `${VAR}`, whole-value `$VAR`, `SSH_AUTH_SOCK`, and the `%%`, `%d`, `%h`, `%i`, `%j`, `%k`, `%L`, `%l`, `%n`, `%p`, `%r`, and `%u` OpenSSH tokens in agent socket paths; `%C` is not supported. Password and keyboard-interactive authentication are not supported. A passphrase-protected key file must first be loaded with `ssh-add`, then used through ssh-agent. Run **Test Connection**, independently verify the observed SHA256 host fingerprint, select **Trust this host**, then save. Kandev pins the final target fingerprint and refuses a changed key. With ProxyJump, the target remains pinned, but bastion handling is weaker: Kandev checks `~/.ssh/known_hosts` when available and rejects a changed known key, while an unknown bastion key is accepted on first use. Verify and pre-populate the bastion key yourself. diff --git a/docs/specs/executors/system-design/ssh-executor.md b/docs/specs/executors/system-design/ssh-executor.md index b6be0b0548..8560424812 100644 --- a/docs/specs/executors/system-design/ssh-executor.md +++ b/docs/specs/executors/system-design/ssh-executor.md @@ -70,9 +70,9 @@ Multiple sessions in the *same* task share the same worktree on disk (same files ### Auth, connectivity, and host-key trust -- **Auth: SSH key file + system ssh-agent.** Authentication via `golang.org/x/crypto/ssh` using either an explicit `IdentityFile` or the user's running `ssh-agent` (`$SSH_AUTH_SOCK`) — covers 1Password / Secretive / Yubikey / forwarded-agent users who don't keep raw keys on disk. **Passphrase-protected keys are not handled in kandev** — users must load them into `ssh-agent` themselves. Password and keyboard-interactive auth are not supported in v1. +- **Auth: SSH key file + ssh-agent.** Authentication via `golang.org/x/crypto/ssh` uses either an explicit `IdentityFile` or an ssh-agent. A per-host OpenSSH `IdentityAgent` overrides the backend process's `$SSH_AUTH_SOCK`, covering 1Password / Secretive / Yubikey / forwarded-agent users whose agent socket differs from the desktop process environment. `IdentityAgent none` disables agent authentication for that host. **Passphrase-protected keys are not handled in kandev** — users must load them into `ssh-agent` themselves. Password and keyboard-interactive auth are not supported in v1. -- **`~/.ssh/config` inheritance.** When a `host_alias` is configured, kandev parses `~/.ssh/config` to inherit `HostName`, `Port`, `User`, `IdentityFile`, `ProxyJump`, and `IdentitiesOnly` from the user's existing config. A user whose terminal already does `ssh prod` can paste `prod` into kandev and have it just work. +- **OpenSSH config inheritance.** When a `host_alias` is configured, kandev resolves user and system OpenSSH client configuration to inherit `HostName`, `Port`, `User`, `IdentityAgent`, `IdentityFile`, and one `ProxyJump`. `IdentityAgent` supports `~`, `${VAR}`, whole-value `$VAR`, `SSH_AUTH_SOCK`, and percent tokens `%%`, `%d`, `%h`, `%i`, `%j`, `%k`, `%L`, `%l`, `%n`, `%p`, `%r`, and `%u`; `%C` is not supported. A user whose terminal already does `ssh prod` can paste `prod` into kandev and have it just work. - **Connectivity: direct, ProxyJump, mesh-VPN.** Direct TCP to `host:port` (default 22). `ProxyJump` (single bastion in v1; chained jumps deferred) implemented natively via the Go SSH client. Tailscale / WireGuard / corporate VPN: "just works" when the kandev backend process is on the same network namespace. @@ -183,6 +183,12 @@ Multiple sessions in the *same* task share the same worktree on disk (same files - **GIVEN** a user keeps their SSH key in 1Password (no key file on disk) with their agent running, **WHEN** they select "ssh-agent" as identity source, **THEN** the connection succeeds without kandev ever touching key material; passphrase-protected keys without an agent are explicitly rejected with a "load this key into ssh-agent first" message. +- **GIVEN** a host alias sets `IdentityAgent` to a working agent socket while the backend's `$SSH_AUTH_SOCK` is empty or stale, **WHEN** the user selects "ssh-agent", **THEN** kandev connects through the alias-specific socket. + +- **GIVEN** a target and its ProxyJump alias select different `IdentityAgent` sockets, **WHEN** kandev connects through the bastion, **THEN** each hop uses its own configured agent; a jump without an auth directive falls back to the target identity. + +- **GIVEN** a literal `[user@]host[:port]` ProxyJump has no matching host stanza, **WHEN** kandev connects through it, **THEN** the jump inherits the target identity. + - **GIVEN** a task already has session A running on an SSH host, **WHEN** the user opens a second session B on the same task, **THEN** kandev reuses the existing task dir (no re-clone), launches a second agentctl on a different remote port with its own local SSH forward over the same SSH connection, and both sessions stream independently against the same worktree. - **GIVEN** two different tasks are running against the same SSH host, **WHEN** the kandev backend restarts, **THEN** `ResumeRemoteInstance` opens one SSH connection to the host, re-forwards each session's recorded remote port through that single connection, verifies `kill -0 ` on each, and the UI reconnects without losing in-progress turns.