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) # provisioning profiles (local)
apps/ios/*.mobileprovision apps/ios/*.mobileprovision
.env

View File

@@ -431,12 +431,56 @@ struct ConfigSettings: View {
self.configSaving = true self.configSaving = true
defer { self.configSaving = false } 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 root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
var talk = root["talk"] 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) .trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModel = chosenModel let trimmedModel = chosenModel
if !trimmedModel.isEmpty { agent["model"] = trimmedModel } if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
@@ -445,40 +489,41 @@ struct ConfigSettings: View {
agent["heartbeatMinutes"] = heartbeatMinutes agent["heartbeatMinutes"] = heartbeatMinutes
} }
let trimmedBody = self.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedBody.isEmpty { if !trimmedBody.isEmpty {
agent["heartbeatBody"] = trimmedBody agent["heartbeatBody"] = trimmedBody
} }
root["agent"] = agent root["agent"] = agent
browser["enabled"] = self.browserEnabled browser["enabled"] = browserEnabled
let trimmedUrl = self.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedUrl = browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl } 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 } if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
browser["attachOnly"] = self.browserAttachOnly browser["attachOnly"] = browserAttachOnly
root["browser"] = browser root["browser"] = browser
let trimmedVoice = self.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedVoice = talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedVoice.isEmpty { if trimmedVoice.isEmpty {
talk.removeValue(forKey: "voiceId") talk.removeValue(forKey: "voiceId")
} else { } else {
talk["voiceId"] = trimmedVoice talk["voiceId"] = trimmedVoice
} }
let trimmedApiKey = self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedApiKey = talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedApiKey.isEmpty { if trimmedApiKey.isEmpty {
talk.removeValue(forKey: "apiKey") talk.removeValue(forKey: "apiKey")
} else { } else {
talk["apiKey"] = trimmedApiKey talk["apiKey"] = trimmedApiKey
} }
talk["interruptOnSpeech"] = self.talkInterruptOnSpeech talk["interruptOnSpeech"] = talkInterruptOnSpeech
root["talk"] = talk root["talk"] = talk
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
} catch { return nil
self.modelError = error.localizedDescription } catch let error {
return error.localizedDescription
} }
} }

View File

@@ -43,7 +43,7 @@ enum ConfigStore {
} }
@MainActor @MainActor
static func save(_ root: [String: Any]) async throws { static func save(_ root: sending [String: Any]) async throws {
let overrides = await self.overrideStore.overrides let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() { if await self.isRemoteMode() {
if let override = overrides.saveRemote { if let override = overrides.saveRemote {

View File

@@ -66,6 +66,7 @@ actor GatewayChannelActor {
private var connectWaiters: [CheckedContinuation<Void, Error>] = [] private var connectWaiters: [CheckedContinuation<Void, Error>] = []
private var url: URL private var url: URL
private var token: String? private var token: String?
private var password: String?
private let session: WebSocketSessioning private let session: WebSocketSessioning
private var backoffMs: Double = 500 private var backoffMs: Double = 500
private var shouldReconnect = true private var shouldReconnect = true
@@ -82,11 +83,13 @@ actor GatewayChannelActor {
init( init(
url: URL, url: URL,
token: String?, token: String?,
password: String? = nil,
session: WebSocketSessionBox? = nil, session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil) pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
{ {
self.url = url self.url = url
self.token = token self.token = token
self.password = password
self.session = session?.session ?? URLSession(configuration: .default) self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler self.pushHandler = pushHandler
Task { [weak self] in Task { [weak self] in
@@ -213,13 +216,9 @@ actor GatewayChannelActor {
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), "userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
] ]
if let token = self.token { if let token = self.token {
// Send both 'token' and 'password' to support both auth modes. params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
// Gateway checks the field matching its auth.mode configuration. } else if let password = self.password {
let authDict: [String: ProtoAnyCodable] = [ params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
"token": ProtoAnyCodable(token),
"password": ProtoAnyCodable(token),
]
params["auth"] = ProtoAnyCodable(authDict)
} }
let frame = RequestFrame( let frame = RequestFrame(

View File

@@ -40,7 +40,7 @@ struct GatewayAgentInvocation: Sendable {
actor GatewayConnection { actor GatewayConnection {
static let shared = GatewayConnection() static let shared = GatewayConnection()
typealias Config = (url: URL, token: String?) typealias Config = (url: URL, token: String?, password: String?)
enum Method: String, Sendable { enum Method: String, Sendable {
case agent case agent
@@ -83,6 +83,7 @@ actor GatewayConnection {
private var client: GatewayChannelActor? private var client: GatewayChannelActor?
private var configuredURL: URL? private var configuredURL: URL?
private var configuredToken: String? private var configuredToken: String?
private var configuredPassword: String?
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:] private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk? private var lastSnapshot: HelloOk?
@@ -103,7 +104,7 @@ actor GatewayConnection {
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
let cfg = try await self.configProvider() 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 { guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) 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) try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do { do {
let cfg = try await self.configProvider() 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 { guard let client = self.client else {
throw NSError( throw NSError(
domain: "Gateway", domain: "Gateway",
@@ -209,7 +210,7 @@ actor GatewayConnection {
/// Ensure the underlying socket is configured (and replaced if config changed). /// Ensure the underlying socket is configured (and replaced if config changed).
func refresh() async throws { func refresh() async throws {
let cfg = try await self.configProvider() 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 { func shutdown() async {
@@ -264,8 +265,8 @@ actor GatewayConnection {
} }
} }
private func configure(url: URL, token: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token { if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
return return
} }
if let client { if let client {
@@ -275,12 +276,14 @@ actor GatewayConnection {
self.client = GatewayChannelActor( self.client = GatewayChannelActor(
url: url, url: url,
token: token, token: token,
password: password,
session: self.sessionBox, session: self.sessionBox,
pushHandler: { [weak self] push in pushHandler: { [weak self] push in
await self?.handle(push: push) await self?.handle(push: push)
}) })
self.configuredURL = url self.configuredURL = url
self.configuredToken = token self.configuredToken = token
self.configuredPassword = password
} }
private func handle(push: GatewayPush) { private func handle(push: GatewayPush) {

View File

@@ -2,7 +2,7 @@ import Foundation
import OSLog import OSLog
enum GatewayEndpointState: Sendable, Equatable { 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) case unavailable(mode: AppState.ConnectionMode, reason: String)
} }
@@ -17,18 +17,44 @@ actor GatewayEndpointStore {
struct Deps: Sendable { struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String? let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int let localPort: @Sendable () -> Int
let remotePortIfRunning: @Sendable () async -> UInt16? let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16 let ensureRemoteTunnel: @Sendable () async throws -> UInt16
static let live = Deps( static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
// First check env var, fallback to config file password: {
if let envToken = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"], !envToken.isEmpty { // First check environment variable
return envToken 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() }, localPort: { GatewayEnvironment.gatewayPort() },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() }, remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
@@ -53,9 +79,11 @@ actor GatewayEndpointStore {
} }
let port = deps.localPort() let port = deps.localPort()
let token = deps.token()
let password = deps.password()
switch initialMode { switch initialMode {
case .local: 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: case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured: case .unconfigured:
@@ -83,17 +111,18 @@ actor GatewayEndpointStore {
func setMode(_ mode: AppState.ConnectionMode) async { func setMode(_ mode: AppState.ConnectionMode) async {
let token = self.deps.token() let token = self.deps.token()
let password = self.deps.password()
switch mode { switch mode {
case .local: case .local:
let port = self.deps.localPort() 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: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return 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: case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
@@ -116,8 +145,8 @@ actor GatewayEndpointStore {
func requireConfig() async throws -> GatewayConnection.Config { func requireConfig() async throws -> GatewayConnection.Config {
await self.refresh() await self.refresh()
switch self.state { switch self.state {
case let .ready(_, url, token): case let .ready(_, url, token, password):
return (url, token) return (url, token, password)
case let .unavailable(mode, reason): case let .unavailable(mode, reason):
guard mode == .remote else { guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason]) throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
@@ -128,9 +157,10 @@ actor GatewayEndpointStore {
do { do {
let forwarded = try await self.deps.ensureRemoteTunnel() let forwarded = try await self.deps.ensureRemoteTunnel()
let token = self.deps.token() let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")! let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token)) self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token) return (url, token, password)
} catch { } catch {
let msg = "\(reason) (\(error.localizedDescription))" let msg = "\(reason) (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg)) self.setState(.unavailable(mode: .remote, reason: msg))
@@ -150,7 +180,7 @@ actor GatewayEndpointStore {
continuation.yield(next) continuation.yield(next)
} }
switch next { switch next {
case let .ready(mode, url, _): case let .ready(mode, url, _, _):
let modeDesc = String(describing: mode) let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString let urlDesc = url.absoluteString
self.logger self.logger

View File

@@ -64,6 +64,7 @@ enum GatewayLaunchAgentManager {
.joined(separator: ":") .joined(separator: ":")
let bind = self.preferredGatewayBind() ?? "loopback" let bind = self.preferredGatewayBind() ?? "loopback"
let token = self.preferredGatewayToken() let token = self.preferredGatewayToken()
let password = self.preferredGatewayPassword()
var envEntries = """ var envEntries = """
<key>PATH</key> <key>PATH</key>
<string>\(preferredPath)</string> <string>\(preferredPath)</string>
@@ -76,6 +77,12 @@ enum GatewayLaunchAgentManager {
<string>\(token)</string> <string>\(token)</string>
""" """
} }
if let password {
envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(password)</string>
"""
}
let plist = """ let plist = """
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!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 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 { private struct LaunchctlResult {
let status: Int32 let status: Int32
let output: String let output: String

View File

@@ -164,14 +164,23 @@ struct MenuContent: View {
} }
private func saveBrowserControlEnabled(_ enabled: Bool) async { 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 root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled browser["enabled"] = enabled
root["browser"] = browser root["browser"] = browser
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, ())
} catch { } catch {
await self.loadBrowserControlEnabled() return (false, ())
} }
} }

View File

@@ -75,6 +75,15 @@ extension OnboardingView {
@discardableResult @discardableResult
func saveAgentWorkspace(_ workspace: String?) async -> Bool { 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 root = await ConfigStore.load()
var agent = root["agent"] as? [String: Any] ?? [:] var agent = root["agent"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -90,10 +99,10 @@ extension OnboardingView {
} }
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return true return (true, nil)
} catch { } catch let error {
self.workspaceStatus = "Failed to save config: \(error.localizedDescription)" let errorMessage = "Failed to save config: \(error.localizedDescription)"
return false return (false, errorMessage)
} }
} }
} }

View File

@@ -280,28 +280,56 @@ struct TailscaleIntegrationSection: View {
return 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 root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
tailscale["mode"] = self.tailscaleMode.rawValue tailscale["mode"] = tailscaleMode.rawValue
gateway["tailscale"] = tailscale gateway["tailscale"] = tailscale
if self.tailscaleMode != .off { if tailscaleMode != .off {
gateway["bind"] = "loopback" gateway["bind"] = "loopback"
} }
if self.tailscaleMode == .off { if tailscaleMode == .off {
gateway.removeValue(forKey: "auth") gateway.removeValue(forKey: "auth")
} else { } else {
var auth = gateway["auth"] as? [String: Any] ?? [:] var auth = gateway["auth"] as? [String: Any] ?? [:]
if self.tailscaleMode == .serve, !self.requireCredentialsForServe { if tailscaleMode == .serve, !requireCredentialsForServe {
auth["allowTailscale"] = true auth["allowTailscale"] = true
auth.removeValue(forKey: "mode") auth.removeValue(forKey: "mode")
auth.removeValue(forKey: "password") auth.removeValue(forKey: "password")
} else { } else {
auth["allowTailscale"] = false auth["allowTailscale"] = false
auth["mode"] = "password" auth["mode"] = "password"
auth["password"] = trimmedPassword auth["password"] = password
} }
if auth.isEmpty { if auth.isEmpty {
@@ -319,17 +347,10 @@ struct TailscaleIntegrationSection: View {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
} catch { return (true, nil)
self.statusMessage = error.localizedDescription } catch let error {
return 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() { private func restartGatewayIfNeeded() {

View File

@@ -46,7 +46,7 @@ export async function callGateway<T = unknown>(
(typeof opts.password === "string" && opts.password.trim().length > 0 (typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim() ? opts.password.trim()
: undefined) || : undefined) ||
process.env.CLAWDIS_GATEWAY_PASSWORD || (process.env.CLAWDIS_GATEWAY_PASSWORD?.trim()) ||
(typeof remote?.password === "string" && remote.password.trim().length > 0 (typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim() ? remote.password.trim()
: undefined); : undefined);