feat: support configurable gateway port

This commit is contained in:
Peter Steinberger
2026-01-03 12:00:17 +01:00
parent 7199813969
commit f47c7ac369
23 changed files with 172 additions and 46 deletions

View File

@@ -106,4 +106,20 @@ enum ClawdisConfigFile {
return remote["password"] as? String
}
static func gatewayPort() -> Int? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
if let port = gateway["port"] as? Int, port > 0 { return port }
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
return number.intValue
}
if let raw = gateway["port"] as? String,
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
return parsed
}
return nil
}
}

View File

@@ -185,7 +185,7 @@ final class ControlChannel {
"Reason: \(reason)"
}
// Common misfire: we connected to localhost:18789 but the port is occupied
// Common misfire: we connected to the configured localhost port but it is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer

View File

@@ -306,7 +306,7 @@ struct DebugSettings: View {
}
if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18789 and suggest fixes.")
Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
@@ -946,7 +946,7 @@ extension DebugSettings {
view.portCheckInFlight = true
view.portReports = [
DebugActions.PortReport(
port: 18789,
port: GatewayEnvironment.gatewayPort(),
expected: "Gateway websocket (node/tsx)",
status: .missing("Missing"),
listeners: []),

View File

@@ -72,6 +72,13 @@ enum GatewayEnvironment {
}
static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let parsed = Int(trimmed), parsed > 0 { return parsed }
}
if let configPort = ClawdisConfigFile.gatewayPort(), configPort > 0 {
return configPort
}
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
}

View File

@@ -188,7 +188,8 @@ final class HealthStore {
if let error = self.lastError, !error.isEmpty {
let lower = error.lowercased()
if lower.contains("connection refused") {
return "The gateway control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back."
let port = GatewayEnvironment.gatewayPort()
return "The gateway control port (127.0.0.1:\(port)) isnt listening — restart Clawdis to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting."

View File

@@ -24,7 +24,7 @@ extension OnboardingView {
discoveryModel: discovery)
view.needsBootstrap = true
view.localGatewayProbe = LocalGatewayProbe(
port: 18789,
port: GatewayEnvironment.gatewayPort(),
pid: 123,
command: "clawdis-gateway",
expected: true)

View File

@@ -42,7 +42,7 @@ actor PortGuardian {
self.logger.info("port sweep skipped (mode=unconfigured)")
return
}
let ports = [18789]
let ports = [GatewayEnvironment.gatewayPort()]
for port in ports {
let listeners = await self.listeners(on: port)
guard !listeners.isEmpty else { continue }
@@ -148,7 +148,7 @@ actor PortGuardian {
if mode == .unconfigured {
return []
}
let ports = [18789]
let ports = [GatewayEnvironment.gatewayPort()]
var reports: [PortReport] = []
for port in ports {
@@ -279,7 +279,8 @@ actor PortGuardian {
return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: [])
}
let tunnelUnhealthy = mode == .remote && port == 18789 && tunnelHealthy == false
let tunnelUnhealthy =
mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false
let reportListeners = listeners.map { listener in
var expected = okPredicate(listener)
if tunnelUnhealthy, expected { expected = false }
@@ -347,7 +348,7 @@ actor PortGuardian {
switch mode {
case .remote:
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
if port == 18789 { return cmd.contains("ssh") }
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false
case .local:
return expectedCommands.contains { cmd.contains($0) }
@@ -361,7 +362,7 @@ actor PortGuardian {
mode: AppState.ConnectionMode,
listeners: [Listener]) async -> Bool?
{
guard mode == .remote, port == 18789, !listeners.isEmpty else { return nil }
guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil }
let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") }
guard hasSsh else { return nil }
return await self.probeGatewayHealth(port: port)

View File

@@ -38,7 +38,7 @@ actor RemoteTunnelManager {
}
/// Ensure an SSH tunnel is running for the gateway control port.
/// Returns the local forwarded port (usually 18789).
/// Returns the local forwarded port (usually the configured gateway port).
func ensureControlTunnel() async throws -> UInt16 {
let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote else {