Merge pull request #85 from jeffersonwarrior/main
feat: add gateway password auth support and fix Swift 6.2 concurrency
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ apps/ios/*.dSYM.zip
|
|||||||
|
|
||||||
# provisioning profiles (local)
|
# provisioning profiles (local)
|
||||||
apps/ios/*.mobileprovision
|
apps/ios/*.mobileprovision
|
||||||
|
.env
|
||||||
|
|||||||
@@ -97,4 +97,13 @@ enum ClawdisConfigFile {
|
|||||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -214,6 +217,8 @@ actor GatewayChannelActor {
|
|||||||
]
|
]
|
||||||
if let token = self.token {
|
if let token = self.token {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
|
} else if let password = self.password {
|
||||||
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame = RequestFrame(
|
let frame = RequestFrame(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,6 +17,7 @@ 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
|
||||||
@@ -24,6 +25,36 @@ actor GatewayEndpointStore {
|
|||||||
static let live = Deps(
|
static let live = Deps(
|
||||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||||
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
|
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() },
|
localPort: { GatewayEnvironment.gatewayPort() },
|
||||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||||
@@ -47,9 +78,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:
|
||||||
@@ -77,17 +110,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"))
|
||||||
}
|
}
|
||||||
@@ -110,8 +144,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])
|
||||||
@@ -122,9 +156,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))
|
||||||
@@ -144,7 +179,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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -71,9 +72,17 @@ enum GatewayLaunchAgentManager {
|
|||||||
<string>sips</string>
|
<string>sips</string>
|
||||||
"""
|
"""
|
||||||
if let token {
|
if let token {
|
||||||
|
let escapedToken = self.escapePlistValue(token)
|
||||||
envEntries += """
|
envEntries += """
|
||||||
<key>CLAWDIS_GATEWAY_TOKEN</key>
|
<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 = """
|
let plist = """
|
||||||
@@ -146,6 +155,33 @@ 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 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 {
|
private struct LaunchctlResult {
|
||||||
let status: Int32
|
let status: Int32
|
||||||
let output: String
|
let output: String
|
||||||
|
|||||||
@@ -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, ())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import Testing
|
|||||||
let store = GatewayEndpointStore(deps: .init(
|
let store = GatewayEndpointStore(deps: .init(
|
||||||
mode: { mode.get() },
|
mode: { mode.get() },
|
||||||
token: { "t" },
|
token: { "t" },
|
||||||
|
password: { nil },
|
||||||
localPort: { 1234 },
|
localPort: { 1234 },
|
||||||
remotePortIfRunning: { nil },
|
remotePortIfRunning: { nil },
|
||||||
ensureRemoteTunnel: { 18789 }))
|
ensureRemoteTunnel: { 18789 }))
|
||||||
@@ -44,6 +45,7 @@ import Testing
|
|||||||
let store = GatewayEndpointStore(deps: .init(
|
let store = GatewayEndpointStore(deps: .init(
|
||||||
mode: { mode.get() },
|
mode: { mode.get() },
|
||||||
token: { nil },
|
token: { nil },
|
||||||
|
password: { nil },
|
||||||
localPort: { 18789 },
|
localPort: { 18789 },
|
||||||
remotePortIfRunning: { nil },
|
remotePortIfRunning: { nil },
|
||||||
ensureRemoteTunnel: { 18789 }))
|
ensureRemoteTunnel: { 18789 }))
|
||||||
@@ -58,6 +60,7 @@ import Testing
|
|||||||
let store = GatewayEndpointStore(deps: .init(
|
let store = GatewayEndpointStore(deps: .init(
|
||||||
mode: { mode.get() },
|
mode: { mode.get() },
|
||||||
token: { "tok" },
|
token: { "tok" },
|
||||||
|
password: { "pw" },
|
||||||
localPort: { 1 },
|
localPort: { 1 },
|
||||||
remotePortIfRunning: { 5555 },
|
remotePortIfRunning: { 5555 },
|
||||||
ensureRemoteTunnel: { 5555 }))
|
ensureRemoteTunnel: { 5555 }))
|
||||||
@@ -69,13 +72,14 @@ import Testing
|
|||||||
_ = try await store.ensureRemoteControlTunnel()
|
_ = try await store.ensureRemoteControlTunnel()
|
||||||
|
|
||||||
let next = await iterator.next()
|
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))")
|
Issue.record("expected .ready after ensure, got \(String(describing: next))")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#expect(mode == .remote)
|
#expect(mode == .remote)
|
||||||
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
||||||
#expect(token == "tok")
|
#expect(token == "tok")
|
||||||
|
#expect(password == "pw")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func unconfiguredModeRejectsConfig() async {
|
@Test func unconfiguredModeRejectsConfig() async {
|
||||||
@@ -83,6 +87,7 @@ import Testing
|
|||||||
let store = GatewayEndpointStore(deps: .init(
|
let store = GatewayEndpointStore(deps: .init(
|
||||||
mode: { mode.get() },
|
mode: { mode.get() },
|
||||||
token: { nil },
|
token: { nil },
|
||||||
|
password: { nil },
|
||||||
localPort: { 18789 },
|
localPort: { 18789 },
|
||||||
remotePortIfRunning: { nil },
|
remotePortIfRunning: { nil },
|
||||||
ensureRemoteTunnel: { 18789 }))
|
ensureRemoteTunnel: { 18789 }))
|
||||||
|
|||||||
@@ -52,7 +52,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user