From 85fd8962422b48e3cf5e31e04b68a513c7f33739 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 13:30:22 +0700 Subject: [PATCH 1/2] fix: expand tilde in SSH agent socket path before setenv --- .../Core/SSH/Auth/AgentAuthenticator.swift | 2 +- .../SSH/Auth/PublicKeyAuthenticator.swift | 14 +--------- TablePro/Core/SSH/SSHConfigParser.swift | 18 +++--------- TablePro/Core/SSH/SSHPathUtilities.swift | 23 +++++++++++++++ .../Core/SSH/SSHConfigurationTests.swift | 28 +++++++++++++++++++ 5 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 TablePro/Core/SSH/SSHPathUtilities.swift diff --git a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift index 946e53d3b..1a8b6ceac 100644 --- a/TablePro/Core/SSH/Auth/AgentAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -21,7 +21,7 @@ internal struct AgentAuthenticator: SSHAuthenticator { let originalSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] let needsSocketOverride = socketPath != nil - if let overridePath = socketPath, needsSocketOverride { + if let overridePath = socketPath.map(SSHPathUtilities.expandTilde), needsSocketOverride { Self.agentSocketLock.lock() Self.logger.debug("Using custom SSH agent socket: \(overridePath, privacy: .private)") setenv("SSH_AUTH_SOCK", overridePath, 1) diff --git a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift index 0f88e9b29..67916728f 100644 --- a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift @@ -12,7 +12,7 @@ internal struct PublicKeyAuthenticator: SSHAuthenticator { let passphrase: String? func authenticate(session: OpaquePointer, username: String) throws { - let expandedPath = expandPath(privateKeyPath) + let expandedPath = SSHPathUtilities.expandTilde(privateKeyPath) guard FileManager.default.fileExists(atPath: expandedPath) else { throw SSHTunnelError.tunnelCreationFailed( @@ -54,16 +54,4 @@ internal struct PublicKeyAuthenticator: SSHAuthenticator { } } - private func expandPath(_ path: String) -> String { - if path.hasPrefix("~/") { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(String(path.dropFirst(2))) - .path(percentEncoded: false) - } - if path == "~" { - return FileManager.default.homeDirectoryForCurrentUser - .path(percentEncoded: false) - } - return path - } } diff --git a/TablePro/Core/SSH/SSHConfigParser.swift b/TablePro/Core/SSH/SSHConfigParser.swift index 93ae5e46a..6f550827f 100644 --- a/TablePro/Core/SSH/SSHConfigParser.swift +++ b/TablePro/Core/SSH/SSHConfigParser.swift @@ -86,8 +86,8 @@ final class SSHConfigParser { hostname: currentHostname, port: currentPort, user: currentUser, - identityFile: expandPath(currentIdentityFile), - identityAgent: expandPath(currentIdentityAgent), + identityFile: currentIdentityFile.map(SSHPathUtilities.expandTilde), + identityAgent: currentIdentityAgent.map(SSHPathUtilities.expandTilde), proxyJump: currentProxyJump )) } @@ -133,8 +133,8 @@ final class SSHConfigParser { hostname: currentHostname, port: currentPort, user: currentUser, - identityFile: expandPath(currentIdentityFile), - identityAgent: expandPath(currentIdentityAgent), + identityFile: currentIdentityFile.map(SSHPathUtilities.expandTilde), + identityAgent: currentIdentityAgent.map(SSHPathUtilities.expandTilde), proxyJump: currentProxyJump )) } @@ -192,14 +192,4 @@ final class SSHConfigParser { return jumpHosts } - /// Expand ~ to home directory in path - private static func expandPath(_ path: String?) -> String? { - guard let path = path else { return nil } - - if path.hasPrefix("~") { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(String(path.dropFirst(2))).path(percentEncoded: false) - } - return path - } } diff --git a/TablePro/Core/SSH/SSHPathUtilities.swift b/TablePro/Core/SSH/SSHPathUtilities.swift new file mode 100644 index 000000000..bfb965c44 --- /dev/null +++ b/TablePro/Core/SSH/SSHPathUtilities.swift @@ -0,0 +1,23 @@ +// +// SSHPathUtilities.swift +// TablePro +// + +import Foundation + +enum SSHPathUtilities { + /// Expand ~ to the current user's home directory in a path. + /// Unlike shell commands, `setenv()` and file APIs do not expand `~` automatically. + static func expandTilde(_ path: String) -> String { + if path.hasPrefix("~/") { + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(String(path.dropFirst(2))) + .path(percentEncoded: false) + } + if path == "~" { + return FileManager.default.homeDirectoryForCurrentUser + .path(percentEncoded: false) + } + return path + } +} diff --git a/TableProTests/Core/SSH/SSHConfigurationTests.swift b/TableProTests/Core/SSH/SSHConfigurationTests.swift index 65f778c30..7e9f3cda0 100644 --- a/TableProTests/Core/SSH/SSHConfigurationTests.swift +++ b/TableProTests/Core/SSH/SSHConfigurationTests.swift @@ -150,6 +150,34 @@ struct SSHConfigurationTests { #expect(config.isValid == false) } + // MARK: - SSHPathUtilities + + @Test("Tilde expansion resolves ~/path to home directory") + func testTildeExpansionWithSubpath() { + let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + let result = SSHPathUtilities.expandTilde("~/Library/agent.sock") + #expect(result == "\(home)/Library/agent.sock") + } + + @Test("Tilde expansion resolves bare ~ to home directory") + func testTildeExpansionBare() { + let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + let result = SSHPathUtilities.expandTilde("~") + #expect(result == home) + } + + @Test("Tilde expansion leaves absolute paths unchanged") + func testTildeExpansionAbsolutePath() { + let result = SSHPathUtilities.expandTilde("/absolute/path") + #expect(result == "/absolute/path") + } + + @Test("Tilde expansion leaves empty string unchanged") + func testTildeExpansionEmptyString() { + let result = SSHPathUtilities.expandTilde("") + #expect(result == "") + } + @Test("Backward-compatible decoding without jumpHosts key") func testBackwardCompatibleDecoding() throws { let jsonString = """ From 663c176a3b95e2f7fbd3d52a321b4eae5fdfbd03 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 15 Mar 2026 13:35:57 +0700 Subject: [PATCH 2/2] fix: remove trailing blank line and add CHANGELOG entry --- CHANGELOG.md | 1 + TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eac488ee3..e92bd94d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SSH agent connections failing when socket path contains `~` (e.g., 1Password agent) - Keychain authorization prompt no longer appears on every table open ## [0.18.1] - 2026-03-14 diff --git a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift index 67916728f..b9fcc434e 100644 --- a/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift +++ b/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift @@ -53,5 +53,4 @@ internal struct PublicKeyAuthenticator: SSHAuthenticator { throw SSHTunnelError.authenticationFailed } } - }