mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +00:00
fix(macos): avoid Tailscale hydration config rewrites
Fixes #59545.
Suppress the macOS General/Tailscale initial hydration apply path from rewriting openclaw.json when settings are unchanged, and add regression coverage for gateway/auth/meta/wizard preservation.
Verified on the retry head 8a30aa831c:
- GitHub CI completed successfully, including macos-node, macos-swift, check-docs, security, Workflow Sanity, and OpenGrep.
- Review threads were empty before merge.
- Duplicate sweep kept #59545 as the canonical standalone issue; no duplicate closures were appropriate.
This commit is contained in:
@@ -29,6 +29,42 @@ private enum GatewayTailscaleMode: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleSettingsSnapshot: Equatable {
|
||||
var mode: GatewayTailscaleMode
|
||||
var requireCredentialsForServe: Bool
|
||||
var password: String
|
||||
|
||||
init(mode: GatewayTailscaleMode, requireCredentialsForServe: Bool, password: String) {
|
||||
self.mode = mode
|
||||
self.requireCredentialsForServe = requireCredentialsForServe
|
||||
self.password = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleLoadedSettings {
|
||||
var snapshot: GatewayTailscaleSettingsSnapshot
|
||||
var displayPassword: String
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleApplyResult {
|
||||
var didApply: Bool
|
||||
var success: Bool
|
||||
var errorMessage: String?
|
||||
var validationMessage: String?
|
||||
}
|
||||
|
||||
private struct GatewayTailscaleApplyMessages {
|
||||
var statusMessage: String?
|
||||
var validationMessage: String?
|
||||
var shouldRecordSuccess: Bool
|
||||
var shouldRestartGateway: Bool
|
||||
}
|
||||
|
||||
private typealias GatewayTailscaleSettingsSaver = @MainActor @Sendable (
|
||||
GatewayTailscaleSettingsSnapshot,
|
||||
AppState.ConnectionMode,
|
||||
Bool) async -> (Bool, String?)
|
||||
|
||||
struct TailscaleIntegrationSection: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
let isPaused: Bool
|
||||
@@ -45,6 +81,7 @@ struct TailscaleIntegrationSection: View {
|
||||
@State private var statusMessage: String?
|
||||
@State private var validationMessage: String?
|
||||
@State private var statusTimer: Timer?
|
||||
@State private var lastAppliedSettings: GatewayTailscaleSettingsSnapshot?
|
||||
|
||||
init(connectionMode: AppState.ConnectionMode, isPaused: Bool) {
|
||||
self.connectionMode = connectionMode
|
||||
@@ -246,60 +283,34 @@ struct TailscaleIntegrationSection: View {
|
||||
|
||||
private func loadConfig() async {
|
||||
let root = await ConfigStore.load()
|
||||
let gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
|
||||
self.tailscaleMode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
||||
|
||||
let auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
let authModeRaw = auth["mode"] as? String
|
||||
let allowTailscale = auth["allowTailscale"] as? Bool
|
||||
|
||||
self.password = auth["password"] as? String ?? ""
|
||||
|
||||
if self.tailscaleMode == .serve {
|
||||
let usesExplicitAuth = authModeRaw == "password"
|
||||
if let allowTailscale, allowTailscale == false {
|
||||
self.requireCredentialsForServe = true
|
||||
} else {
|
||||
self.requireCredentialsForServe = usesExplicitAuth
|
||||
}
|
||||
} else {
|
||||
self.requireCredentialsForServe = false
|
||||
}
|
||||
let loaded = TailscaleIntegrationSection.loadedSettings(from: root)
|
||||
self.tailscaleMode = loaded.snapshot.mode
|
||||
self.requireCredentialsForServe = loaded.snapshot.requireCredentialsForServe
|
||||
self.password = loaded.displayPassword
|
||||
self.lastAppliedSettings = loaded.snapshot
|
||||
}
|
||||
|
||||
private func applySettings() async {
|
||||
guard self.hasLoaded else { return }
|
||||
self.validationMessage = nil
|
||||
self.statusMessage = nil
|
||||
|
||||
let trimmedPassword = self.password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requiresPassword = self.tailscaleMode == .funnel
|
||||
|| (self.tailscaleMode == .serve && self.requireCredentialsForServe)
|
||||
if requiresPassword, trimmedPassword.isEmpty {
|
||||
self.validationMessage = "Password required for this mode."
|
||||
return
|
||||
}
|
||||
|
||||
let (success, errorMessage) = await TailscaleIntegrationSection.buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: self.tailscaleMode,
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: trimmedPassword,
|
||||
let currentSettings = self.currentSettingsSnapshot()
|
||||
let result = await TailscaleIntegrationSection.applySettingsIfChanged(
|
||||
currentSettings: currentSettings,
|
||||
lastAppliedSettings: self.lastAppliedSettings,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused,
|
||||
saveSettings: TailscaleIntegrationSection.saveTailscaleSettings)
|
||||
let messages = TailscaleIntegrationSection.messages(
|
||||
for: result,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused)
|
||||
self.validationMessage = messages.validationMessage
|
||||
self.statusMessage = messages.statusMessage
|
||||
guard messages.shouldRecordSuccess else { return }
|
||||
|
||||
if !success, let errorMessage {
|
||||
self.statusMessage = errorMessage
|
||||
return
|
||||
self.lastAppliedSettings = currentSettings
|
||||
if messages.shouldRestartGateway {
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
if self.connectionMode == .local, !self.isPaused {
|
||||
self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restarting gateway…"
|
||||
} else {
|
||||
self.statusMessage = "Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply."
|
||||
}
|
||||
self.restartGatewayIfNeeded()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -310,28 +321,46 @@ struct TailscaleIntegrationSection: View {
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
var root = await ConfigStore.load()
|
||||
let settings = GatewayTailscaleSettingsSnapshot(
|
||||
mode: tailscaleMode,
|
||||
requireCredentialsForServe: requireCredentialsForServe,
|
||||
password: password)
|
||||
let root = await self.buildTailscaleConfigRoot(root: ConfigStore.load(), settings: settings)
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root, allowGatewayAuthMutation: true)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildTailscaleConfigRoot(
|
||||
root originalRoot: [String: Any],
|
||||
settings: GatewayTailscaleSettingsSnapshot) -> [String: Any]
|
||||
{
|
||||
var root = originalRoot
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
tailscale["mode"] = tailscaleMode.rawValue
|
||||
tailscale["mode"] = settings.mode.rawValue
|
||||
gateway["tailscale"] = tailscale
|
||||
|
||||
if tailscaleMode != .off {
|
||||
if settings.mode != .off {
|
||||
gateway["bind"] = "loopback"
|
||||
}
|
||||
|
||||
if tailscaleMode == .off {
|
||||
if settings.mode == .off {
|
||||
gateway.removeValue(forKey: "auth")
|
||||
} else {
|
||||
var auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
if tailscaleMode == .serve, !requireCredentialsForServe {
|
||||
if settings.mode == .serve, !settings.requireCredentialsForServe {
|
||||
auth["allowTailscale"] = true
|
||||
auth.removeValue(forKey: "mode")
|
||||
auth.removeValue(forKey: "password")
|
||||
} else {
|
||||
auth["allowTailscale"] = false
|
||||
auth["mode"] = "password"
|
||||
auth["password"] = password
|
||||
auth["password"] = settings.password
|
||||
}
|
||||
|
||||
if auth.isEmpty {
|
||||
@@ -347,12 +376,7 @@ struct TailscaleIntegrationSection: View {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root, allowGatewayAuthMutation: true)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private func restartGatewayIfNeeded() {
|
||||
@@ -360,6 +384,132 @@ struct TailscaleIntegrationSection: View {
|
||||
Task { await GatewayLaunchAgentManager.kickstart() }
|
||||
}
|
||||
|
||||
private func currentSettingsSnapshot() -> GatewayTailscaleSettingsSnapshot {
|
||||
GatewayTailscaleSettingsSnapshot(
|
||||
mode: self.tailscaleMode,
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: self.password.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
private static func loadedSettings(from root: [String: Any]) -> GatewayTailscaleLoadedSettings {
|
||||
let gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
let tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
let modeRaw = (tailscale["mode"] as? String) ?? "serve"
|
||||
let mode = GatewayTailscaleMode(rawValue: modeRaw) ?? .off
|
||||
|
||||
let auth = gateway["auth"] as? [String: Any] ?? [:]
|
||||
let authModeRaw = auth["mode"] as? String
|
||||
let allowTailscale = auth["allowTailscale"] as? Bool
|
||||
let password = auth["password"] as? String ?? ""
|
||||
let requireCredentialsForServe: Bool
|
||||
|
||||
if mode == .serve {
|
||||
let usesExplicitAuth = authModeRaw == "password"
|
||||
if let allowTailscale, allowTailscale == false {
|
||||
requireCredentialsForServe = true
|
||||
} else {
|
||||
requireCredentialsForServe = usesExplicitAuth
|
||||
}
|
||||
} else {
|
||||
requireCredentialsForServe = false
|
||||
}
|
||||
|
||||
return GatewayTailscaleLoadedSettings(
|
||||
snapshot: GatewayTailscaleSettingsSnapshot(
|
||||
mode: mode,
|
||||
requireCredentialsForServe: requireCredentialsForServe,
|
||||
password: password),
|
||||
displayPassword: password)
|
||||
}
|
||||
|
||||
private static func applySettingsIfChanged(
|
||||
currentSettings: GatewayTailscaleSettingsSnapshot,
|
||||
lastAppliedSettings: GatewayTailscaleSettingsSnapshot?,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool,
|
||||
saveSettings: GatewayTailscaleSettingsSaver) async -> GatewayTailscaleApplyResult
|
||||
{
|
||||
guard currentSettings != lastAppliedSettings else {
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: false,
|
||||
success: true,
|
||||
errorMessage: nil,
|
||||
validationMessage: nil)
|
||||
}
|
||||
|
||||
let requiresPassword = currentSettings.mode == .funnel
|
||||
|| (currentSettings.mode == .serve && currentSettings.requireCredentialsForServe)
|
||||
if requiresPassword, currentSettings.password.isEmpty {
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: true,
|
||||
success: false,
|
||||
errorMessage: nil,
|
||||
validationMessage: "Password required for this mode.")
|
||||
}
|
||||
|
||||
let (success, errorMessage) = await saveSettings(currentSettings, connectionMode, isPaused)
|
||||
return GatewayTailscaleApplyResult(
|
||||
didApply: true,
|
||||
success: success,
|
||||
errorMessage: errorMessage,
|
||||
validationMessage: nil)
|
||||
}
|
||||
|
||||
private static func messages(
|
||||
for result: GatewayTailscaleApplyResult,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) -> GatewayTailscaleApplyMessages
|
||||
{
|
||||
guard result.didApply else {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: nil,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
if let validationMessage = result.validationMessage {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: nil,
|
||||
validationMessage: validationMessage,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
if !result.success, let errorMessage = result.errorMessage {
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: errorMessage,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: false,
|
||||
shouldRestartGateway: false)
|
||||
}
|
||||
|
||||
let statusMessage = if connectionMode == .local, !isPaused {
|
||||
"Saved to ~/.openclaw/openclaw.json. Restarting gateway…"
|
||||
} else {
|
||||
"Saved to ~/.openclaw/openclaw.json. Restart the gateway to apply."
|
||||
}
|
||||
return GatewayTailscaleApplyMessages(
|
||||
statusMessage: statusMessage,
|
||||
validationMessage: nil,
|
||||
shouldRecordSuccess: true,
|
||||
shouldRestartGateway: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func saveTailscaleSettings(
|
||||
settings: GatewayTailscaleSettingsSnapshot,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
await self.buildAndSaveTailscaleConfig(
|
||||
tailscaleMode: settings.mode,
|
||||
requireCredentialsForServe: settings.requireCredentialsForServe,
|
||||
password: settings.password,
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused)
|
||||
}
|
||||
|
||||
private func startStatusTimer() {
|
||||
self.stopStatusTimer()
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
@@ -397,5 +547,51 @@ extension TailscaleIntegrationSection {
|
||||
mutating func setTestingService(_ service: TailscaleService?) {
|
||||
self.testingService = service
|
||||
}
|
||||
|
||||
static func simulateHydrationApplyForTesting(
|
||||
root: [String: Any],
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool,
|
||||
saveRoot: @MainActor @Sendable @escaping ([String: Any]) -> Void) async
|
||||
{
|
||||
let loaded = self.loadedSettings(from: root)
|
||||
_ = await self.applySettingsIfChanged(
|
||||
currentSettings: loaded.snapshot,
|
||||
lastAppliedSettings: loaded.snapshot,
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused,
|
||||
saveSettings: { settings, _, _ in
|
||||
let nextRoot = self.buildTailscaleConfigRoot(root: root, settings: settings)
|
||||
saveRoot(nextRoot)
|
||||
return (true, nil)
|
||||
})
|
||||
}
|
||||
|
||||
static func messagesForTesting(
|
||||
didApply: Bool,
|
||||
success: Bool,
|
||||
errorMessage: String? = nil,
|
||||
validationMessage: String? = nil,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) -> (
|
||||
statusMessage: String?,
|
||||
validationMessage: String?,
|
||||
shouldRecordSuccess: Bool,
|
||||
shouldRestartGateway: Bool)
|
||||
{
|
||||
let messages = self.messages(
|
||||
for: GatewayTailscaleApplyResult(
|
||||
didApply: didApply,
|
||||
success: success,
|
||||
errorMessage: errorMessage,
|
||||
validationMessage: validationMessage),
|
||||
connectionMode: connectionMode,
|
||||
isPaused: isPaused)
|
||||
return (
|
||||
statusMessage: messages.statusMessage,
|
||||
validationMessage: messages.validationMessage,
|
||||
shouldRecordSuccess: messages.shouldRecordSuccess,
|
||||
shouldRestartGateway: messages.shouldRestartGateway)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -45,4 +45,87 @@ struct TailscaleIntegrationSectionTests {
|
||||
validationMessage: "Invalid token")
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `general tailscale hydration does not rewrite existing config`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
let initialConfig = """
|
||||
{
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.3.28",
|
||||
"lastTouchedAt": "2026-03-31T13:15:24.532Z"
|
||||
},
|
||||
"wizard": {
|
||||
"lastRunAt": "2026-03-30T14:24:54.570Z",
|
||||
"lastRunVersion": "2026.3.24"
|
||||
},
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "auto",
|
||||
"tailscale": {
|
||||
"mode": "serve"
|
||||
},
|
||||
"auth": {
|
||||
"mode": "token",
|
||||
"token": "existing-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try initialConfig.write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
let before = try Data(contentsOf: configPath)
|
||||
let root = try #require(
|
||||
JSONSerialization.jsonObject(with: before) as? [String: Any])
|
||||
|
||||
await TailscaleIntegrationSection.simulateHydrationApplyForTesting(
|
||||
root: root,
|
||||
connectionMode: .local,
|
||||
isPaused: true,
|
||||
saveRoot: { root in
|
||||
OpenClawConfigFile.saveDict(root, allowGatewayAuthMutation: true)
|
||||
})
|
||||
|
||||
let after = try Data(contentsOf: configPath)
|
||||
#expect(after == before)
|
||||
|
||||
let afterRoot = try #require(
|
||||
JSONSerialization.jsonObject(with: after) as? [String: Any])
|
||||
let gateway = try #require(afterRoot["gateway"] as? [String: Any])
|
||||
let auth = try #require(gateway["auth"] as? [String: Any])
|
||||
let meta = try #require(afterRoot["meta"] as? [String: Any])
|
||||
let wizard = try #require(afterRoot["wizard"] as? [String: Any])
|
||||
|
||||
#expect(gateway["bind"] as? String == "auto")
|
||||
#expect(auth["mode"] as? String == "token")
|
||||
#expect(auth["token"] as? String == "existing-token") // pragma: allowlist secret
|
||||
#expect(meta["lastTouchedAt"] as? String == "2026-03-31T13:15:24.532Z")
|
||||
#expect(wizard["lastRunAt"] as? String == "2026-03-30T14:24:54.570Z")
|
||||
#expect(wizard["lastRunVersion"] as? String == "2026.3.24")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `unchanged tailscale apply clears stale messages`() {
|
||||
let messages = TailscaleIntegrationSection.messagesForTesting(
|
||||
didApply: false,
|
||||
success: true,
|
||||
connectionMode: .local,
|
||||
isPaused: false)
|
||||
|
||||
#expect(messages.statusMessage == nil)
|
||||
#expect(messages.validationMessage == nil)
|
||||
#expect(messages.shouldRecordSuccess == false)
|
||||
#expect(messages.shouldRestartGateway == false)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user