Files
clawdbot/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift
jeffersonwarrior 9387ecf043 fix(macos): support password auth mode for gateway connections
GatewayChannel now sends both 'token' and 'password' fields in the auth
payload to support both authentication modes. Gateway checks the field
matching its auth.mode configuration ('token' or 'password').

Also adds config file password fallback for remote mode, allowing
gateway password to be configured in ~/.clawdis/clawdis.json without
requiring environment variables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-01 21:26:37 -06:00

167 lines
6.8 KiB
Swift

import Foundation
import OSLog
enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?)
case unavailable(mode: AppState.ConnectionMode, reason: String)
}
/// Single place to resolve (and publish) the effective gateway control endpoint.
///
/// This is intentionally separate from `GatewayConnection`:
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let localPort: @Sendable () -> Int
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: {
// First check env var, fallback to config file
if let envToken = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"], !envToken.isEmpty {
return envToken
}
return ClawdisConfigFile.gatewayPassword()
},
localPort: { GatewayEnvironment.gatewayPort() },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
private let deps: Deps
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "gateway-endpoint")
private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
init(deps: Deps = .live) {
self.deps = deps
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
let initialMode: AppState.ConnectionMode
if let modeRaw {
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
initialMode = seen ? .local : .unconfigured
}
let port = deps.localPort()
switch initialMode {
case .local:
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token())
case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
}
}
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
let id = UUID()
let initial = self.state
let store = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
continuation.yield(initial)
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await store.removeSubscriber(id) }
}
}
}
func refresh() async {
let mode = await self.deps.mode()
await self.setMode(mode)
}
func setMode(_ mode: AppState.ConnectionMode) async {
let token = self.deps.token()
switch mode {
case .local:
let port = self.deps.localPort()
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token))
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return
}
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token))
case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
}
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let port = try await self.deps.ensureRemoteTunnel()
await self.setMode(.remote)
return port
}
func requireConfig() async throws -> GatewayConnection.Config {
await self.refresh()
switch self.state {
case let .ready(_, url, token):
return (url, token)
case let .unavailable(mode, reason):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
}
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
do {
let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token))
return (url, token)
} catch {
let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func setState(_ next: GatewayEndpointState) {
guard next != self.state else { return }
self.state = next
for (_, continuation) in self.subscribers {
continuation.yield(next)
}
switch next {
case let .ready(mode, url, _):
let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
}