mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +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:
@@ -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.
|
||||
|
||||
@@ -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