fix(macos): parse SSH port correctly in remote connection test

- Fix "Test Remote" button failing for non-standard SSH ports (e.g., user@host:2222)
  The raw target string was passed directly to SSH, which interpreted host:2222
  as the hostname. Now uses -p flag for the port, matching RemotePortTunnel.swift.

- Fix SSH target field auto-repopulating after being cleared
  The config file watcher would reconstruct the SSH target from gateway.remote.url,
  undoing user edits. Removed auto-population from applyConfigOverrides(); initial
  population still happens in init() for first-time setup only.

- Document gateway.remote.sshTarget and gateway.remote.sshIdentity config keys
- Add non-standard SSH port example to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kenny Lee 2026-01-26 11:11:15 -08:00
parent a486940781
commit 1e15174a0a
4 changed files with 35 additions and 26 deletions

View File

@ -403,30 +403,10 @@ final class AppState {
self.remoteUrl = remoteUrlText self.remoteUrl = remoteUrlText
} }
let targetMode = desiredMode ?? self.connectionMode // Note: We intentionally do NOT auto-populate remoteTarget here.
if targetMode == .remote, // The SSH target is user-controlled via the UI; auto-populating it from
remoteTransport != .direct, // gateway.remote.url would override user edits (including clearing the field).
let host = AppState.remoteHost(from: remoteUrl) // Initial population happens in init() for first-time setup only.
{
self.updateRemoteTarget(host: host)
}
}
private func updateRemoteTarget(host: String) {
let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
let port = parsed.port
let assembled: String
if let user {
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
} else {
assembled = port == 22 ? host : "\(host):\(port)"
}
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
} }
private func syncGatewayConfigIfNeeded() { private func syncGatewayConfigIfNeeded() {

View File

@ -588,6 +588,11 @@ extension GeneralSettings {
} }
private static func sshCheckCommand(target: String, identity: String) -> [String] { private static func sshCheckCommand(target: String, identity: String) -> [String] {
guard let parsed = CommandResolver.parseSSHTarget(target) else {
// Fallback: pass target as-is (will likely fail, but matches old behavior)
return ["/usr/bin/ssh", target, "echo ok"]
}
var args: [String] = [ var args: [String] = [
"/usr/bin/ssh", "/usr/bin/ssh",
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
@ -595,10 +600,14 @@ extension GeneralSettings {
"-o", "StrictHostKeyChecking=accept-new", "-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes", "-o", "UpdateHostKeys=yes",
] ]
if parsed.port > 0 && parsed.port != 22 {
args.append(contentsOf: ["-p", String(parsed.port)])
}
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
args.append(contentsOf: ["-i", identity]) args.append(contentsOf: ["-i", identity])
} }
args.append(target) let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
args.append(userHost)
args.append("echo ok") args.append("echo ok")
return args return args
} }

View File

@ -2890,6 +2890,8 @@ Auth and Tailscale:
Remote client defaults (CLI): Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`. - `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
- `gateway.remote.sshTarget` sets the SSH target for CLI probes and macOS app tunneling (`user@host` or `user@host:port`; port defaults to 22).
- `gateway.remote.sshIdentity` sets the SSH identity file path for remote connections.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
@ -2911,6 +2913,23 @@ macOS app behavior:
} }
``` ```
SSH tunnel example with non-standard SSH port (macOS app):
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "ssh",
sshTarget: "admin@gateway.example.com:2222",
sshIdentity: "~/.ssh/id_ed25519",
url: "ws://127.0.0.1:18789",
token: "your-token"
}
}
}
```
Direct transport example (macOS app): Direct transport example (macOS app):
```json5 ```json5

View File

@ -27,7 +27,8 @@ Remote mode supports two transports:
1) Open *Settings → General*. 1) Open *Settings → General*.
2) Under **Clawdbot runs**, pick **Remote over SSH** and set: 2) Under **Clawdbot runs**, pick **Remote over SSH** and set:
- **Transport**: **SSH tunnel** or **Direct (ws/wss)**. - **Transport**: **SSH tunnel** or **Direct (ws/wss)**.
- **SSH target**: `user@host` (optional `:port`). - **SSH target**: `user@host` or `user@host:port` (port defaults to 22).
- Example with non-standard SSH port: `admin@gateway.example.com:2222`
- If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field. - If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field.
- **Gateway URL** (Direct only): `wss://gateway.example.ts.net` (or `ws://...` for local/LAN). - **Gateway URL** (Direct only): `wss://gateway.example.ts.net` (or `ws://...` for local/LAN).
- **Identity file** (advanced): path to your key. - **Identity file** (advanced): path to your key.