diff --git a/CHANGELOG.md b/CHANGELOG.md index b41ed76da1c..dfd2c10056b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai - Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon. - WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured `allowFrom` entries. Fixes #62339. Thanks @kelvinisly-collab. - Google Meet: keep the agent-facing `google_meet` tool visible on non-macOS hosts but block local Chrome realtime actions with guidance, so Linux agents can still use transcribe, Twilio, chrome-node, and artifact flows without choosing the macOS-only BlackHole path. Refs #75950. Thanks @actual-software-inc. +- macOS/settings: keep opening General from rewriting `openclaw.json` during Tailscale settings hydration, preserving `gateway`, `auth`, `meta`, and `wizard` until the user changes a setting. Fixes #59545. Thanks @Tengdw. - Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar. - Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant `loadOpenClawPlugins` call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen. - Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010. diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift index cf206a60bb3..6984351f4df 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift @@ -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 diff --git a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift index 13cd622b920..5ee92536488 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift @@ -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) + } }