fix(macos): preserve gateway auth config writes

Preserve existing gateway.auth and unrelated config keys during macOS app fallback writes, while requiring explicit opt-in for auth mutation paths.\n\nValidation:\n- swift test --package-path apps/macos --filter OpenClawIPCTests.OpenClawConfigFileTests\n- swift test --package-path apps/macos --filter OpenClawIPCTests.ConfigStoreTests\n- node scripts/check-changed.mjs CHANGELOG.md apps/macos/Sources/OpenClaw/ConfigStore.swift apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift\n\nCloses #75631.
This commit is contained in:
Val Alexander
2026-05-01 23:45:55 -05:00
committed by GitHub
parent f2370b769c
commit 7c2802b212
5 changed files with 179 additions and 7 deletions

View File

@@ -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.

View File

@@ -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)
}
}
}

View File

@@ -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?,

View File

@@ -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)

View File

@@ -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 {