From 8a20f44228249e8d7f99f0b5c7b1c9b179ae0167 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 06:53:02 +0000 Subject: [PATCH] fix: improve gateway ssh auth handling --- .../Sources/Clawdbot/ControlChannel.swift | 28 ++++++++++- .../Sources/Clawdbot/GatewayConnection.swift | 5 ++ .../Sources/Clawdbot/GeneralSettings.swift | 5 ++ .../macos/Sources/Clawdbot/PortGuardian.swift | 8 ++++ .../Clawdbot/RemoteTunnelManager.swift | 46 ++++--------------- .../Sources/ClawdbotKit/GatewayChannel.swift | 22 +++++++++ scripts/codesign-mac-app.sh | 2 +- src/commands/gateway-status.test.ts | 4 ++ src/commands/gateway-status/helpers.ts | 2 +- 9 files changed, 83 insertions(+), 39 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ControlChannel.swift b/apps/macos/Sources/Clawdbot/ControlChannel.swift index e72ccbbde..4c47ee26e 100644 --- a/apps/macos/Sources/Clawdbot/ControlChannel.swift +++ b/apps/macos/Sources/Clawdbot/ControlChannel.swift @@ -74,6 +74,7 @@ final class ControlChannel { } private(set) var lastPingMs: Double? + private(set) var authSourceLabel: String? private let logger = Logger(subsystem: "com.clawdbot", category: "control") @@ -128,6 +129,7 @@ final class ControlChannel { await GatewayConnection.shared.shutdown() self.state = .disconnected self.lastPingMs = nil + self.authSourceLabel = nil } func health(timeout: TimeInterval? = nil) async throws -> Data { @@ -188,8 +190,11 @@ final class ControlChannel { urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures { let reason = urlErr.failureURLString ?? urlErr.localizedDescription + let tokenKey = CommandResolver.connectionModeIsRemote() + ? "gateway.remote.token" + : "gateway.auth.token" return - "Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " + + "Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " + "or clear it on the gateway. " + "Reason: \(reason)" } @@ -300,6 +305,27 @@ final class ControlChannel { code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"]) } + await self.refreshAuthSourceLabel() + } + + private func refreshAuthSourceLabel() async { + let isRemote = CommandResolver.connectionModeIsRemote() + let authSource = await GatewayConnection.shared.authSource() + self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote) + } + + private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? { + guard let source else { return nil } + switch source { + case .deviceToken: + return "Auth: device token (paired device)" + case .sharedToken: + return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" + case .password: + return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))" + case .none: + return "Auth: none" + } } func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws { diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 36e8fc62b..4a0234748 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -249,6 +249,11 @@ actor GatewayConnection { await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) } + func authSource() async -> GatewayAuthSource? { + guard let client else { return nil } + return await client.authSource() + } + func shutdown() async { if let client { await client.shutdown() diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index daa07466d..bffd75b3c 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -212,6 +212,11 @@ struct GeneralSettings: View { .font(.caption) .foregroundStyle(.secondary) } + if let authLabel = ControlChannel.shared.authSourceLabel { + Text(authLabel) + .font(.caption) + .foregroundStyle(.secondary) + } } Text("Tip: enable Tailscale for stable remote access.") diff --git a/apps/macos/Sources/Clawdbot/PortGuardian.swift b/apps/macos/Sources/Clawdbot/PortGuardian.swift index 50d4e0e9f..422300d59 100644 --- a/apps/macos/Sources/Clawdbot/PortGuardian.swift +++ b/apps/macos/Sources/Clawdbot/PortGuardian.swift @@ -184,6 +184,14 @@ actor PortGuardian { } } + func isListening(port: Int, pid: Int32? = nil) async -> Bool { + let listeners = await self.listeners(on: port) + if let pid { + return listeners.contains(where: { $0.pid == pid }) + } + return !listeners.isEmpty + } + private func listeners(on port: Int) async -> [Listener] { let res = await ShellExecutor.run( command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"], diff --git a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift b/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift index 5e42fbd05..898b1b482 100644 --- a/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift +++ b/apps/macos/Sources/Clawdbot/RemoteTunnelManager.swift @@ -20,11 +20,13 @@ actor RemoteTunnelManager { tunnel.process.isRunning, let local = tunnel.localPort { - if await self.isTunnelHealthy(port: local) { + let pid = tunnel.process.processIdentifier + if await PortGuardian.shared.isListening(port: Int(local), pid: pid) { self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)") return local } - self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting") + self.logger.error( + "active SSH tunnel on port \(local, privacy: .public) is not listening; restarting") await self.beginRestart() tunnel.terminate() self.controlTunnel = nil @@ -35,19 +37,11 @@ actor RemoteTunnelManager { if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), self.isSshProcess(desc) { - if await self.isTunnelHealthy(port: desiredPort) { - self.logger.info( - "reusing existing SSH tunnel listener " + - "localPort=\(desiredPort, privacy: .public) " + - "pid=\(desc.pid, privacy: .public)") - return desiredPort - } - if self.restartInFlight { - self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup") - return nil - } - await self.beginRestart() - await self.cleanupStaleTunnel(desc: desc, port: desiredPort) + self.logger.info( + "reusing existing SSH tunnel listener " + + "localPort=\(desiredPort, privacy: .public) " + + "pid=\(desc.pid, privacy: .public)") + return desiredPort } return nil } @@ -88,10 +82,6 @@ actor RemoteTunnelManager { self.controlTunnel = nil } - private func isTunnelHealthy(port: UInt16) async -> Bool { - await PortGuardian.shared.probeGatewayHealth(port: Int(port)) - } - private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { let cmd = desc.command.lowercased() if cmd.contains("ssh") { return true } @@ -128,21 +118,5 @@ actor RemoteTunnelManager { try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) } - private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async { - let pid = desc.pid - self.logger.error( - "stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)") - let killed = await self.kill(pid: pid) - if !killed { - self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)") - } - await PortGuardian.shared.removeRecord(pid: pid) - } - - private func kill(pid: Int32) async -> Bool { - let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) - if term.ok { return true } - let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2) - return sigkill.ok - } + // Keep tunnel reuse lightweight; restart only when the listener disappears. } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index 90a19a64b..db2ffa36d 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -94,6 +94,13 @@ public struct GatewayConnectOptions: Sendable { } } +public enum GatewayAuthSource: String, Sendable { + case deviceToken = "device-token" + case sharedToken = "shared-token" + case password = "password" + case none = "none" +} + // Avoid ambiguity with the app's own AnyCodable type. private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable @@ -117,6 +124,7 @@ public actor GatewayChannelActor { private var lastSeq: Int? private var lastTick: Date? private var tickIntervalMs: Double = 30000 + private var lastAuthSource: GatewayAuthSource = .none private let decoder = JSONDecoder() private let encoder = JSONEncoder() private let connectTimeoutSeconds: Double = 6 @@ -149,6 +157,8 @@ public actor GatewayChannelActor { } } + public func authSource() -> GatewayAuthSource { self.lastAuthSource } + public func shutdown() async { self.shouldReconnect = false self.connected = false @@ -300,6 +310,18 @@ public actor GatewayChannelActor { let identity = DeviceIdentityStore.loadOrCreate() let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token let authToken = storedToken ?? self.token + let authSource: GatewayAuthSource + if storedToken != nil { + authSource = .deviceToken + } else if authToken != nil { + authSource = .sharedToken + } else if self.password != nil { + authSource = .password + } else { + authSource = .none + } + self.lastAuthSource = authSource + self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") let canFallbackToShared = storedToken != nil && self.token != nil if let authToken { params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)]) diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index d8eab87c6..6759324f6 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -285,5 +285,5 @@ sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS" verify_team_ids -rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME" +rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME" echo "Codesign complete for $APP_BUNDLE" diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index c7616f32f..09547a83d 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -181,6 +181,10 @@ describe("gateway-status command", () => { expect(startSshPortForward).toHaveBeenCalledTimes(1); expect(probeGateway).toHaveBeenCalled(); + const tunnelCall = probeGateway.mock.calls.find( + (call) => typeof call?.[0]?.url === "string" && call[0].url.startsWith("ws://127.0.0.1:"), + )?.[0] as { auth?: { token?: string } } | undefined; + expect(tunnelCall?.auth?.token).toBe("rtok"); expect(sshStop).toHaveBeenCalledTimes(1); const parsed = JSON.parse(runtimeLogs.join("\n")) as Record; diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index ca48ee0ba..50c3288a2 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -134,7 +134,7 @@ export function resolveAuthForTarget( return { token: tokenOverride, password: passwordOverride }; } - if (target.kind === "configRemote") { + if (target.kind === "configRemote" || target.kind === "sshTunnel") { const token = typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : ""; const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;