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
This commit is contained in:
Jefferson Nunn
2026-01-01 21:34:46 -06:00
parent 9387ecf043
commit fe87d6d8be
12 changed files with 203 additions and 61 deletions

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip
# provisioning profiles (local)
apps/ios/*.mobileprovision
.env

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -66,6 +66,7 @@ actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
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(

View File

@@ -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<GatewayPush>.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) {

View File

@@ -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

View File

@@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager {
.joined(separator: ":")
let bind = self.preferredGatewayBind() ?? "loopback"
let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """
<key>PATH</key>
<string>\(preferredPath)</string>
@@ -76,6 +77,12 @@ enum GatewayLaunchAgentManager {
<string>\(token)</string>
"""
}
if let password {
envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(password)</string>
"""
}
let plist = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -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

View File

@@ -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, ())
}
}

View File

@@ -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)
}
}
}

View File

@@ -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() {

View File

@@ -1 +1 @@
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36

View File

@@ -46,7 +46,7 @@ export async function callGateway<T = unknown>(
(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);