Add Host PTY environment variables

This commit is contained in:
Kaede Akino committed 2026-09-21 16:48:54 +08:00
1 parent 46f4b17ab0
commit 2918b32af3
14 files changed
+600 -27

No files matched your search

@@ -34,6 +34,31 @@ struct HostShellIntegrationTests {
Host.self, from: JSONSerialization.data(withJSONObject: json)
)
#expect(decoded.startupSnippetID == nil)
#expect(decoded.environmentVariables.isEmpty)
}
@Test func environmentVariablesValidateAndRoundTripInOrder() throws {
let variables = [
HostEnvironmentVariable(variable: "TERMIND_ENV", value: "production"),
HostEnvironmentVariable(variable: "EMPTY", value: ""),
]
let host = Host(name: "Server", host: "server.invalid", environmentVariables: variables)
#expect(host.environmentVariablesAreValid)
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host)
#expect(PersistentHost(host).value == host)
#expect(!HostEnvironmentVariable(variable: "1INVALID", value: "value").isValid)
#expect(!HostEnvironmentVariable(variable: "HAS SPACE", value: "value").isValid)
#expect(!HostEnvironmentVariable(variable: "VALID", value: "value\0suffix").isValid)
#expect(
!Host(
name: "Duplicate", host: "server.invalid",
environmentVariables: [
HostEnvironmentVariable(variable: "SAME", value: "one"),
HostEnvironmentVariable(variable: "SAME", value: "two"),
]
).environmentVariablesAreValid
)
}
@Test func savedModesSyncAndCanReturnToFollowingGlobalSettings() throws {
@@ -70,11 +70,14 @@ struct PersistenceCompatibilityTests {
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV24.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV25.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier })
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV27.versionIdentifier })
#expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self })
#expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
#expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
#expect(!TermindSchemaV26.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
#expect(TermindSchemaV26.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self })
#expect(!TermindSchemaV27.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self })
#expect(TermindSchemaV27.models.contains { $0 == TermindSchemaV27HostModels.PersistentHost.self })
#expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" })
#expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" })
#expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" })
@@ -109,6 +112,35 @@ struct PersistenceCompatibilityTests {
#expect(host.startupSnippetID == nil)
}
@Test
func version26StoreMigratesWithNoEnvironmentVariables() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
let oldSchema = Schema(versionedSchema: TermindSchemaV26.self)
let oldConfiguration = ModelConfiguration(
"Termind", schema: oldSchema, url: url, cloudKitDatabase: .none
)
var oldContainer: ModelContainer? = try ModelContainer(
for: oldSchema, configurations: [oldConfiguration]
)
oldContainer?.mainContext.insert(
TermindSchemaV26HostModels.PersistentHost(
Host(name: "Before environment variables", host: "server.invalid")
)
)
try oldContainer?.mainContext.save()
oldContainer = nil
let migrated = try TermindPersistence.makeContainer(url: url)
let host = try #require(
migrated.mainContext.fetch(FetchDescriptor<PersistentHost>()).first?.value
)
#expect(host.name == "Before environment variables")
#expect(host.environmentVariables.isEmpty)
}
@Test
func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+120 -17
View File
@@ -2347,6 +2347,23 @@
}
}
},
"Add Environment Variable" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加环境变量"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "加入環境變數"
}
}
}
},
"Add Forwarding" : {
"extractionState" : "stale",
"localizations" : {
@@ -9192,6 +9209,22 @@
}
}
},
"Edit Environment Variable" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "编辑环境变量"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "編輯環境變數"
}
}
}
},
"Edit Forwarding" : {
"localizations" : {
"zh-Hans" : {
@@ -9775,6 +9808,23 @@
}
}
},
"Environment Variables" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "环境变量"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "環境變數"
}
}
}
},
"Error" : {
"extractionState" : "manual",
"localizations" : {
@@ -15248,6 +15298,22 @@
}
}
},
"New Environment Variable" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "新建环境变量"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "新增環境變數"
}
}
}
},
"New Folder" : {
"localizations" : {
"zh-Hans" : {
@@ -17396,6 +17462,7 @@
}
},
"Organization" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -22327,22 +22394,6 @@
}
}
},
"SFTP Initial Path" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SFTP初始路径"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "SFTP初始路徑"
}
}
}
},
"SFTP Error" : {
"localizations" : {
"zh-Hans" : {
@@ -22359,6 +22410,23 @@
}
}
},
"SFTP Initial Path" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SFTP初始路径"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "SFTP初始路徑"
}
}
}
},
"SHA-256" : {
"extractionState" : "stale",
"localizations" : {
@@ -24114,6 +24182,7 @@
}
},
"Store password in" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -24130,6 +24199,7 @@
}
},
"Stored in" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -25272,6 +25342,7 @@
}
},
"The host could not be saved." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -26339,6 +26410,22 @@
}
}
},
"The server rejected these environment variables: %@" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "服务器拒绝了这些环境变量:%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "伺服器拒絕了這些環境變數:%@"
}
}
}
},
"The server sent an invalid SSH identification." : {
"extractionState" : "stale",
"localizations" : {
@@ -29281,6 +29368,22 @@
}
}
},
"Variable" : {
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "变量"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "變數"
}
}
}
},
"Vault" : {
"localizations" : {
"zh-Hans" : {
@@ -30425,4 +30528,4 @@
}
},
"version" : "1.2"
}
}
+40 -1
View File
@@ -1,5 +1,32 @@
import Foundation
public nonisolated struct HostEnvironmentVariable: Codable, Hashable, Sendable {
public var variable: String
public var value: String
public init(variable: String, value: String) {
self.variable = variable
self.value = value
}
public var isValid: Bool {
guard let first = variable.utf8.first,
first == Character("_").asciiValue || first.isASCIILetter,
!variable.contains("\0"), !value.contains("\0")
else { return false }
return variable.utf8.dropFirst().allSatisfy {
$0 == Character("_").asciiValue || $0.isASCIILetter || $0.isASCIIDigit
}
}
}
nonisolated
extension UInt8
{
fileprivate var isASCIILetter: Bool { (65...90).contains(self) || (97...122).contains(self) }
fileprivate var isASCIIDigit: Bool { (48...57).contains(self) }
}
public nonisolated struct HostProxy: Codable, Hashable, Sendable {
public nonisolated enum ProtocolKind: String, Codable, CaseIterable, Identifiable, Sendable {
case http
@@ -93,6 +120,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
/// A reusable script submitted after each interactive SSH shell becomes ready.
/// The body is resolved from the Snippet library for every connection attempt.
public var startupSnippetID: Snippet.ID?
/// Plain-text variables requested on interactive PTY channels before the shell starts.
/// Servers may reject individual variables according to their AcceptEnv policy.
public var environmentVariables: [HostEnvironmentVariable] = []
/// Physical SSH connections available to local and dynamic forwarding on supported platforms.
public var portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections
/// Last distribution reported by Shell Integration for this saved Host.
@@ -149,6 +179,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0,
shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings,
startupSnippetID: Snippet.ID? = nil,
environmentVariables: [HostEnvironmentVariable] = [],
portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections
) {
self.id = id
@@ -166,6 +197,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
self.sortIndex = sortIndex
self.shellIntegrationMode = shellIntegrationMode
self.startupSnippetID = startupSnippetID
self.environmentVariables = environmentVariables
self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections(
portForwardingMaxConnections)
}
@@ -174,7 +206,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
case id, name, host, port, identityID, jumpHostID, proxy
case sftpChannelInitialPath = "sftpSessionInitialPath"
case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode
case startupSnippetID, portForwardingMaxConnections
case startupSnippetID, environmentVariables, portForwardingMaxConnections
}
public init(from decoder: any Decoder) throws {
@@ -196,6 +228,8 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode)
.flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings
startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID)
environmentVariables =
try values.decodeIfPresent([HostEnvironmentVariable].self, forKey: .environmentVariables) ?? []
portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections(
try values.decodeIfPresent(Int.self, forKey: .portForwardingMaxConnections)
?? Self.defaultPortForwardingMaxConnections)
@@ -205,6 +239,11 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
min(max(value, portForwardingMaxConnectionsRange.lowerBound), portForwardingMaxConnectionsRange.upperBound)
}
public var environmentVariablesAreValid: Bool {
environmentVariables.allSatisfy(\.isValid)
&& Set(environmentVariables.map(\.variable)).count == environmentVariables.count
}
}
/// A user-defined folder of hosts. `parentID` is optional and self-referential,
@@ -755,6 +755,15 @@ public enum TermindSchemaV26: VersionedSchema {
}
}
public enum TermindSchemaV27: VersionedSchema {
public static let versionIdentifier = Schema.Version(27, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV26.models.filter {
$0 != TermindSchemaV26HostModels.PersistentHost.self
} + [TermindSchemaV27HostModels.PersistentHost.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -765,7 +774,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self,
TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self,
TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self,
TermindSchemaV26.self,
TermindSchemaV26.self, TermindSchemaV27.self,
]
}
@@ -833,10 +842,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV23.self, toVersion: TermindSchemaV24.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self),
]
}
}
public typealias PersistentHost = TermindSchemaV26HostModels.PersistentHost
public typealias PersistentHost = TermindSchemaV27HostModels.PersistentHost
public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity
public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey
@@ -5,7 +5,7 @@ import SwiftData
/// choose storage and own the container lifetime, never the model set.
@MainActor
public enum TermindPersistence {
public static let schema = Schema(versionedSchema: TermindSchemaV26.self)
public static let schema = Schema(versionedSchema: TermindSchemaV27.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,96 @@
import Foundation
import SwiftData
import TermindDomain
import struct TermindDomain.Host
/// V27 adds ordered PTY environment variables while preserving the Host entity name.
public enum TermindSchemaV27HostModels {
@Model
public final class PersistentHost {
@Attribute(.unique) public var id: UUID = UUID()
public var name = ""
public var address = ""
public var port = 22
public var identityID: UUID?
public var jumpHostID: UUID?
public var proxyProtocol: String?
public var proxyHostname = ""
public var proxyPort = 0
public var proxyUsername = ""
public var proxyIdentityID: UUID?
public var sftpSessionInitialPath = ""
public var groupID: UUID?
public var tags: [String] = []
public var colorTag = TagColor.none.rawValue
public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue
public var startupSnippetID: UUID?
public var environmentVariableNames: [String] = []
public var environmentVariableValues: [String] = []
public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections
public var detectedDistributionID: String?
public var detectedDistributionLikeIDs: [String] = []
public var lastUsedAt: Date?
public var sortIndex = 0.0
public init(_ value: Host) {
id = value.id
apply(value)
}
public func apply(_ value: Host) {
name = value.name
address = value.host
port = value.port
identityID = value.identityID
jumpHostID = value.jumpHostID
proxyProtocol = value.proxy?.kind.rawValue
proxyHostname = value.proxy?.hostname ?? ""
proxyPort = value.proxy?.port ?? 0
proxyUsername = value.proxy?.username ?? ""
proxyIdentityID = value.proxy?.identityID
sftpSessionInitialPath = value.sftpChannelInitialPath
groupID = value.groupID
tags = value.tags
colorTag = value.colorTag.rawValue
shellIntegrationMode = value.shellIntegrationMode.rawValue
startupSnippetID = value.startupSnippetID
environmentVariableNames = value.environmentVariables.map(\.variable)
environmentVariableValues = value.environmentVariables.map(\.value)
portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections(
value.portForwardingMaxConnections)
detectedDistributionID = value.detectedDistribution?.id
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
sortIndex = value.sortIndex
}
public var value: Host {
var host = Host(
id: id, name: name, host: address, port: port,
identityID: identityID, groupID: groupID, tags: tags,
colorTag: TagColor(rawValue: colorTag) ?? .none,
detectedDistribution: detectedDistributionID.flatMap {
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
},
sortIndex: sortIndex,
shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode)
?? .followGlobalSettings,
startupSnippetID: startupSnippetID,
environmentVariables: zip(environmentVariableNames, environmentVariableValues).map {
HostEnvironmentVariable(variable: $0, value: $1)
},
portForwardingMaxConnections: portForwardingMaxConnections
?? Host.defaultPortForwardingMaxConnections
)
host.jumpHostID = jumpHostID
host.sftpChannelInitialPath = sftpSessionInitialPath
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
host.proxy = HostProxy(
kind: kind, hostname: proxyHostname, port: proxyPort,
username: proxyUsername, identityID: proxyIdentityID
)
}
return host
}
}
}
@@ -22,19 +22,25 @@ public struct HostIntegrationSection: View {
public struct HostSessionSection: View {
@Binding private var sftpInitialPath: String
@Binding private var startupSnippetID: Snippet.ID?
@Binding private var environmentVariables: [HostEnvironmentVariable]
private let snippets: [Snippet]
private let snippetPackagePath: (SnippetPackage.ID?) -> String?
private let editEnvironmentVariable: (Int?) -> Void
public init(
sftpInitialPath: Binding<String>,
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]),
snippets: [Snippet] = [],
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil }
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil },
editEnvironmentVariable: @escaping (Int?) -> Void = { _ in }
) {
_sftpInitialPath = sftpInitialPath
_startupSnippetID = startupSnippetID
_environmentVariables = environmentVariables
self.snippets = snippets
self.snippetPackagePath = snippetPackagePath
self.editEnvironmentVariable = editEnvironmentVariable
}
public var body: some View {
@@ -63,6 +69,68 @@ public struct HostSessionSection: View {
.pickerStyle(.menu)
.menuOrder(.fixed)
}
Section("Environment Variables") {
ForEach(environmentVariables.indices, id: \.self) { index in
environmentVariableRow(at: index)
}
Button {
editEnvironmentVariable(nil)
} label: {
HStack(spacing: 8) {
Text("Add Environment Variable")
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
@ViewBuilder
private func environmentVariableRow(at index: Int) -> some View {
#if os(iOS)
Button {
editEnvironmentVariable(index)
} label: {
environmentVariableLabel(at: index)
}
.buttonStyle(.plain)
.swipeActions {
Button("Delete", role: .destructive) {
environmentVariables.remove(at: index)
}
}
#else
HStack(spacing: 8) {
Button {
editEnvironmentVariable(index)
} label: {
environmentVariableLabel(at: index)
}
.buttonStyle(.plain)
Button("Delete", systemImage: "xmark", role: .destructive) {
environmentVariables.remove(at: index)
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
}
#endif
}
private func environmentVariableLabel(at index: Int) -> some View {
Text("\(environmentVariables[index].variable)=\(environmentVariables[index].value)")
.font(.body.monospaced())
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func snippetLabel(_ snippet: Snippet) -> String {
@@ -602,9 +602,12 @@ public actor SSHPTYChannel {
public typealias OutputHandler = @Sendable (Data) -> Void
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
private nonisolated let worker: SSHPTYChannelWorker
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
private var environmentVariables: [HostEnvironmentVariable] = []
private var environmentRejection: EnvironmentRejectionHandler = { _ in }
public init(
output: @escaping OutputHandler, exit: @escaping ExitHandler,
@@ -690,11 +693,21 @@ public actor SSHPTYChannel {
defer { request.configuration.clearSecrets() }
try await worker.connect(
using: request.withKeyboardInteractiveFallback(keyboardInteractive),
environmentVariables: environmentVariables,
environmentRejection: environmentRejection,
shellIntegrationEnabled: shellIntegrationEnabled,
columns: columns, rows: rows, progress: progress
)
}
public func configureEnvironment(
_ variables: [HostEnvironmentVariable],
rejection: @escaping EnvironmentRejectionHandler = { _ in }
) {
environmentVariables = variables
environmentRejection = rejection
}
public func write(_ data: Data) {
worker.write(data)
}
@@ -1011,6 +1024,8 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
public func connect(
using request: SSHConnectionRequest,
environmentVariables: [HostEnvironmentVariable],
environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler,
shellIntegrationEnabled: Bool,
columns: UInt16, rows: UInt16,
progress: SSHRouteProgressHandler?
@@ -1059,6 +1074,22 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
)
guard pty == 0 else { throw Self.connectionError(created, prefix: "PTY request failed") }
ptyResize.didCreatePTY(size: size)
var rejectedVariables: [String] = []
for environmentVariable in environmentVariables {
let result = environmentVariable.variable.withCString { variable in
environmentVariable.value.withCString { value in
libssh2_channel_setenv_ex(
openedChannel, variable, UInt32(environmentVariable.variable.utf8.count),
value, UInt32(environmentVariable.value.utf8.count))
}
}
if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED {
rejectedVariables.append(environmentVariable.variable)
} else if result != 0 {
throw Self.connectionError(created, prefix: "SSH environment request failed")
}
}
if !rejectedVariables.isEmpty { environmentRejection(rejectedVariables) }
guard libssh2_channel_process_startup(openedChannel, "shell", 5, nil, 0) == 0 else {
throw Self.connectionError(created, prefix: "Remote shell request failed")
}
+4
View File
@@ -190,6 +190,10 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
### Terminal behavior
- Host environment variables are ordinary Vault-synced, non-secret configuration. Interactive SSH PTY Channels send
them as SSH environment requests before starting the shell on every connection and reconnection. Server rejection is
non-fatal and reports variable names, never values, in local terminal output. Exec, SFTP, Metrics, Agent, forwarding
and background probe Channels never inherit them; there is no shell-command fallback.
- SSH worker owns PTY dimensions; retain latest valid authentication-time size/pixels. EAGAIN window-change packets keep
original dimensions and finish before the newest request without blocking other I/O. Disconnect clears resize state;
retired auth cannot install a PTY/clear replacement dimensions. Ghostty computes grid. Resize failure is diagnostic only.
+74 -4
View File
@@ -6,12 +6,13 @@ import TermindPresentation
import struct TermindDomain.Host
struct MobileHostEditor: View {
private enum EditorRoute: String, Identifiable {
private enum EditorRoute: Hashable, Identifiable {
case hostIdentity
case proxyIdentity
case group
case environmentVariable(Int?)
var id: String { rawValue }
var id: Self { self }
}
private enum Choice<ID: Hashable>: Hashable {
@@ -43,7 +44,8 @@ struct MobileHostEditor: View {
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65_535).contains(draft.port)
&& customIdentity.isSSHValid(for: draft)
&& customIdentity.isProxyValid(for: draft),
&& customIdentity.isProxyValid(for: draft)
&& draft.environmentVariablesAreValid,
save: {
try customIdentity.save(into: &draft, using: library)
},
@@ -158,8 +160,10 @@ struct MobileHostEditor: View {
HostSessionSection(
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
environmentVariables: $draft.environmentVariables,
snippets: library.snippets,
snippetPackagePath: library.snippetPackagePath
snippetPackagePath: library.snippetPackagePath,
editEnvironmentVariable: { editorRoute = .environmentVariable($0) }
)
HostIntegrationSection(mode: $draft.shellIntegrationMode)
}
@@ -189,6 +193,20 @@ struct MobileHostEditor: View {
MobileHostGroupEditor(app: app, group: nil, parentID: nil) { group in
draft.groupID = group.id
}
case .environmentVariable(let index):
MobileHostEnvironmentVariableEditor(
variable: index.map { draft.environmentVariables[$0] },
usedVariables: Set(
draft.environmentVariables.indices.compactMap {
$0 == index ? nil : draft.environmentVariables[$0].variable
})
) { variable in
if let index {
draft.environmentVariables[index] = variable
} else {
draft.environmentVariables.append(variable)
}
}
}
}
.onAppear(perform: loadCustomOnce)
@@ -287,6 +305,58 @@ struct MobileHostEditor: View {
}
private struct MobileHostEnvironmentVariableEditor: View {
let original: HostEnvironmentVariable
let isNew: Bool
let usedVariables: Set<String>
let didSave: (HostEnvironmentVariable) -> Void
@State private var draft: HostEnvironmentVariable
init(
variable: HostEnvironmentVariable?, usedVariables: Set<String>,
didSave: @escaping (HostEnvironmentVariable) -> Void
) {
let initial = variable ?? HostEnvironmentVariable(variable: "", value: "")
original = initial
isNew = variable == nil
self.usedVariables = usedVariables
self.didSave = didSave
_draft = State(initialValue: initial)
}
var body: some View {
MobileEditorSheet(
title: String(localized: isNew ? "New Environment Variable" : "Edit Environment Variable"),
isDirty: draft != original,
canSave: draft.isValid && !usedVariables.contains(draft.variable),
compact: true,
save: { didSave(draft) },
content: {
LabeledContent("Variable") {
TextField("Variable", text: $draft.variable.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
LabeledContent("Value") {
TextField("Value", text: $draft.value.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
}
)
}
}
nonisolated enum MobileHostEditorPolicy {
static func selectProxy(
_ kind: HostProxy.ProtocolKind?, current: HostProxy?
@@ -697,6 +697,11 @@ final class MobileWorkspace: Identifiable {
handlers.exit,
handlers.shellIntegration)
ssh = session
await session.configureEnvironment(host.environmentVariables) { variables in
let list = variables.joined(separator: ", ")
let warning = String(localized: "The server rejected these environment variables: \(list)")
backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8))
}
try await connectionServices.connectSSH(
session,
request,
+86 -1
View File
@@ -14,6 +14,7 @@ struct HostEditorView: View {
case identity
case proxyIdentity
case group
case environmentVariable(Int?)
}
let hostID: Host.ID?
@@ -201,8 +202,10 @@ struct HostEditorView: View {
HostSessionSection(
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
environmentVariables: $draft.environmentVariables,
snippets: app.hostLibrary.snippets,
snippetPackagePath: app.hostLibrary.snippetPackagePath
snippetPackagePath: app.hostLibrary.snippetPackagePath,
editEnvironmentVariable: { route = .environmentVariable($0) }
)
Section("Port Forwarding") {
@@ -282,6 +285,21 @@ struct HostEditorView: View {
try app.hostLibrary.upsert(identity, passwordUpdate: passwordUpdate)
customIdentity.selectProxyIdentity(identity, on: &draft)
}
case .environmentVariable(let index):
HostEnvironmentVariableEditor(
variable: index.map { draft.environmentVariables[$0] },
usedVariables: Set(
draft.environmentVariables.indices.compactMap {
$0 == index ? nil : draft.environmentVariables[$0].variable
}),
back: closeNestedEditor
) { variable in
if let index {
draft.environmentVariables[index] = variable
} else {
draft.environmentVariables.append(variable)
}
}
}
}
@@ -498,6 +516,7 @@ struct HostEditorView: View {
&& (1...65_535).contains(draft.port)
&& customIdentity.isProxyValid(for: draft)
&& customIdentity.isSSHValid(for: draft)
&& draft.environmentVariablesAreValid
&& (draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| draft.sftpChannelInitialPath.hasPrefix("/"))
&& app.hostLibrary.validatedRoute(for: draft) != nil
@@ -538,3 +557,69 @@ struct HostEditorView: View {
window.openPage(.hosts)
}
}
private struct HostEnvironmentVariableEditor: View {
let original: HostEnvironmentVariable?
let usedVariables: Set<String>
let back: () -> Void
let save: (HostEnvironmentVariable) -> Void
@State private var draft: HostEnvironmentVariable
init(
variable: HostEnvironmentVariable?, usedVariables: Set<String>, back: @escaping () -> Void,
save: @escaping (HostEnvironmentVariable) -> Void
) {
original = variable
self.usedVariables = usedVariables
self.back = back
self.save = save
_draft = State(initialValue: variable ?? HostEnvironmentVariable(variable: "", value: ""))
}
var body: some View {
VaultDetailScaffold(
title: original == nil ? "New Environment Variable" : "Edit Environment Variable",
symbol: "text.line.first.and.arrowtriangle.forward", close: back,
closeLabel: "Back", closeSymbol: "chevron.backward"
) {
Form {
Section {
LabeledContent("Variable") {
TextField("Variable", text: $draft.variable.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
LabeledContent("Value") {
TextField("Value", text: $draft.value.singleLineText)
.singleLinePaste()
.lineLimit(1)
.labelsHidden()
.multilineTextAlignment(.trailing)
.font(.body.monospaced())
}
}
}
.formStyle(.grouped)
} footer: {
HStack {
Spacer()
Button("Cancel", action: back).keyboardShortcut(.cancelAction)
Button(original == nil ? "Add" : "Save") {
save(draft)
back()
}
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
.padding(Metrics.Space.l)
}
}
private var isValid: Bool {
draft.isValid && !usedVariables.contains(draft.variable)
}
}
@@ -932,6 +932,11 @@ final class SSHSessionModel: Identifiable {
)
ssh = actor
do {
await actor.configureEnvironment(host.environmentVariables) { variables in
let list = variables.joined(separator: ", ")
let warning = String(localized: "The server rejected these environment variables: \(list)")
backend.receive(Data("\r\n⚠︎ \(warning)\r\n".utf8))
}
try await actor.connect(
using: SSHConnectionRequest(
configuration: configuration,