From 9387ecf0437341d4fabd1381cc475890b93bb94c Mon Sep 17 00:00:00 2001 From: jeffersonwarrior Date: Thu, 1 Jan 2026 21:26:37 -0600 Subject: [PATCH 1/3] fix(macos): support password auth mode for gateway connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/macos/Sources/Clawdis/ClawdisConfigFile.swift | 9 +++++++++ apps/macos/Sources/Clawdis/GatewayChannel.swift | 8 +++++++- apps/macos/Sources/Clawdis/GatewayEndpointStore.swift | 8 +++++++- src/gateway/hooks-mapping.ts | 4 ++-- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index 3a712ebce..e0b3532ad 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -97,4 +97,13 @@ enum ClawdisConfigFile { self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)") } + static func gatewayPassword() -> String? { + let root = self.loadDict() + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] else { + return nil + } + return remote["password"] as? String + } + } diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 4de00bd3e..af1c1635c 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -213,7 +213,13 @@ actor GatewayChannelActor { "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), ] if let token = self.token { - params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(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) } let frame = RequestFrame( diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 192eb3838..b0eec702a 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -23,7 +23,13 @@ actor GatewayEndpointStore { static let live = Deps( mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, - token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, + 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() }) diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1d29d786b..81c001878 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +50,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; From fe87d6d8be754a030cd5e738b00a82e47a638449 Mon Sep 17 00:00:00 2001 From: Jefferson Nunn Date: Thu, 1 Jan 2026 21:34:46 -0600 Subject: [PATCH 2/3] 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); From a8bc974a2e98f4a4850b2f48856e1cefd9b05c45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:47:52 +0100 Subject: [PATCH 3/3] fix: harden gateway password auth --- .../Clawdis/GatewayEndpointStore.swift | 25 +++++++++---------- .../Clawdis/GatewayLaunchAgentManager.swift | 15 +++++++++-- .../GatewayEndpointStoreTests.swift | 7 +++++- src/gateway/call.ts | 2 +- src/gateway/hooks-mapping.ts | 18 ++++++++++--- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift index 079fdc8e7..7be78c407 100644 --- a/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdis/GatewayEndpointStore.swift @@ -26,15 +26,24 @@ actor GatewayEndpointStore { mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, 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 } - // Then check config file based on connection mode let root = ClawdisConfigFile.loadDict() - // Check gateway.auth.password (for local gateway auth) + if CommandResolver.connectionModeIsRemote() { + 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 + } if let gateway = root["gateway"] as? [String: Any], let auth = gateway["auth"] as? [String: Any], let password = auth["password"] as? String @@ -44,16 +53,6 @@ actor GatewayEndpointStore { 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() }, diff --git a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift index 9cc01ffef..7d0f04995 100644 --- a/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayLaunchAgentManager.swift @@ -72,15 +72,17 @@ enum GatewayLaunchAgentManager { sips """ if let token { + let escapedToken = self.escapePlistValue(token) envEntries += """ CLAWDIS_GATEWAY_TOKEN - \(token) + \(escapedToken) """ } if let password { + let escapedPassword = self.escapePlistValue(password) envEntries += """ CLAWDIS_GATEWAY_PASSWORD - \(password) + \(escapedPassword) """ } let plist = """ @@ -171,6 +173,15 @@ enum GatewayLaunchAgentManager { return nil } + private static func escapePlistValue(_ raw: String) -> String { + raw + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + private struct LaunchctlResult { let status: Int32 let output: String diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift index 64f117ec2..6061fc8f9 100644 --- a/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayEndpointStoreTests.swift @@ -29,6 +29,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { "t" }, + password: { nil }, localPort: { 1234 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) @@ -44,6 +45,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { nil }, + password: { nil }, localPort: { 18789 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) @@ -58,6 +60,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { "tok" }, + password: { "pw" }, localPort: { 1 }, remotePortIfRunning: { 5555 }, ensureRemoteTunnel: { 5555 })) @@ -69,13 +72,14 @@ import Testing _ = try await store.ensureRemoteControlTunnel() let next = await iterator.next() - guard case let .ready(mode, url, token) = next else { + guard case let .ready(mode, url, token, password) = next else { Issue.record("expected .ready after ensure, got \(String(describing: next))") return } #expect(mode == .remote) #expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(token == "tok") + #expect(password == "pw") } @Test func unconfiguredModeRejectsConfig() async { @@ -83,6 +87,7 @@ import Testing let store = GatewayEndpointStore(deps: .init( mode: { mode.get() }, token: { nil }, + password: { nil }, localPort: { 18789 }, remotePortIfRunning: { nil }, ensureRemoteTunnel: { 18789 })) diff --git a/src/gateway/call.ts b/src/gateway/call.ts index e17f8203f..edecfa285 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?.trim()) || + process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() || (typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 81c001878..0b2ee8132 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,13 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +56,13 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -86,7 +98,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram" | "discord"; + channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to: string; thinking: string; timeoutSeconds: number;