From fe87d6d8be754a030cd5e738b00a82e47a638449 Mon Sep 17 00:00:00 2001 From: Jefferson Nunn Date: Thu, 1 Jan 2026 21:34:46 -0600 Subject: [PATCH] feat(macOS): add gateway password auth support and fix Swift 6.2 concurrency - Add CLAWDIS_GATEWAY_PASSWORD to launchd plist environment - Read password from gateway.remote.password config in client - Fix Swift 6.2 sending parameter violations in config save functions - Add password parameter to GatewayConnection.Config type - GatewayChannel now sends password in connect auth params - GatewayEndpointStore and GatewayLaunchAgentManager read password from config - CLI gateway client reads password from remote config and env --- .gitignore | 1 + .../Sources/Clawdis/ConfigSettings.swift | 67 ++++++++++++++++--- apps/macos/Sources/Clawdis/ConfigStore.swift | 2 +- .../Sources/Clawdis/GatewayChannel.swift | 13 ++-- .../Sources/Clawdis/GatewayConnection.swift | 15 +++-- .../Clawdis/GatewayEndpointStore.swift | 58 ++++++++++++---- .../Clawdis/GatewayLaunchAgentManager.swift | 25 +++++++ .../Sources/Clawdis/MenuContentView.swift | 11 ++- .../Clawdis/OnboardingView+Workspace.swift | 17 +++-- .../Clawdis/TailscaleIntegrationSection.swift | 51 +++++++++----- src/canvas-host/a2ui/.bundle.hash | 2 +- src/gateway/call.ts | 2 +- 12 files changed, 203 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 148918225..769176832 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip # provisioning profiles (local) apps/ios/*.mobileprovision +.env diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 1cd3f9569..9dc2c4d01 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -431,12 +431,56 @@ struct ConfigSettings: View { self.configSaving = true defer { self.configSaving = false } + let configModel = self.configModel + let customModel = self.customModel + let heartbeatMinutes = self.heartbeatMinutes + let heartbeatBody = self.heartbeatBody + let browserEnabled = self.browserEnabled + let browserControlUrl = self.browserControlUrl + let browserColorHex = self.browserColorHex + let browserAttachOnly = self.browserAttachOnly + let talkVoiceId = self.talkVoiceId + let talkApiKey = self.talkApiKey + let talkInterruptOnSpeech = self.talkInterruptOnSpeech + + let errorMessage = await ConfigSettings.buildAndSaveConfig( + configModel: configModel, + customModel: customModel, + heartbeatMinutes: heartbeatMinutes, + heartbeatBody: heartbeatBody, + browserEnabled: browserEnabled, + browserControlUrl: browserControlUrl, + browserColorHex: browserColorHex, + browserAttachOnly: browserAttachOnly, + talkVoiceId: talkVoiceId, + talkApiKey: talkApiKey, + talkInterruptOnSpeech: talkInterruptOnSpeech + ) + + if let errorMessage { + self.modelError = errorMessage + } + } + + private nonisolated static func buildAndSaveConfig( + configModel: String, + customModel: String, + heartbeatMinutes: Int?, + heartbeatBody: String, + browserEnabled: Bool, + browserControlUrl: String, + browserColorHex: String, + browserAttachOnly: Bool, + talkVoiceId: String, + talkApiKey: String, + talkInterruptOnSpeech: Bool + ) async -> String? { var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:] var talk = root["talk"] as? [String: Any] ?? [:] - let chosenModel = (self.configModel == "__custom__" ? self.customModel : self.configModel) + let chosenModel = (configModel == "__custom__" ? customModel : configModel) .trimmingCharacters(in: .whitespacesAndNewlines) let trimmedModel = chosenModel if !trimmedModel.isEmpty { agent["model"] = trimmedModel } @@ -445,40 +489,41 @@ struct ConfigSettings: View { agent["heartbeatMinutes"] = heartbeatMinutes } - let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedBody.isEmpty { agent["heartbeatBody"] = trimmedBody } root["agent"] = agent - browser["enabled"] = self.browserEnabled - let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) + browser["enabled"] = browserEnabled + let trimmedUrl = browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl } - let trimmedColor = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedColor = browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedColor.isEmpty { browser["color"] = trimmedColor } - browser["attachOnly"] = self.browserAttachOnly + browser["attachOnly"] = browserAttachOnly root["browser"] = browser - let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedVoice = talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedVoice.isEmpty { talk.removeValue(forKey: "voiceId") } else { talk["voiceId"] = trimmedVoice } - let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedApiKey = talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedApiKey.isEmpty { talk.removeValue(forKey: "apiKey") } else { talk["apiKey"] = trimmedApiKey } - talk["interruptOnSpeech"] = self.talkInterruptOnSpeech + talk["interruptOnSpeech"] = talkInterruptOnSpeech root["talk"] = talk do { try await ConfigStore.save(root) - } catch { - self.modelError = error.localizedDescription + return nil + } catch let error { + return error.localizedDescription } } diff --git a/apps/macos/Sources/Clawdis/ConfigStore.swift b/apps/macos/Sources/Clawdis/ConfigStore.swift index 533ee6825..d06c77609 100644 --- a/apps/macos/Sources/Clawdis/ConfigStore.swift +++ b/apps/macos/Sources/Clawdis/ConfigStore.swift @@ -43,7 +43,7 @@ enum ConfigStore { } @MainActor - static func save(_ root: [String: Any]) async throws { + static func save(_ root: sending [String: Any]) async throws { let overrides = await self.overrideStore.overrides if await self.isRemoteMode() { if let override = overrides.saveRemote { diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index af1c1635c..7786b30a0 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -66,6 +66,7 @@ actor GatewayChannelActor { private var connectWaiters: [CheckedContinuation] = [] private var url: URL private var token: String? + private var password: String? private let session: WebSocketSessioning private var backoffMs: Double = 500 private var shouldReconnect = true @@ -82,11 +83,13 @@ actor GatewayChannelActor { init( url: URL, token: String?, + password: String? = nil, session: WebSocketSessionBox? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil) { self.url = url self.token = token + self.password = password self.session = session?.session ?? URLSession(configuration: .default) self.pushHandler = pushHandler Task { [weak self] in @@ -213,13 +216,9 @@ actor GatewayChannelActor { "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), ] if let token = self.token { - // Send both 'token' and 'password' to support both auth modes. - // Gateway checks the field matching its auth.mode configuration. - let authDict: [String: ProtoAnyCodable] = [ - "token": ProtoAnyCodable(token), - "password": ProtoAnyCodable(token), - ] - params["auth"] = ProtoAnyCodable(authDict) + params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)]) + } else if let password = self.password { + params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } let frame = RequestFrame( diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index 52ab9ce6f..b5ee499e7 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable { actor GatewayConnection { static let shared = GatewayConnection() - typealias Config = (url: URL, token: String?) + typealias Config = (url: URL, token: String?, password: String?) enum Method: String, Sendable { case agent @@ -83,6 +83,7 @@ actor GatewayConnection { private var client: GatewayChannelActor? private var configuredURL: URL? private var configuredToken: String? + private var configuredPassword: String? private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var lastSnapshot: HelloOk? @@ -103,7 +104,7 @@ actor GatewayConnection { timeoutMs: Double? = nil) async throws -> Data { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client else { throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) } @@ -149,7 +150,7 @@ actor GatewayConnection { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) do { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) guard let client = self.client else { throw NSError( domain: "Gateway", @@ -209,7 +210,7 @@ actor GatewayConnection { /// Ensure the underlying socket is configured (and replaced if config changed). func refresh() async throws { let cfg = try await self.configProvider() - await self.configure(url: cfg.url, token: cfg.token) + await self.configure(url: cfg.url, token: cfg.token, password: cfg.password) } func shutdown() async { @@ -264,8 +265,8 @@ actor GatewayConnection { } } - private func configure(url: URL, token: String?) async { - if self.client != nil, self.configuredURL == url, self.configuredToken == token { + private func configure(url: URL, token: String?, password: String?) async { + if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password { return } if let client { @@ -275,12 +276,14 @@ actor GatewayConnection { self.client = GatewayChannelActor( url: url, token: token, + password: password, session: self.sessionBox, pushHandler: { [weak self] push in await self?.handle(push: push) }) self.configuredURL = url self.configuredToken = token + self.configuredPassword = password } private func handle(push: GatewayPush) { diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index b0eec702a..079fdc8e7 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -2,7 +2,7 @@ import Foundation import OSLog enum GatewayEndpointState: Sendable, Equatable { - case ready(mode: AppState.ConnectionMode, url: URL, token: String?) + case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?) case unavailable(mode: AppState.ConnectionMode, reason: String) } @@ -17,18 +17,44 @@ actor GatewayEndpointStore { struct Deps: Sendable { let mode: @Sendable () async -> AppState.ConnectionMode let token: @Sendable () -> String? + let password: @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 + token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, + password: { + // First check environment variable + let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed } - return ClawdisConfigFile.gatewayPassword() + // Then check config file based on connection mode + let root = ClawdisConfigFile.loadDict() + // Check gateway.auth.password (for local gateway auth) + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + // Check gateway.remote.password (for remote gateway auth) + if let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let password = remote["password"] as? String + { + let pw = password.trimmingCharacters(in: .whitespacesAndNewlines) + if !pw.isEmpty { + return pw + } + } + return nil }, localPort: { GatewayEnvironment.gatewayPort() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, @@ -53,9 +79,11 @@ actor GatewayEndpointStore { } let port = deps.localPort() + let token = deps.token() + let password = deps.password() switch initialMode { case .local: - self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: deps.token()) + self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password) case .remote: self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") case .unconfigured: @@ -83,17 +111,18 @@ actor GatewayEndpointStore { func setMode(_ mode: AppState.ConnectionMode) async { let token = self.deps.token() + let password = self.deps.password() 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)) + self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)) 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)) + self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password)) case .unconfigured: self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) } @@ -116,8 +145,8 @@ actor GatewayEndpointStore { func requireConfig() async throws -> GatewayConnection.Config { await self.refresh() switch self.state { - case let .ready(_, url, token): - return (url, token) + case let .ready(_, url, token, password): + return (url, token, password) case let .unavailable(mode, reason): guard mode == .remote else { throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) @@ -128,9 +157,10 @@ actor GatewayEndpointStore { do { let forwarded = try await self.deps.ensureRemoteTunnel() let token = self.deps.token() + let password = self.deps.password() let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! - self.setState(.ready(mode: .remote, url: url, token: token)) - return (url, token) + self.setState(.ready(mode: .remote, url: url, token: token, password: password)) + return (url, token, password) } catch { let msg = "\(reason) (\(error.localizedDescription))" self.setState(.unavailable(mode: .remote, reason: msg)) @@ -150,7 +180,7 @@ actor GatewayEndpointStore { continuation.yield(next) } switch next { - case let .ready(mode, url, _): + case let .ready(mode, url, _, _): let modeDesc = String(describing: mode) let urlDesc = url.absoluteString self.logger diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index a5ee815f0..9cc01ffef 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager { .joined(separator: ":") let bind = self.preferredGatewayBind() ?? "loopback" let token = self.preferredGatewayToken() + let password = self.preferredGatewayPassword() var envEntries = """ PATH \(preferredPath) @@ -76,6 +77,12 @@ enum GatewayLaunchAgentManager { \(token) """ } + if let password { + envEntries += """ + CLAWDIS_GATEWAY_PASSWORD + \(password) + """ + } let plist = """ @@ -146,6 +153,24 @@ enum GatewayLaunchAgentManager { return trimmed.isEmpty ? nil : trimmed } + private static func preferredGatewayPassword() -> String? { + // First check environment variable + let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + // Then check config file (gateway.auth.password) + let root = ClawdisConfigFile.loadDict() + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + let password = auth["password"] as? String + { + return password.trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + private struct LaunchctlResult { let status: Int32 let output: String diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 5ac5a37af..72b0abcfa 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -164,14 +164,23 @@ struct MenuContent: View { } private func saveBrowserControlEnabled(_ enabled: Bool) async { + let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled) + + if !success { + await self.loadBrowserControlEnabled() + } + } + + private nonisolated static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { var root = await ConfigStore.load() var browser = root["browser"] as? [String: Any] ?? [:] browser["enabled"] = enabled root["browser"] = browser do { try await ConfigStore.save(root) + return (true, ()) } catch { - await self.loadBrowserControlEnabled() + return (false, ()) } } diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift index 8f02adc38..4ff9f8fa1 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -75,6 +75,15 @@ extension OnboardingView { @discardableResult func saveAgentWorkspace(_ workspace: String?) async -> Bool { + let (success, errorMessage) = await OnboardingView.buildAndSaveWorkspace(workspace) + + if let errorMessage { + self.workspaceStatus = errorMessage + } + return success + } + + private nonisolated static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) { var root = await ConfigStore.load() var agent = root["agent"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -90,10 +99,10 @@ extension OnboardingView { } do { try await ConfigStore.save(root) - return true - } catch { - self.workspaceStatus = "Failed to save config: \(error.localizedDescription)" - return false + return (true, nil) + } catch let error { + let errorMessage = "Failed to save config: \(error.localizedDescription)" + return (false, errorMessage) } } } diff --git a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift index f8cabf9c7..2015e221a 100644 --- a/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/Clawdis/TailscaleIntegrationSection.swift @@ -280,28 +280,56 @@ struct TailscaleIntegrationSection: View { return } + let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig( + tailscaleMode: self.tailscaleMode, + requireCredentialsForServe: self.requireCredentialsForServe, + password: trimmedPassword, + connectionMode: self.connectionMode, + isPaused: self.isPaused + ) + + if !success, let errorMessage { + self.statusMessage = errorMessage + return + } + + if self.connectionMode == .local, !self.isPaused { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" + } else { + self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply." + } + self.restartGatewayIfNeeded() + } + + private nonisolated static func buildAndSaveTailscaleConfig( + tailscaleMode: GatewayTailscaleMode, + requireCredentialsForServe: Bool, + password: String, + connectionMode: AppState.ConnectionMode, + isPaused: Bool + ) async -> (Bool, String?) { var root = await ConfigStore.load() var gateway = root["gateway"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] - tailscale["mode"] = self.tailscaleMode.rawValue + tailscale["mode"] = tailscaleMode.rawValue gateway["tailscale"] = tailscale - if self.tailscaleMode != .off { + if tailscaleMode != .off { gateway["bind"] = "loopback" } - if self.tailscaleMode == .off { + if tailscaleMode == .off { gateway.removeValue(forKey: "auth") } else { var auth = gateway["auth"] as? [String: Any] ?? [:] - if self.tailscaleMode == .serve, !self.requireCredentialsForServe { + if tailscaleMode == .serve, !requireCredentialsForServe { auth["allowTailscale"] = true auth.removeValue(forKey: "mode") auth.removeValue(forKey: "password") } else { auth["allowTailscale"] = false auth["mode"] = "password" - auth["password"] = trimmedPassword + auth["password"] = password } if auth.isEmpty { @@ -319,17 +347,10 @@ struct TailscaleIntegrationSection: View { do { try await ConfigStore.save(root) - } catch { - self.statusMessage = error.localizedDescription - return + return (true, nil) + } catch let error { + return (false, error.localizedDescription) } - - if self.connectionMode == .local, !self.isPaused { - self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restarting gateway…" - } else { - self.statusMessage = "Saved to ~/.clawdis/clawdis.json. Restart the gateway to apply." - } - self.restartGatewayIfNeeded() } private func restartGatewayIfNeeded() { diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index fd929f88d..621a797bf 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 +13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 \ No newline at end of file diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 4fc92e303..e17f8203f 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -46,7 +46,7 @@ export async function callGateway( (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() : undefined) || - process.env.CLAWDIS_GATEWAY_PASSWORD || + (process.env.CLAWDIS_GATEWAY_PASSWORD?.trim()) || (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined);