Merge pull request #85 from jeffersonwarrior/main

feat: add gateway password auth support and fix Swift 6.2 concurrency
This commit is contained in:
Peter Steinberger
2026-01-02 16:50:57 +01:00
committed by GitHub
13 changed files with 228 additions and 50 deletions

1
.gitignore vendored
View File

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

View File

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

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
@@ -214,6 +217,8 @@ actor GatewayChannelActor {
]
if let token = self.token {
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,6 +17,7 @@ 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
@@ -24,6 +25,36 @@ actor GatewayEndpointStore {
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
password: {
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
let root = ClawdisConfigFile.loadDict()
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
{
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
if !pw.isEmpty {
return pw
}
}
return nil
},
localPort: { GatewayEnvironment.gatewayPort() },
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
@@ -47,9 +78,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:
@@ -77,17 +110,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"))
}
@@ -110,8 +144,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])
@@ -122,9 +156,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))
@@ -144,7 +179,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>
@@ -71,9 +72,17 @@ enum GatewayLaunchAgentManager {
<string>sips</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """
<key>CLAWDIS_GATEWAY_TOKEN</key>
<string>\(token)</string>
<string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(escapedPassword)</string>
"""
}
let plist = """
@@ -146,6 +155,33 @@ 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 static func escapePlistValue(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
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

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

View File

@@ -52,7 +52,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);