Skip to content

Commit af420db

Browse files
authored
fix(macos): remote mode fails with managed SSH aliases (#99661)
* fix(macos): support managed SSH aliases safely * test(macos): serialize gateway state isolation
1 parent 525c4c7 commit af420db

24 files changed

Lines changed: 489 additions & 171 deletions

apps/macos/Sources/OpenClaw/AppState.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ final class AppState {
541541
key: "transport",
542542
value: RemoteTransport.ssh.rawValue) || changed
543543

544+
let existingTarget = Self.sanitizeSSHTarget(remote["sshTarget"] as? String ?? "")
544545
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
545546
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
546547
let existingUrl = (remote["url"] as? String)?
@@ -551,6 +552,12 @@ final class AppState {
551552
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
552553
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
553554
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: draft.remoteIdentity) || changed
555+
if existingTarget != sanitizedTarget {
556+
changed = Self.updateGatewayString(
557+
&remote,
558+
key: "sshHostKeyPolicy",
559+
value: "strict") || changed
560+
}
554561
}
555562

556563
if draft.remoteTokenDirty {

apps/macos/Sources/OpenClaw/CommandResolver.swift

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@ import Foundation
33
enum CommandResolver {
44
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
55
private static let helperName = "openclaw"
6-
static let strictHostKeyCheckingSSHOptions = [
7-
"-o", "StrictHostKeyChecking=yes",
8-
]
9-
static let updateHostKeysSSHOptions = [
10-
"-o", "UpdateHostKeys=yes",
11-
]
126

137
static func gatewayEntrypoint(in root: URL) -> String? {
148
let distEntry = root.appendingPathComponent("dist/index.js").path
@@ -401,9 +395,10 @@ enum CommandResolver {
401395
echo "openclaw CLI missing on remote host"; exit 127;
402396
fi
403397
"""
398+
// Remote credentials require strict host verification unless config explicitly opts into OpenSSH policy.
404399
let options: [String] = [
405400
"-o", "BatchMode=yes",
406-
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
401+
] + settings.sshHostKeyPolicy.commandOptions
407402
let args = self.sshArguments(
408403
target: parsed,
409404
identity: settings.identity,
@@ -412,12 +407,39 @@ enum CommandResolver {
412407
return ["/usr/bin/ssh"] + args
413408
}
414409

410+
enum SSHHostKeyPolicy: String {
411+
case strict
412+
case openssh
413+
414+
var hostKeyOptions: [String] {
415+
switch self {
416+
case .strict:
417+
[
418+
"-o", "StrictHostKeyChecking=yes",
419+
"-o", "UpdateHostKeys=yes",
420+
]
421+
case .openssh:
422+
[]
423+
}
424+
}
425+
426+
var commandOptions: [String] {
427+
[
428+
"-o", "ControlMaster=no",
429+
"-o", "ControlPath=none",
430+
"-o", "ControlPersist=no",
431+
"-o", "ForkAfterAuthentication=no",
432+
] + self.hostKeyOptions
433+
}
434+
}
435+
415436
struct RemoteSettings {
416437
let mode: AppState.ConnectionMode
417438
let target: String
418439
let identity: String
419440
let projectRoot: String
420441
let cliPath: String
442+
let sshHostKeyPolicy: SSHHostKeyPolicy
421443
}
422444

423445
static func connectionSettings(
@@ -427,20 +449,31 @@ enum CommandResolver {
427449
let root = configRoot ?? OpenClawConfigFile.loadDict()
428450
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
429451
let remote = (root["gateway"] as? [String: Any])?["remote"] as? [String: Any]
430-
let target = defaults.string(forKey: remoteTargetKey)?.nonEmpty
431-
?? remote?["sshTarget"] as? String
432-
?? ""
452+
let configuredTarget = self.sanitizedTarget(remote?["sshTarget"] as? String ?? "")
453+
let target = self.sanitizedTarget(
454+
defaults.string(forKey: remoteTargetKey)?.nonEmpty ?? configuredTarget)
433455
let identity = defaults.string(forKey: remoteIdentityKey)?.nonEmpty
434456
?? remote?["sshIdentity"] as? String
435457
?? ""
436458
let projectRoot = defaults.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
437459
let cliPath = defaults.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
460+
let rawHostKeyPolicy = remote?["sshHostKeyPolicy"] as? String
461+
let configuredHostKeyPolicy = rawHostKeyPolicy.flatMap(SSHHostKeyPolicy.init(rawValue:)) ?? .strict
462+
let sshHostKeyPolicy: SSHHostKeyPolicy = if configuredHostKeyPolicy == .openssh,
463+
!target.isEmpty,
464+
target == configuredTarget
465+
{
466+
.openssh
467+
} else {
468+
.strict
469+
}
438470
return RemoteSettings(
439471
mode: mode,
440-
target: self.sanitizedTarget(target),
472+
target: target,
441473
identity: identity,
442474
projectRoot: projectRoot,
443-
cliPath: cliPath)
475+
cliPath: cliPath,
476+
sshHostKeyPolicy: sshHostKeyPolicy)
444477
}
445478

446479
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {

apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ enum NodePairingReconcilePolicy {
2020
@MainActor
2121
@Observable
2222
final class NodePairingApprovalPrompter {
23+
private static let silentPairingSSHOptions = [
24+
"-o", "BatchMode=yes",
25+
"-o", "ConnectTimeout=5",
26+
"-o", "NumberOfPasswordPrompts=0",
27+
"-o", "PreferredAuthentications=publickey",
28+
"-o", "ControlMaster=no",
29+
"-o", "ControlPath=none",
30+
"-o", "ControlPersist=no",
31+
"-o", "ForkAfterAuthentication=no",
32+
// Silent approval is an authorization boundary; require an already trusted host key.
33+
"-o", "StrictHostKeyChecking=yes",
34+
]
35+
2336
static let shared = NodePairingApprovalPrompter()
2437

2538
private let logger = Logger(subsystem: "ai.openclaw", category: "node-pairing")
@@ -475,16 +488,11 @@ final class NodePairingApprovalPrompter {
475488
}
476489

477490
private static func probeSSH(user: String, host: String, port: Int) async -> Bool {
478-
await Task.detached(priority: .utility) {
491+
let options = self.silentPairingSSHOptions
492+
return await Task.detached(priority: .utility) {
479493
let process = Process()
480494
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
481495

482-
let options = [
483-
"-o", "BatchMode=yes",
484-
"-o", "ConnectTimeout=5",
485-
"-o", "NumberOfPasswordPrompts=0",
486-
"-o", "PreferredAuthentications=publickey",
487-
] + CommandResolver.strictHostKeyCheckingSSHOptions
488496
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
489497
return false
490498
}
@@ -592,6 +600,10 @@ final class NodePairingApprovalPrompter {
592600
#if DEBUG
593601
@MainActor
594602
extension NodePairingApprovalPrompter {
603+
static func _testSilentPairingSSHOptions() -> [String] {
604+
self.silentPairingSSHOptions
605+
}
606+
595607
static func exerciseForTesting() async {
596608
let prompter = NodePairingApprovalPrompter()
597609
let pending = PendingRequest(

apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,11 @@ enum RemoteGatewayProbe {
175175
if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) {
176176
return .failed(validationMessage)
177177
}
178-
guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else {
178+
guard let sshCommand = self.sshCheckCommand(
179+
target: settings.target,
180+
identity: settings.identity,
181+
hostKeyPolicy: settings.sshHostKeyPolicy)
182+
else {
179183
return .failed("SSH target is invalid")
180184
}
181185

@@ -205,12 +209,16 @@ enum RemoteGatewayProbe {
205209
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
206210
}
207211

208-
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
212+
private static func sshCheckCommand(
213+
target: String,
214+
identity: String,
215+
hostKeyPolicy: CommandResolver.SSHHostKeyPolicy) -> [String]?
216+
{
209217
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
210218
let options = [
211219
"-o", "BatchMode=yes",
212220
"-o", "ConnectTimeout=5",
213-
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
221+
] + hostKeyPolicy.commandOptions
214222
let args = CommandResolver.sshArguments(
215223
target: parsed,
216224
identity: identity,
@@ -219,6 +227,15 @@ enum RemoteGatewayProbe {
219227
return ["/usr/bin/ssh"] + args
220228
}
221229

230+
#if SWIFT_PACKAGE
231+
static func _testSSHCheckCommand(
232+
target: String,
233+
hostKeyPolicy: CommandResolver.SSHHostKeyPolicy) -> [String]?
234+
{
235+
self.sshCheckCommand(target: target, identity: "", hostKeyPolicy: hostKeyPolicy)
236+
}
237+
#endif
238+
222239
private static func formatSSHFailure(_ response: Response, target: String) -> String {
223240
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
224241
let trimmed = payload?

apps/macos/Sources/OpenClaw/RemotePortTunnel.swift

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,10 @@ final class RemotePortTunnel: @unchecked Sendable {
9696
"ssh tunnel using default remote port " +
9797
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
9898
}
99-
let options: [String] = [
100-
"-o", "BatchMode=yes",
101-
"-o", "ExitOnForwardFailure=yes",
102-
"-o", "ServerAliveInterval=15",
103-
"-o", "ServerAliveCountMax=3",
104-
"-o", "TCPKeepAlive=yes",
105-
"-n",
106-
"-N",
107-
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
108-
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
99+
let options = Self.sshOptions(
100+
localPort: localPort,
101+
remotePort: resolvedRemotePort,
102+
hostKeyPolicy: settings.sshHostKeyPolicy)
109103
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
110104
let args = CommandResolver.sshArguments(
111105
target: parsed,
@@ -226,6 +220,28 @@ final class RemotePortTunnel: @unchecked Sendable {
226220
return port
227221
}
228222

223+
private static func sshOptions(
224+
localPort: UInt16,
225+
remotePort: Int,
226+
hostKeyPolicy: CommandResolver.SSHHostKeyPolicy) -> [String]
227+
{
228+
[
229+
"-o", "BatchMode=yes",
230+
// The app tracks this exact child PID, so aliases must not hand the tunnel to a shared master.
231+
"-o", "ControlMaster=no",
232+
"-o", "ControlPath=none",
233+
"-o", "ControlPersist=no",
234+
"-o", "ForkAfterAuthentication=no",
235+
"-o", "ExitOnForwardFailure=yes",
236+
"-o", "ServerAliveInterval=15",
237+
"-o", "ServerAliveCountMax=3",
238+
"-o", "TCPKeepAlive=yes",
239+
"-n",
240+
"-N",
241+
"-L", "\(localPort):127.0.0.1:\(remotePort)",
242+
] + hostKeyPolicy.hostKeyOptions
243+
}
244+
229245
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
230246
if let preferred, self.portIsFree(preferred) { return preferred }
231247
if let preferred, !allowRandom {
@@ -377,6 +393,14 @@ final class RemotePortTunnel: @unchecked Sendable {
377393
self.resolveRemotePortOverride(defaultRemotePort: defaultRemotePort, for: sshHost)
378394
}
379395

396+
static func _testSSHOptions(
397+
localPort: UInt16,
398+
remotePort: Int,
399+
hostKeyPolicy: CommandResolver.SSHHostKeyPolicy = .strict) -> [String]
400+
{
401+
self.sshOptions(localPort: localPort, remotePort: remotePort, hostKeyPolicy: hostKeyPolicy)
402+
}
403+
380404
static func _testDrainStderr(_ handle: FileHandle) -> String {
381405
self.drainStderr(handle)
382406
}

0 commit comments

Comments
 (0)