diff --git a/CHANGELOG.md b/CHANGELOG.md index 253e71a58d2..7edcc627a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213. - Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi. - Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei. +- macOS/config: preserve existing `gateway.auth` and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013. - Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. - CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw. - Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury. diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index 29146aca7e1..ecc96205067 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -48,7 +48,10 @@ enum ConfigStore { } @MainActor - static func save(_ root: sending [String: Any]) async throws { + static func save( + _ root: sending [String: Any], + allowGatewayAuthMutation: Bool = false) async throws + { let overrides = await self.overrideStore.overrides if await self.isRemoteMode() { if let override = overrides.saveRemote { @@ -63,7 +66,10 @@ enum ConfigStore { do { try await self.saveToGateway(root) } catch { - OpenClawConfigFile.saveDict(root) + OpenClawConfigFile.saveDict( + root, + preserveExistingKeys: true, + allowGatewayAuthMutation: allowGatewayAuthMutation) } } } diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 287df23e5fc..7b80a34bff7 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -52,7 +52,11 @@ enum OpenClawConfigFile { } } - static func saveDict(_ dict: [String: Any]) { + static func saveDict( + _ dict: [String: Any], + preserveExistingKeys: Bool = false, + allowGatewayAuthMutation: Bool = false) + { self.withFileLock { // Nix mode disables config writes in production, but tests rely on saving temp configs. if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } @@ -64,7 +68,15 @@ enum OpenClawConfigFile { let hadMetaBefore = self.hasMeta(previousRoot) let gatewayModeBefore = self.gatewayMode(previousRoot) - var output = dict + var output = if preserveExistingKeys, let previousRoot { + self.mergeExistingConfig(previousRoot, overridingWith: dict) + } else { + dict + } + let preservedGatewayAuth = self.preserveGatewayAuthIfNeeded( + previousRoot: previousRoot, + output: &output, + allowGatewayAuthMutation: allowGatewayAuthMutation) self.stampMeta(&output) do { @@ -76,13 +88,16 @@ enum OpenClawConfigFile { let nextBytes = data.count let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) let gatewayModeAfter = self.gatewayMode(output) - let suspicious = self.configWriteSuspiciousReasons( + var suspicious = self.configWriteSuspiciousReasons( existsBefore: previousData != nil, previousBytes: previousBytes, nextBytes: nextBytes, hadMetaBefore: hadMetaBefore, gatewayModeBefore: gatewayModeBefore, gatewayModeAfter: gatewayModeAfter) + if preservedGatewayAuth { + suspicious.append("gateway-auth-preserved") + } if !suspicious.isEmpty { self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") } @@ -123,7 +138,7 @@ enum OpenClawConfigFile { "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), - "suspicious": [], + "suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [], "error": error.localizedDescription, ]) } @@ -331,6 +346,52 @@ enum OpenClawConfigFile { return trimmed.isEmpty ? nil : trimmed } + private static func gatewayAuth(_ root: [String: Any]?) -> [String: Any]? { + guard let root, + let gateway = root["gateway"] as? [String: Any] + else { return nil } + return gateway["auth"] as? [String: Any] + } + + private static func configDictionariesEqual(_ left: [String: Any]?, _ right: [String: Any]) -> Bool { + guard let left else { return false } + return NSDictionary(dictionary: left).isEqual(NSDictionary(dictionary: right)) + } + + private static func mergeExistingConfig( + _ existing: [String: Any], + overridingWith next: [String: Any]) -> [String: Any] + { + var merged = existing + for (key, value) in next { + if let nextDict = value as? [String: Any], + let existingDict = merged[key] as? [String: Any] + { + merged[key] = self.mergeExistingConfig(existingDict, overridingWith: nextDict) + } else { + merged[key] = value + } + } + return merged + } + + private static func preserveGatewayAuthIfNeeded( + previousRoot: [String: Any]?, + output: inout [String: Any], + allowGatewayAuthMutation: Bool) -> Bool + { + guard !allowGatewayAuthMutation, + let previousAuth = self.gatewayAuth(previousRoot) + else { + return false + } + var gateway = output["gateway"] as? [String: Any] ?? [:] + let changed = !self.configDictionariesEqual(gateway["auth"] as? [String: Any], previousAuth) + gateway["auth"] = previousAuth + output["gateway"] = gateway + return changed + } + private static func configWriteSuspiciousReasons( existsBefore: Bool, previousBytes: Int?, diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift index c9354d38bc2..cf206a60bb3 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift @@ -348,7 +348,7 @@ struct TailscaleIntegrationSection: View { } do { - try await ConfigStore.save(root) + try await ConfigStore.save(root, allowGatewayAuthMutation: true) return (true, nil) } catch { return (false, error.localizedDescription) diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 14ab472558d..018626be884 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -162,6 +162,110 @@ struct OpenClawConfigFileTests { } } + @MainActor + @Test + func `save dict preserves gateway auth unless explicitly allowed`() 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) } + + await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "remote", + "auth": [ + "mode": "token", + "token": "existing-token", // pragma: allowlist secret + ], + ], + ]) + + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + ], + ]) + + let root = OpenClawConfigFile.loadDict() + let gateway = root["gateway"] as? [String: Any] + let auth = gateway?["auth"] as? [String: Any] + #expect(gateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "token") + #expect(auth?["token"] as? String == "existing-token") // pragma: allowlist secret + + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + ], + ], allowGatewayAuthMutation: true) + + let allowedRoot = OpenClawConfigFile.loadDict() + let allowedGateway = allowedRoot["gateway"] as? [String: Any] + #expect(allowedGateway?["mode"] as? String == "local") + #expect((allowedGateway?["auth"] as? [String: Any]) == nil) + } + } + + @MainActor + @Test + func `save dict can merge local fallback writes with fresh 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) } + + await TestIsolation.withEnvValues([ + "OPENCLAW_STATE_DIR": stateDir.path, + "OPENCLAW_CONFIG_PATH": configPath.path, + ]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "remote", + "auth": [ + "mode": "password", + "password": "existing-password", // pragma: allowlist secret + ], + ], + "browser": [ + "enabled": true, + "profile": "work", + ], + "channels": [ + "discord": [ + "enabled": true, + ], + ], + ]) + + OpenClawConfigFile.saveDict([ + "gateway": [ + "mode": "local", + ], + "browser": [ + "enabled": false, + ], + ], preserveExistingKeys: true) + + let root = OpenClawConfigFile.loadDict() + let gateway = root["gateway"] as? [String: Any] + let auth = gateway?["auth"] as? [String: Any] + let browser = root["browser"] as? [String: Any] + let discord = ((root["channels"] as? [String: Any])?["discord"] as? [String: Any]) + #expect(gateway?["mode"] as? String == "local") + #expect(auth?["mode"] as? String == "password") + #expect(auth?["password"] as? String == "existing-password") // pragma: allowlist secret + #expect(browser?["enabled"] as? Bool == false) + #expect(browser?["profile"] as? String == "work") + #expect(discord?["enabled"] as? Bool == true) + } + } + @MainActor @Test func `load dict audits suspicious out-of-band clobbers`() async throws {