diff --git a/CHANGELOG.md b/CHANGELOG.md index 0466824dc..cd84926e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). - Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. +- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). ## 2026.1.14 diff --git a/apps/macos/Sources/Clawdbot/ConfigStore.swift b/apps/macos/Sources/Clawdbot/ConfigStore.swift index 93b10cff4..b1643d713 100644 --- a/apps/macos/Sources/Clawdbot/ConfigStore.swift +++ b/apps/macos/Sources/Clawdbot/ConfigStore.swift @@ -19,6 +19,7 @@ enum ConfigStore { } private static let overrideStore = OverrideStore() + @MainActor private static var lastHash: String? private static func isRemoteMode() async -> Bool { let overrides = await self.overrideStore.overrides @@ -75,6 +76,7 @@ enum ConfigStore { method: .configGet, params: nil, timeoutMs: 8000) + self.lastHash = snap.hash return snap.config?.mapValues { $0.foundationValue } ?? [:] } catch { return nil @@ -83,17 +85,24 @@ enum ConfigStore { @MainActor private static func saveToGateway(_ root: [String: Any]) async throws { + if self.lastHash == nil { + _ = await self.loadFromGateway() + } let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) guard let raw = String(data: data, encoding: .utf8) else { throw NSError(domain: "ConfigStore", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Failed to encode config.", ]) } - let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] + var params: [String: AnyCodable] = ["raw": AnyCodable(raw)] + if let baseHash = self.lastHash { + params["baseHash"] = AnyCodable(baseHash) + } _ = try await GatewayConnection.shared.requestRaw( method: .configSet, params: params, timeoutMs: 10000) + _ = await self.loadFromGateway() } #if DEBUG diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift index 4d19d5d65..2e8f12d42 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore+Config.swift @@ -12,6 +12,7 @@ extension ConnectionsStore { ? "Config invalid; fix it in ~/.clawdbot/clawdbot.json." : nil self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] + self.configHash = snap.hash self.configLoaded = true self.applyUIConfig(snap) @@ -34,10 +35,22 @@ extension ConnectionsStore { AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam } + private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? { + if let channels = snap.config?["channels"]?.dictionaryValue, + let entry = channels[key]?.dictionaryValue { + return entry + } + return snap.config?[key]?.dictionaryValue + } + private func applyTelegramConfig(_ snap: ConfigSnapshot) { - let telegram = snap.config?["telegram"]?.dictionaryValue + let telegram = self.resolveChannelConfig(snap, key: "telegram") self.telegramToken = telegram?["botToken"]?.stringValue ?? "" - self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true + let groups = telegram?["groups"]?.dictionaryValue + let defaultGroup = groups?["*"]?.dictionaryValue + self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue + ?? telegram?["requireMention"]?.boolValue + ?? true self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue) self.telegramProxy = telegram?["proxy"]?.stringValue ?? "" self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" @@ -46,7 +59,7 @@ extension ConnectionsStore { } private func applyDiscordConfig(_ snap: ConfigSnapshot) { - let discord = snap.config?["discord"]?.dictionaryValue + let discord = self.resolveChannelConfig(snap, key: "discord") self.discordEnabled = discord?["enabled"]?.boolValue ?? true self.discordToken = discord?["token"]?.stringValue ?? "" @@ -122,7 +135,7 @@ extension ConnectionsStore { } private func applySignalConfig(_ snap: ConfigSnapshot) { - let signal = snap.config?["signal"]?.dictionaryValue + let signal = self.resolveChannelConfig(snap, key: "signal") self.signalEnabled = signal?["enabled"]?.boolValue ?? true self.signalAccount = signal?["account"]?.stringValue ?? "" self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? "" @@ -139,7 +152,7 @@ extension ConnectionsStore { } private func applyIMessageConfig(_ snap: ConfigSnapshot) { - let imessage = snap.config?["imessage"]?.dictionaryValue + let imessage = self.resolveChannelConfig(snap, key: "imessage") self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? "" self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? "" @@ -150,6 +163,15 @@ extension ConnectionsStore { self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"]) } + private func channelConfigRoot(for key: String) -> [String: Any] { + if let channels = self.configRoot["channels"] as? [String: Any], + let entry = channels[key] as? [String: Any] + { + return entry + } + return self.configRoot[key] as? [String: Any] ?? [:] + } + func saveTelegramConfig() async { guard !self.isSavingConfig else { return } self.isSavingConfig = true @@ -158,30 +180,24 @@ extension ConnectionsStore { await self.loadConfig() } - var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:] - let token = self.trimmed(self.telegramToken) - if token.isEmpty { - telegram.removeValue(forKey: "botToken") - } else { - telegram["botToken"] = token + var telegram: [String: Any] = [:] + if !self.isTelegramTokenLocked { + self.setPatchString(&telegram, key: "botToken", value: self.telegramToken) } - - telegram["requireMention"] = self.telegramRequireMention - + telegram["requireMention"] = NSNull() + telegram["groups"] = [ + "*": [ + "requireMention": self.telegramRequireMention, + ], + ] let allow = self.splitCsv(self.telegramAllowFrom) - if allow.isEmpty { - telegram.removeValue(forKey: "allowFrom") - } else { - telegram["allowFrom"] = allow - } + self.setPatchList(&telegram, key: "allowFrom", values: allow) + self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy) + self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl) + self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret) + self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath) - self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy) - self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl) - self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret) - self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath) - - self.setSection("telegram", payload: telegram) - await self.persistConfig() + await self.persistChannelPatch("telegram", payload: telegram) } func saveDiscordConfig() async { @@ -192,9 +208,9 @@ extension ConnectionsStore { await self.loadConfig() } - let discord = self.buildDiscordConfig() - self.setSection("discord", payload: discord) - await self.persistConfig() + let base = self.channelConfigRoot(for: "discord") + let discord = self.buildDiscordPatch(base: base) + await self.persistChannelPatch("discord", payload: discord) } func saveSignalConfig() async { @@ -205,42 +221,23 @@ extension ConnectionsStore { await self.loadConfig() } - var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:] - if self.signalEnabled { - signal.removeValue(forKey: "enabled") - } else { - signal["enabled"] = false - } - - self.setOptionalString(&signal, key: "account", value: self.signalAccount) - self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl) - self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost) - self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort) - self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath) - - if self.signalAutoStart { - signal.removeValue(forKey: "autoStart") - } else { - signal["autoStart"] = false - } - - self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode) - - self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments) - self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories) - self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts) - + var signal: [String: Any] = [:] + self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true) + self.setPatchString(&signal, key: "account", value: self.signalAccount) + self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl) + self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost) + self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort) + self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath) + self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true) + self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode) + self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false) + self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false) + self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false) let allow = self.splitCsv(self.signalAllowFrom) - if allow.isEmpty { - signal.removeValue(forKey: "allowFrom") - } else { - signal["allowFrom"] = allow - } + self.setPatchList(&signal, key: "allowFrom", values: allow) + self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb) - self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb) - - self.setSection("signal", payload: signal) - await self.persistConfig() + await self.persistChannelPatch("signal", payload: signal) } func saveIMessageConfig() async { @@ -251,147 +248,168 @@ extension ConnectionsStore { await self.loadConfig() } - var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:] - if self.imessageEnabled { - imessage.removeValue(forKey: "enabled") - } else { - imessage["enabled"] = false - } - - self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath) - self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath) + var imessage: [String: Any] = [:] + self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true) + self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath) + self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath) let service = self.trimmed(self.imessageService) if service.isEmpty || service == "auto" { - imessage.removeValue(forKey: "service") + imessage["service"] = NSNull() } else { imessage["service"] = service } - self.setOptionalString(&imessage, key: "region", value: self.imessageRegion) + self.setPatchString(&imessage, key: "region", value: self.imessageRegion) let allow = self.splitCsv(self.imessageAllowFrom) - if allow.isEmpty { - imessage.removeValue(forKey: "allowFrom") - } else { - imessage["allowFrom"] = allow - } + self.setPatchList(&imessage, key: "allowFrom", values: allow) - self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments) - self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb) + self.setPatchBool( + &imessage, + key: "includeAttachments", + value: self.imessageIncludeAttachments, + defaultValue: false) + self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb) - self.setSection("imessage", payload: imessage) - await self.persistConfig() + await self.persistChannelPatch("imessage", payload: imessage) } - private func buildDiscordConfig() -> [String: Any] { - var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] - if self.discordEnabled { - discord.removeValue(forKey: "enabled") - } else { - discord["enabled"] = false + private func buildDiscordPatch(base: [String: Any]) -> [String: Any] { + var discord: [String: Any] = [:] + self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true) + if !self.isDiscordTokenLocked { + self.setPatchString(&discord, key: "token", value: self.discordToken) } - self.setOptionalString(&discord, key: "token", value: self.discordToken) - if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) { + if let dm = self.buildDiscordDmPatch() { discord["dm"] = dm } else { - discord.removeValue(forKey: "dm") + discord["dm"] = NSNull() } - self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) - self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) - self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) + self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) + self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) + self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) let replyToMode = self.trimmed(self.discordReplyToMode) - if replyToMode.isEmpty || replyToMode == "off" { - discord.removeValue(forKey: "replyToMode") - } else if ["first", "all"].contains(replyToMode) { - discord["replyToMode"] = replyToMode + if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) { + discord["replyToMode"] = NSNull() } else { - discord.removeValue(forKey: "replyToMode") + discord["replyToMode"] = replyToMode } - if let guilds = self.buildDiscordGuildsConfig() { + let baseGuilds = base["guilds"] as? [String: Any] ?? [:] + if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) { discord["guilds"] = guilds } else { - discord.removeValue(forKey: "guilds") + discord["guilds"] = NSNull() } - if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) { + if let actions = self.buildDiscordActionsPatch() { discord["actions"] = actions } else { - discord.removeValue(forKey: "actions") + discord["actions"] = NSNull() } - if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) { + if let slash = self.buildDiscordSlashPatch() { discord["slashCommand"] = slash } else { - discord.removeValue(forKey: "slashCommand") + discord["slashCommand"] = NSNull() } return discord } - private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? { - var dm = base - if self.discordDmEnabled { - dm.removeValue(forKey: "enabled") - } else { - dm["enabled"] = false - } + private func buildDiscordDmPatch() -> [String: Any]? { + var dm: [String: Any] = [:] + self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true) let allow = self.splitCsv(self.discordAllowFrom) - if allow.isEmpty { - dm.removeValue(forKey: "allowFrom") - } else { - dm["allowFrom"] = allow - } - - if self.discordGroupEnabled { - dm["groupEnabled"] = true - } else { - dm.removeValue(forKey: "groupEnabled") - } - + self.setPatchList(&dm, key: "allowFrom", values: allow) + self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false) let groupChannels = self.splitCsv(self.discordGroupChannels) - if groupChannels.isEmpty { - dm.removeValue(forKey: "groupChannels") - } else { - dm["groupChannels"] = groupChannels - } - + self.setPatchList(&dm, key: "groupChannels", values: groupChannels) return dm.isEmpty ? nil : dm } - private func buildDiscordGuildsConfig() -> [String: Any]? { - let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in - let key = self.trimmed(entry.key) - guard !key.isEmpty else { return } - var payload: [String: Any] = [:] - let slug = self.trimmed(entry.slug) - if !slug.isEmpty { payload["slug"] = slug } - if entry.requireMention { payload["requireMention"] = true } - if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) { - payload["reactionNotifications"] = entry.reactionNotifications - } - let users = self.splitCsv(entry.users) - if !users.isEmpty { payload["users"] = users } - let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in - let channelKey = self.trimmed(channel.key) - guard !channelKey.isEmpty else { return } - var channelPayload: [String: Any] = [:] - if !channel.allow { channelPayload["allow"] = false } - if channel.requireMention { channelPayload["requireMention"] = true } - channelsResult[channelKey] = channelPayload - } - if !channels.isEmpty { payload["channels"] = channels } - result[key] = payload + private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? { + if self.discordGuilds.isEmpty { + return NSNull() } - return guilds.isEmpty ? nil : guilds + var patch: [String: Any] = [:] + let baseKeys = Set(base.keys) + var formKeys = Set() + for entry in self.discordGuilds { + let key = self.trimmed(entry.key) + guard !key.isEmpty else { continue } + formKeys.insert(key) + let baseGuild = base[key] as? [String: Any] ?? [:] + patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild) + } + for key in baseKeys.subtracting(formKeys) { + patch[key] = NSNull() + } + return patch.isEmpty ? NSNull() : patch } - private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? { - var actions = base + private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] { + var payload: [String: Any] = [:] + let slug = self.trimmed(entry.slug) + if slug.isEmpty { + payload["slug"] = NSNull() + } else { + payload["slug"] = slug + } + if entry.requireMention { + payload["requireMention"] = true + } else { + payload["requireMention"] = NSNull() + } + if ["off", "all", "allowlist"].contains(entry.reactionNotifications) { + payload["reactionNotifications"] = entry.reactionNotifications + } else { + payload["reactionNotifications"] = NSNull() + } + let users = self.splitCsv(entry.users) + self.setPatchList(&payload, key: "users", values: users) + + let baseChannels = base["channels"] as? [String: Any] ?? [:] + if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) { + payload["channels"] = channels + } else { + payload["channels"] = NSNull() + } + return payload + } + + private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? { + if forms.isEmpty { + return NSNull() + } + var patch: [String: Any] = [:] + let baseKeys = Set(base.keys) + var formKeys = Set() + for channel in forms { + let channelKey = self.trimmed(channel.key) + guard !channelKey.isEmpty else { continue } + formKeys.insert(channelKey) + var channelPayload: [String: Any] = [:] + self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true) + self.setPatchBool( + &channelPayload, + key: "requireMention", + value: channel.requireMention, + defaultValue: false) + patch[channelKey] = channelPayload + } + for key in baseKeys.subtracting(formKeys) { + patch[key] = NSNull() + } + return patch.isEmpty ? NSNull() : patch + } + + private func buildDiscordActionsPatch() -> [String: Any]? { + var actions: [String: Any] = [:] self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true) self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true) self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true) @@ -410,52 +428,44 @@ extension ConnectionsStore { return actions.isEmpty ? nil : actions } - private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? { - var slash = base - if self.discordSlashEnabled { - slash["enabled"] = true - } else { - slash.removeValue(forKey: "enabled") - } - self.setOptionalString(&slash, key: "name", value: self.discordSlashName) - self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix) - if self.discordSlashEphemeral { - slash.removeValue(forKey: "ephemeral") - } else { - slash["ephemeral"] = false - } + private func buildDiscordSlashPatch() -> [String: Any]? { + var slash: [String: Any] = [:] + self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false) + self.setPatchString(&slash, key: "name", value: self.discordSlashName) + self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix) + self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true) return slash.isEmpty ? nil : slash } - private func persistConfig() async { + private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async { do { + guard let baseHash = self.configHash else { + self.configStatus = "Config hash missing; reload and retry." + return + } let data = try JSONSerialization.data( - withJSONObject: self.configRoot, + withJSONObject: ["channels": [channelId: payload]], options: [.prettyPrinted, .sortedKeys]) guard let raw = String(data: data, encoding: .utf8) else { self.configStatus = "Failed to encode config." return } - let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] + let params: [String: AnyCodable] = [ + "raw": AnyCodable(raw), + "baseHash": AnyCodable(baseHash), + ] _ = try await GatewayConnection.shared.requestRaw( - method: .configSet, + method: .configPatch, params: params, timeoutMs: 10000) self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." + await self.loadConfig() await self.refresh(probe: true) } catch { self.configStatus = error.localizedDescription } } - private func setSection(_ key: String, payload: [String: Any]) { - if payload.isEmpty { - self.configRoot.removeValue(forKey: key) - } else { - self.configRoot[key] = payload - } - } - private func stringList(from values: [AnyCodable]?) -> String { guard let values else { return "" } let strings = values.compactMap { entry -> String? in @@ -492,25 +502,29 @@ extension ConnectionsStore { value.trimmingCharacters(in: .whitespacesAndNewlines) } - private func setOptionalString(_ target: inout [String: Any], key: String, value: String) { + private func setPatchString(_ target: inout [String: Any], key: String, value: String) { let trimmed = self.trimmed(value) if trimmed.isEmpty { - target.removeValue(forKey: key) + target[key] = NSNull() } else { target[key] = trimmed } } - private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) { + private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) { let trimmed = self.trimmed(value) if trimmed.isEmpty { - target.removeValue(forKey: key) - } else if let number = Double(trimmed) { + target[key] = NSNull() + return + } + if let number = Double(trimmed) { target[key] = number + } else { + target[key] = NSNull() } } - private func setOptionalInt( + private func setPatchInt( _ target: inout [String: Any], key: String, value: String, @@ -518,26 +532,39 @@ extension ConnectionsStore { { let trimmed = self.trimmed(value) if trimmed.isEmpty { - target.removeValue(forKey: key) + target[key] = NSNull() return } guard let number = Int(trimmed) else { - target.removeValue(forKey: key) + target[key] = NSNull() return } let isValid = allowZero ? number >= 0 : number > 0 guard isValid else { - target.removeValue(forKey: key) + target[key] = NSNull() return } target[key] = number } - private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) { - if value { - target[key] = true + private func setPatchBool( + _ target: inout [String: Any], + key: String, + value: Bool, + defaultValue: Bool) + { + if value == defaultValue { + target[key] = NSNull() } else { - target.removeValue(forKey: key) + target[key] = value + } + } + + private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) { + if values.isEmpty { + target[key] = NSNull() + } else { + target[key] = values } } @@ -548,7 +575,7 @@ extension ConnectionsStore { defaultValue: Bool) { if value == defaultValue { - actions.removeValue(forKey: key) + actions[key] = NSNull() } else { actions[key] = value } diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift index d8daaec1c..fe0aa337b 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift @@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable { let path: String? let exists: Bool? let raw: String? + let hash: String? let parsed: AnyCodable? let valid: Bool? let config: [String: AnyCodable]? @@ -307,6 +308,7 @@ final class ConnectionsStore { var pollTask: Task? var configRoot: [String: Any] = [:] var configLoaded = false + var configHash: String? init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { self.isPreview = isPreview diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 29ee39fed..abe5310b6 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -55,6 +55,7 @@ actor GatewayConnection { case channelsStatus = "channels.status" case configGet = "config.get" case configSet = "config.set" + case configPatch = "config.patch" case wizardStart = "wizard.start" case wizardNext = "wizard.next" case wizardCancel = "wizard.cancel" diff --git a/docs/cli/index.md b/docs/cli/index.md index 490688c1f..2f587e4f9 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -579,8 +579,12 @@ Subcommands: Common RPCs: - `config.apply` (validate + write config + restart + wake) +- `config.patch` (merge a partial update without clobbering unrelated keys) - `update.run` (run update + restart + wake) +Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from +`config.get` if a config already exists. + ## Models See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 4b412e6b3..cf4e82647 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway Params: - `raw` (string) — JSON5 payload for the entire config +- `baseHash` (optional) — config hash from `config.get` (required when a config already exists) - `sessionKey` (optional) — last active session key for the wake-up ping - `restartDelayMs` (optional) — delay before restart (default 2000) Example (via `gateway call`): ```bash +clawdbot gateway call config.get --params '{}' # capture payload.hash clawdbot gateway call config.apply --params '{ "raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n", + "baseHash": "", "sessionKey": "agent:main:whatsapp:dm:+15555550123", "restartDelayMs": 1000 }' ``` +## Partial updates (RPC) + +Use `config.patch` to merge a partial update into the existing config without clobbering +unrelated keys. It applies JSON merge patch semantics: +- objects merge recursively +- `null` deletes a key +- arrays replace + +Params: +- `raw` (string) — JSON5 payload containing just the keys to change +- `baseHash` (required) — config hash from `config.get` + +Example: + +```bash +clawdbot gateway call config.get --params '{}' # capture payload.hash +clawdbot gateway call config.patch --params '{ + "raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n", + "baseHash": "" +}' +``` + ## Minimal config (recommended starting point) ```json5 diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index fa13fb3e4..0408131d8 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on ## What it can do (today) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) - Stream tool calls + live tool output cards in Chat (agent events) -- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.set`) +- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`) @@ -38,6 +38,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on - Nodes: list + caps (`node.list`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: apply + restart with validation (`config.apply`) and wake the last active session +- Config writes include a base-hash guard to prevent clobbering concurrent edits - Config schema + form rendering (`config.schema`); Raw JSON editor remains available - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`) diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index 54b57692a..a3dfa8309 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -6,7 +6,12 @@ import { describe, expect, it, vi } from "vitest"; import { createClawdbotTools } from "./clawdbot-tools.js"; vi.mock("./tools/gateway.js", () => ({ - callGatewayTool: vi.fn(async () => ({ ok: true })), + callGatewayTool: vi.fn(async (method: string) => { + if (method === "config.get") { + return { hash: "hash-1" }; + } + return { ok: true }; + }), })); describe("gateway tool", () => { @@ -71,11 +76,13 @@ describe("gateway tool", () => { raw, }); + expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {}); expect(callGatewayTool).toHaveBeenCalledWith( "config.apply", expect.any(Object), expect.objectContaining({ raw: raw.trim(), + baseHash: "hash-1", sessionKey: "agent:main:whatsapp:dm:+15555550123", }), ); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 5055d2dc5..09e1976bd 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -1,3 +1,5 @@ +import crypto from "node:crypto"; + import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; @@ -33,6 +35,7 @@ const GatewayToolSchema = Type.Object({ timeoutMs: Type.Optional(Type.Number()), // config.apply raw: Type.Optional(Type.String()), + baseHash: Type.Optional(Type.String()), // config.apply, update.run sessionKey: Type.Optional(Type.String()), note: Type.Optional(Type.String()), @@ -125,6 +128,24 @@ export function createGatewayTool(opts?: { } if (action === "config.apply") { const raw = readStringParam(params, "raw", { required: true }); + let baseHash = readStringParam(params, "baseHash"); + if (!baseHash) { + const snapshot = await callGatewayTool("config.get", gatewayOpts, {}); + if (snapshot && typeof snapshot === "object") { + const hash = (snapshot as { hash?: unknown }).hash; + if (typeof hash === "string" && hash.trim()) { + baseHash = hash.trim(); + } else { + const rawSnapshot = (snapshot as { raw?: unknown }).raw; + if (typeof rawSnapshot === "string") { + baseHash = crypto + .createHash("sha256") + .update(rawSnapshot) + .digest("hex"); + } + } + } + } const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() ? params.sessionKey.trim() @@ -137,6 +158,7 @@ export function createGatewayTool(opts?: { : undefined; const result = await callGatewayTool("config.apply", gatewayOpts, { raw, + baseHash, sessionKey, note, restartDelayMs, diff --git a/src/config/io.ts b/src/config/io.ts index f221d5aac..631952e80 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -51,6 +51,10 @@ const SHELL_ENV_EXPECTED_KEYS = [ export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string }; +function hashConfigRaw(raw: string | null): string { + return crypto.createHash("sha256").update(raw ?? "").digest("hex"); +} + export type ConfigIoDeps = { fs?: typeof fs; json5?: typeof JSON5; @@ -263,6 +267,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { async function readConfigFileSnapshot(): Promise { const exists = deps.fs.existsSync(configPath); if (!exists) { + const hash = hashConfigRaw(null); const config = applyTalkApiKey( applyModelDefaults( applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))), @@ -276,6 +281,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: {}, valid: true, config, + hash, issues: [], legacyIssues, }; @@ -283,6 +289,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { try { const raw = deps.fs.readFileSync(configPath, "utf-8"); + const hash = hashConfigRaw(raw); const parsedRes = parseConfigJson5(raw, deps.json5); if (!parsedRes.ok) { return { @@ -292,6 +299,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: {}, valid: false, config: {}, + hash, issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], legacyIssues: [], }; @@ -316,6 +324,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: false, config: {}, + hash, issues: [{ path: "", message }], legacyIssues: [], }; @@ -338,6 +347,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: false, config: resolvedConfig, + hash, issues: validated.issues, legacyIssues, }; @@ -363,6 +373,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ), ), ), + hash, issues: [], legacyIssues, }; @@ -374,6 +385,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: {}, valid: false, config: {}, + hash: hashConfigRaw(null), issues: [{ path: "", message: `read failed: ${String(err)}` }], legacyIssues: [], }; diff --git a/src/config/merge-patch.ts b/src/config/merge-patch.ts new file mode 100644 index 000000000..6b66d15ed --- /dev/null +++ b/src/config/merge-patch.ts @@ -0,0 +1,28 @@ +type PlainObject = Record; + +function isPlainObject(value: unknown): value is PlainObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function applyMergePatch(base: unknown, patch: unknown): unknown { + if (!isPlainObject(patch)) { + return patch; + } + + const result: PlainObject = isPlainObject(base) ? { ...base } : {}; + + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete result[key]; + continue; + } + if (isPlainObject(value)) { + const baseValue = result[key]; + result[key] = applyMergePatch(isPlainObject(baseValue) ? baseValue : {}, value); + continue; + } + result[key] = value; + } + + return result; +} diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 88b9e3984..8312e63a4 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -93,6 +93,7 @@ export type ConfigFileSnapshot = { parsed: unknown; valid: boolean; config: ClawdbotConfig; + hash?: string; issues: ConfigValidationIssue[]; legacyIssues: LegacyConfigIssue[]; }; diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 7fbd1c106..dca6c2862 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -27,6 +27,8 @@ import { ConfigApplyParamsSchema, type ConfigGetParams, ConfigGetParamsSchema, + type ConfigPatchParams, + ConfigPatchParamsSchema, type ConfigSchemaParams, ConfigSchemaParamsSchema, type ConfigSchemaResponse, @@ -201,6 +203,7 @@ export const validateSessionsCompactParams = ajv.compile( export const validateConfigGetParams = ajv.compile(ConfigGetParamsSchema); export const validateConfigSetParams = ajv.compile(ConfigSetParamsSchema); export const validateConfigApplyParams = ajv.compile(ConfigApplyParamsSchema); +export const validateConfigPatchParams = ajv.compile(ConfigPatchParamsSchema); export const validateConfigSchemaParams = ajv.compile(ConfigSchemaParamsSchema); export const validateWizardStartParams = ajv.compile(WizardStartParamsSchema); export const validateWizardNextParams = ajv.compile(WizardNextParamsSchema); @@ -272,6 +275,7 @@ export { ConfigGetParamsSchema, ConfigSetParamsSchema, ConfigApplyParamsSchema, + ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, WizardStartParamsSchema, @@ -338,6 +342,7 @@ export type { ConfigGetParams, ConfigSetParams, ConfigApplyParams, + ConfigPatchParams, ConfigSchemaParams, ConfigSchemaResponse, WizardStartParams, diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index c7e791075..10d0a7647 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -7,6 +7,7 @@ export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: fal export const ConfigSetParamsSchema = Type.Object( { raw: NonEmptyString, + baseHash: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); @@ -14,6 +15,7 @@ export const ConfigSetParamsSchema = Type.Object( export const ConfigApplyParamsSchema = Type.Object( { raw: NonEmptyString, + baseHash: Type.Optional(NonEmptyString), sessionKey: Type.Optional(Type.String()), note: Type.Optional(Type.String()), restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })), @@ -21,6 +23,14 @@ export const ConfigApplyParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ConfigPatchParamsSchema = Type.Object( + { + raw: NonEmptyString, + baseHash: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false }); export const UpdateRunParamsSchema = Type.Object( diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 5c10d5302..e57313f07 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -30,6 +30,7 @@ import { import { ConfigApplyParamsSchema, ConfigGetParamsSchema, + ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, @@ -131,6 +132,7 @@ export const ProtocolSchemas: Record = { ConfigGetParams: ConfigGetParamsSchema, ConfigSetParams: ConfigSetParamsSchema, ConfigApplyParams: ConfigApplyParamsSchema, + ConfigPatchParams: ConfigPatchParamsSchema, ConfigSchemaParams: ConfigSchemaParamsSchema, ConfigSchemaResponse: ConfigSchemaResponseSchema, WizardStartParams: WizardStartParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 82a538e59..502926561 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -28,6 +28,7 @@ import type { import type { ConfigApplyParamsSchema, ConfigGetParamsSchema, + ConfigPatchParamsSchema, ConfigSchemaParamsSchema, ConfigSchemaResponseSchema, ConfigSetParamsSchema, @@ -124,6 +125,7 @@ export type SessionsCompactParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; export type ConfigApplyParams = Static; +export type ConfigPatchParams = Static; export type ConfigSchemaParams = Static; export type ConfigSchemaResponse = Static; export type WizardStartParams = Static; diff --git a/src/gateway/server-bridge-methods-config.ts b/src/gateway/server-bridge-methods-config.ts index fd2a9b034..65d4211b1 100644 --- a/src/gateway/server-bridge-methods-config.ts +++ b/src/gateway/server-bridge-methods-config.ts @@ -7,17 +7,63 @@ import { validateConfigObject, writeConfigFile, } from "../config/config.js"; +import { applyLegacyMigrations } from "../config/legacy.js"; +import { applyMergePatch } from "../config/merge-patch.js"; import { buildConfigSchema } from "../config/schema.js"; import { loadClawdbotPlugins } from "../plugins/loader.js"; import { ErrorCodes, formatValidationErrors, validateConfigGetParams, + validateConfigPatchParams, validateConfigSchemaParams, validateConfigSetParams, } from "./protocol/index.js"; import type { BridgeMethodHandler } from "./server-bridge-types.js"; +function resolveBaseHash(params: unknown): string | null { + const raw = (params as { baseHash?: unknown })?.baseHash; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed ? trimmed : null; +} + +function requireConfigBaseHash( + params: unknown, + snapshot: Awaited>, +): { ok: true } | { ok: false; error: { code: string; message: string } } { + if (!snapshot.exists) return { ok: true }; + if (typeof snapshot.raw !== "string" || !snapshot.hash) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "config base hash unavailable; re-run config.get and retry", + }, + }; + } + const baseHash = resolveBaseHash(params); + if (!baseHash) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "config base hash required; re-run config.get and retry", + }, + }; + } + if (baseHash !== snapshot.hash) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "config changed since last load; re-run config.get and retry", + }, + }; + } + return { ok: true }; +} + export const handleConfigBridgeMethods: BridgeMethodHandler = async ( _ctx, _nodeId, @@ -85,6 +131,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async ( }, }; } + const snapshot = await readConfigFileSnapshot(); + const guard = requireConfigBaseHash(params, snapshot); + if (!guard.ok) { + return { ok: false, error: guard.error }; + } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { return { @@ -126,6 +177,87 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async ( }), }; } + case "config.patch": { + if (!validateConfigPatchParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`, + }, + }; + } + const snapshot = await readConfigFileSnapshot(); + const guard = requireConfigBaseHash(params, snapshot); + if (!guard.ok) { + return { ok: false, error: guard.error }; + } + if (!snapshot.valid) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config; fix before patching", + }, + }; + } + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config.patch params: raw (string) required", + }, + }; + } + const parsedRes = parseConfigJson5(rawValue); + if (!parsedRes.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: parsedRes.error, + }, + }; + } + if ( + !parsedRes.parsed || + typeof parsedRes.parsed !== "object" || + Array.isArray(parsedRes.parsed) + ) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "config.patch raw must be an object", + }, + }; + } + const merged = applyMergePatch(snapshot.config, parsedRes.parsed); + const migrated = applyLegacyMigrations(merged); + const resolved = migrated.next ?? merged; + const validated = validateConfigObject(resolved); + if (!validated.ok) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config", + details: { issues: validated.issues }, + }, + }; + } + await writeConfigFile(validated.config); + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + path: CONFIG_PATH_CLAWDBOT, + config: validated.config, + }), + }; + } default: return null; } diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index b74404ca9..59823232b 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -10,6 +10,7 @@ const BASE_METHODS = [ "config.get", "config.set", "config.apply", + "config.patch", "config.schema", "wizard.start", "wizard.next", diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 9c3af70e6..1e43a3c41 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -7,6 +7,8 @@ import { validateConfigObject, writeConfigFile, } from "../../config/config.js"; +import { applyLegacyMigrations } from "../../config/legacy.js"; +import { applyMergePatch } from "../../config/merge-patch.js"; import { buildConfigSchema } from "../../config/schema.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { @@ -21,11 +23,62 @@ import { formatValidationErrors, validateConfigApplyParams, validateConfigGetParams, + validateConfigPatchParams, validateConfigSchemaParams, validateConfigSetParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; +function resolveBaseHash(params: unknown): string | null { + const raw = (params as { baseHash?: unknown })?.baseHash; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed ? trimmed : null; +} + +function requireConfigBaseHash( + params: unknown, + snapshot: Awaited>, + respond: (ok: boolean, payload?: unknown, error?: unknown) => void, +): boolean { + if (!snapshot.exists) return true; + if (typeof snapshot.raw !== "string" || !snapshot.hash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "config base hash unavailable; re-run config.get and retry", + ), + ); + return false; + } + const baseHash = resolveBaseHash(params); + if (!baseHash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "config base hash required; re-run config.get and retry", + ), + ); + return false; + } + if (baseHash !== snapshot.hash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "config changed since last load; re-run config.get and retry", + ), + ); + return false; + } + return true; +} + export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!validateConfigGetParams(params)) { @@ -93,6 +146,10 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } + const snapshot = await readConfigFileSnapshot(); + if (!requireConfigBaseHash(params, snapshot, respond)) { + return; + } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( @@ -129,6 +186,80 @@ export const configHandlers: GatewayRequestHandlers = { undefined, ); }, + "config.patch": async ({ params, respond }) => { + if (!validateConfigPatchParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`, + ), + ); + return; + } + const snapshot = await readConfigFileSnapshot(); + if (!requireConfigBaseHash(params, snapshot, respond)) { + return; + } + if (!snapshot.valid) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid config; fix before patching"), + ); + return; + } + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid config.patch params: raw (string) required", + ), + ); + return; + } + const parsedRes = parseConfigJson5(rawValue); + if (!parsedRes.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); + return; + } + if (!parsedRes.parsed || typeof parsedRes.parsed !== "object" || Array.isArray(parsedRes.parsed)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "config.patch raw must be an object"), + ); + return; + } + const merged = applyMergePatch(snapshot.config, parsedRes.parsed); + const migrated = applyLegacyMigrations(merged); + const resolved = migrated.next ?? merged; + const validated = validateConfigObject(resolved); + if (!validated.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { + details: { issues: validated.issues }, + }), + ); + return; + } + await writeConfigFile(validated.config); + respond( + true, + { + ok: true, + path: CONFIG_PATH_CLAWDBOT, + config: validated.config, + }, + undefined, + ); + }, "config.apply": async ({ params, respond }) => { if (!validateConfigApplyParams(params)) { respond( @@ -141,6 +272,10 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } + const snapshot = await readConfigFileSnapshot(); + if (!requireConfigBaseHash(params, snapshot, respond)) { + return; + } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts new file mode 100644 index 000000000..5f1cfe7fc --- /dev/null +++ b/src/gateway/server.config-patch.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; + +import { connectOk, onceMessage, startServerWithClient } from "./test-helpers.js"; + +describe("gateway config.patch", () => { + it("merges patches without clobbering unrelated config", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const setId = "req-set"; + ws.send( + JSON.stringify({ + type: "req", + id: setId, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "local" }, + channels: { telegram: { botToken: "token-1" } }, + }), + }, + }), + ); + const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId); + expect(setRes.ok).toBe(true); + + const getId = "req-get"; + ws.send( + JSON.stringify({ + type: "req", + id: getId, + method: "config.get", + params: {}, + }), + ); + const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string } }>( + ws, + (o) => o.type === "res" && o.id === getId, + ); + expect(getRes.ok).toBe(true); + const baseHash = getRes.payload?.hash; + expect(typeof baseHash).toBe("string"); + + const patchId = "req-patch"; + ws.send( + JSON.stringify({ + type: "req", + id: patchId, + method: "config.patch", + params: { + raw: JSON.stringify({ + channels: { + telegram: { + groups: { + "*": { requireMention: false }, + }, + }, + }, + }), + baseHash, + }, + }), + ); + const patchRes = await onceMessage<{ ok: boolean }>( + ws, + (o) => o.type === "res" && o.id === patchId, + ); + expect(patchRes.ok).toBe(true); + + const get2Id = "req-get-2"; + ws.send( + JSON.stringify({ + type: "req", + id: get2Id, + method: "config.get", + params: {}, + }), + ); + const get2Res = await onceMessage<{ + ok: boolean; + payload?: { config?: { gateway?: { mode?: string }; channels?: { telegram?: { botToken?: string } } } }; + }>(ws, (o) => o.type === "res" && o.id === get2Id); + expect(get2Res.ok).toBe(true); + expect(get2Res.payload?.config?.gateway?.mode).toBe("local"); + expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1"); + + ws.close(); + await server.close(); + }); + + it("requires base hash when config exists", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const setId = "req-set-2"; + ws.send( + JSON.stringify({ + type: "req", + id: setId, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "local" }, + }), + }, + }), + ); + const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId); + expect(setRes.ok).toBe(true); + + const patchId = "req-patch-2"; + ws.send( + JSON.stringify({ + type: "req", + id: patchId, + method: "config.patch", + params: { + raw: JSON.stringify({ gateway: { mode: "remote" } }), + }, + }), + ); + const patchRes = await onceMessage<{ ok: boolean; error?: { message?: string } }>( + ws, + (o) => o.type === "res" && o.id === patchId, + ); + expect(patchRes.ok).toBe(false); + expect(patchRes.error?.message).toContain("base hash"); + + ws.close(); + await server.close(); + }); + + it("requires base hash for config.set when config exists", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const setId = "req-set-3"; + ws.send( + JSON.stringify({ + type: "req", + id: setId, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "local" }, + }), + }, + }), + ); + const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId); + expect(setRes.ok).toBe(true); + + const set2Id = "req-set-4"; + ws.send( + JSON.stringify({ + type: "req", + id: set2Id, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "remote" }, + }), + }, + }), + ); + const set2Res = await onceMessage<{ ok: boolean; error?: { message?: string } }>( + ws, + (o) => o.type === "res" && o.id === set2Id, + ); + expect(set2Res.ok).toBe(false); + expect(set2Res.error?.message).toContain("base hash"); + + ws.close(); + await server.close(); + }); +}); diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 58de635c0..a4b223014 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -133,10 +133,12 @@ describe("applyConfigSnapshot", () => { const state = createState(); applyConfigSnapshot(state, { config: { - telegram: {}, - discord: {}, - signal: {}, - imessage: {}, + channels: { + telegram: {}, + discord: {}, + signal: {}, + imessage: {}, + }, }, valid: true, issues: [], @@ -171,7 +173,7 @@ describe("updateConfigFormValue", () => { it("seeds from snapshot when form is null", () => { const state = createState(); state.configSnapshot = { - config: { telegram: { botToken: "t" }, gateway: { mode: "local" } }, + config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } }, valid: true, issues: [], raw: "{}", @@ -181,7 +183,7 @@ describe("updateConfigFormValue", () => { expect(state.configFormDirty).toBe(true); expect(state.configForm).toEqual({ - telegram: { botToken: "t" }, + channels: { telegram: { botToken: "t" } }, gateway: { mode: "local", port: 18789 }, }); }); @@ -212,11 +214,15 @@ describe("applyConfig", () => { state.applySessionKey = "agent:main:whatsapp:dm:+15555550123"; state.configFormMode = "raw"; state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n"; + state.configSnapshot = { + hash: "hash-123", + }; await applyConfig(state); expect(request).toHaveBeenCalledWith("config.apply", { raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n", + baseHash: "hash-123", sessionKey: "agent:main:whatsapp:dm:+15555550123", }); }); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index cc0cc76ad..1722695af 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -115,11 +115,12 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; const config = snapshot.config ?? {}; - const telegram = (config.telegram ?? {}) as Record; - const discord = (config.discord ?? {}) as Record; - const slack = (config.slack ?? {}) as Record; - const signal = (config.signal ?? {}) as Record; - const imessage = (config.imessage ?? {}) as Record; + const channels = (config.channels ?? {}) as Record; + const telegram = (channels.telegram ?? config.telegram ?? {}) as Record; + const discord = (channels.discord ?? config.discord ?? {}) as Record; + const slack = (channels.slack ?? config.slack ?? {}) as Record; + const signal = (channels.signal ?? config.signal ?? {}) as Record; + const imessage = (channels.imessage ?? config.imessage ?? {}) as Record; const toList = (value: unknown) => Array.isArray(value) ? value @@ -406,7 +407,12 @@ export async function saveConfig(state: ConfigState) { state.configFormMode === "form" && state.configForm ? serializeConfigForm(state.configForm) : state.configRaw; - await state.client.request("config.set", { raw }); + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.lastError = "Config hash missing; reload and retry."; + return; + } + await state.client.request("config.set", { raw, baseHash }); state.configFormDirty = false; await loadConfig(state); } catch (err) { @@ -425,8 +431,14 @@ export async function applyConfig(state: ConfigState) { state.configFormMode === "form" && state.configForm ? serializeConfigForm(state.configForm) : state.configRaw; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.lastError = "Config hash missing; reload and retry."; + return; + } await state.client.request("config.apply", { raw, + baseHash, sessionKey: state.applySessionKey, }); state.configFormDirty = false; diff --git a/ui/src/ui/controllers/connections.save-discord.ts b/ui/src/ui/controllers/connections.save-discord.ts index f00981698..36a9b17ff 100644 --- a/ui/src/ui/controllers/connections.save-discord.ts +++ b/ui/src/ui/controllers/connections.save-discord.ts @@ -13,70 +13,68 @@ export async function saveDiscordConfig(state: ConnectionsState) { state.discordSaving = true; state.discordConfigStatus = null; try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const discord = { ...(config.discord ?? {}) } as Record; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.discordConfigStatus = "Config hash missing; reload and retry."; + return; + } + const discord: Record = {}; const form = state.discordForm; if (form.enabled) { - delete discord.enabled; + discord.enabled = null; } else { discord.enabled = false; } if (!state.discordTokenLocked) { const token = form.token.trim(); - if (token) discord.token = token; - else delete discord.token; + discord.token = token || null; } const allowFrom = parseList(form.allowFrom); const groupChannels = parseList(form.groupChannels); - const dm = { ...(discord.dm ?? {}) } as Record; - if (form.dmEnabled) delete dm.enabled; - else dm.enabled = false; - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; - if (form.groupEnabled) dm.groupEnabled = true; - else delete dm.groupEnabled; - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) discord.dm = dm; - else delete discord.dm; + const dm: Record = { + enabled: form.dmEnabled ? null : false, + allowFrom: allowFrom.length > 0 ? allowFrom : null, + groupEnabled: form.groupEnabled ? true : null, + groupChannels: groupChannels.length > 0 ? groupChannels : null, + }; + discord.dm = dm; const mediaMaxMb = Number(form.mediaMaxMb); if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { discord.mediaMaxMb = mediaMaxMb; } else { - delete discord.mediaMaxMb; + discord.mediaMaxMb = null; } const historyLimitRaw = form.historyLimit.trim(); if (historyLimitRaw.length === 0) { - delete discord.historyLimit; + discord.historyLimit = null; } else { const historyLimit = Number(historyLimitRaw); if (Number.isFinite(historyLimit) && historyLimit >= 0) { discord.historyLimit = historyLimit; } else { - delete discord.historyLimit; + discord.historyLimit = null; } } const chunkLimitRaw = form.textChunkLimit.trim(); if (chunkLimitRaw.length === 0) { - delete discord.textChunkLimit; + discord.textChunkLimit = null; } else { const chunkLimit = Number(chunkLimitRaw); if (Number.isFinite(chunkLimit) && chunkLimit > 0) { discord.textChunkLimit = chunkLimit; } else { - delete discord.textChunkLimit; + discord.textChunkLimit = null; } } if (form.replyToMode === "off") { - delete discord.replyToMode; + discord.replyToMode = null; } else { discord.replyToMode = form.replyToMode; } @@ -114,7 +112,7 @@ export async function saveDiscordConfig(state: ConnectionsState) { guilds[key] = entry; }); if (Object.keys(guilds).length > 0) discord.guilds = guilds; - else delete discord.guilds; + else discord.guilds = null; const actions: Partial = {}; const applyAction = (key: keyof DiscordActionForm) => { @@ -139,36 +137,33 @@ export async function saveDiscordConfig(state: ConnectionsState) { if (Object.keys(actions).length > 0) { discord.actions = actions; } else { - delete discord.actions; + discord.actions = null; } const slash = { ...(discord.slashCommand ?? {}) } as Record; if (form.slashEnabled) { slash.enabled = true; } else { - delete slash.enabled; + slash.enabled = null; } if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; + else slash.name = null; if (form.slashSessionPrefix.trim()) slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; + else slash.sessionPrefix = null; if (form.slashEphemeral) { - delete slash.ephemeral; + slash.ephemeral = null; } else { slash.ephemeral = false; } - if (Object.keys(slash).length > 0) discord.slashCommand = slash; - else delete discord.slashCommand; + discord.slashCommand = Object.keys(slash).length > 0 ? slash : null; - if (Object.keys(discord).length > 0) { - config.discord = discord; - } else { - delete config.discord; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); + const raw = `${JSON.stringify( + { channels: { discord } }, + null, + 2, + ).trimEnd()}\n`; + await state.client.request("config.patch", { raw, baseHash }); state.discordConfigStatus = "Saved. Restart gateway if needed."; } catch (err) { state.discordConfigStatus = String(err); @@ -176,4 +171,3 @@ export async function saveDiscordConfig(state: ConnectionsState) { state.discordSaving = false; } } - diff --git a/ui/src/ui/controllers/connections.save-imessage.ts b/ui/src/ui/controllers/connections.save-imessage.ts index 70eed860b..a8dae50cf 100644 --- a/ui/src/ui/controllers/connections.save-imessage.ts +++ b/ui/src/ui/controllers/connections.save-imessage.ts @@ -7,57 +7,53 @@ export async function saveIMessageConfig(state: ConnectionsState) { state.imessageSaving = true; state.imessageConfigStatus = null; try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const imessage = { ...(config.imessage ?? {}) } as Record; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.imessageConfigStatus = "Config hash missing; reload and retry."; + return; + } + const imessage: Record = {}; const form = state.imessageForm; if (form.enabled) { - delete imessage.enabled; + imessage.enabled = null; } else { imessage.enabled = false; } const cliPath = form.cliPath.trim(); - if (cliPath) imessage.cliPath = cliPath; - else delete imessage.cliPath; + imessage.cliPath = cliPath || null; const dbPath = form.dbPath.trim(); - if (dbPath) imessage.dbPath = dbPath; - else delete imessage.dbPath; + imessage.dbPath = dbPath || null; if (form.service === "auto") { - delete imessage.service; + imessage.service = null; } else { imessage.service = form.service; } const region = form.region.trim(); - if (region) imessage.region = region; - else delete imessage.region; + imessage.region = region || null; const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) imessage.allowFrom = allowFrom; - else delete imessage.allowFrom; + imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null; - if (form.includeAttachments) imessage.includeAttachments = true; - else delete imessage.includeAttachments; + imessage.includeAttachments = form.includeAttachments ? true : null; const mediaMaxMb = Number(form.mediaMaxMb); if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { imessage.mediaMaxMb = mediaMaxMb; } else { - delete imessage.mediaMaxMb; + imessage.mediaMaxMb = null; } - if (Object.keys(imessage).length > 0) { - config.imessage = imessage; - } else { - delete config.imessage; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); + const raw = `${JSON.stringify( + { channels: { imessage } }, + null, + 2, + ).trimEnd()}\n`; + await state.client.request("config.patch", { raw, baseHash }); state.imessageConfigStatus = "Saved. Restart gateway if needed."; } catch (err) { state.imessageConfigStatus = String(err); @@ -65,4 +61,3 @@ export async function saveIMessageConfig(state: ConnectionsState) { state.imessageSaving = false; } } - diff --git a/ui/src/ui/controllers/connections.save-signal.ts b/ui/src/ui/controllers/connections.save-signal.ts index e07a71f99..57fccbe36 100644 --- a/ui/src/ui/controllers/connections.save-signal.ts +++ b/ui/src/ui/controllers/connections.save-signal.ts @@ -7,42 +7,41 @@ export async function saveSignalConfig(state: ConnectionsState) { state.signalSaving = true; state.signalConfigStatus = null; try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const signal = { ...(config.signal ?? {}) } as Record; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.signalConfigStatus = "Config hash missing; reload and retry."; + return; + } + const signal: Record = {}; const form = state.signalForm; if (form.enabled) { - delete signal.enabled; + signal.enabled = null; } else { signal.enabled = false; } const account = form.account.trim(); - if (account) signal.account = account; - else delete signal.account; + signal.account = account || null; const httpUrl = form.httpUrl.trim(); - if (httpUrl) signal.httpUrl = httpUrl; - else delete signal.httpUrl; + signal.httpUrl = httpUrl || null; const httpHost = form.httpHost.trim(); - if (httpHost) signal.httpHost = httpHost; - else delete signal.httpHost; + signal.httpHost = httpHost || null; const httpPort = Number(form.httpPort); if (Number.isFinite(httpPort) && httpPort > 0) { signal.httpPort = httpPort; } else { - delete signal.httpPort; + signal.httpPort = null; } const cliPath = form.cliPath.trim(); - if (cliPath) signal.cliPath = cliPath; - else delete signal.cliPath; + signal.cliPath = cliPath || null; if (form.autoStart) { - delete signal.autoStart; + signal.autoStart = null; } else { signal.autoStart = false; } @@ -50,35 +49,29 @@ export async function saveSignalConfig(state: ConnectionsState) { if (form.receiveMode === "on-start" || form.receiveMode === "manual") { signal.receiveMode = form.receiveMode; } else { - delete signal.receiveMode; + signal.receiveMode = null; } - if (form.ignoreAttachments) signal.ignoreAttachments = true; - else delete signal.ignoreAttachments; - if (form.ignoreStories) signal.ignoreStories = true; - else delete signal.ignoreStories; - if (form.sendReadReceipts) signal.sendReadReceipts = true; - else delete signal.sendReadReceipts; + signal.ignoreAttachments = form.ignoreAttachments ? true : null; + signal.ignoreStories = form.ignoreStories ? true : null; + signal.sendReadReceipts = form.sendReadReceipts ? true : null; const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) signal.allowFrom = allowFrom; - else delete signal.allowFrom; + signal.allowFrom = allowFrom.length > 0 ? allowFrom : null; const mediaMaxMb = Number(form.mediaMaxMb); if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { signal.mediaMaxMb = mediaMaxMb; } else { - delete signal.mediaMaxMb; + signal.mediaMaxMb = null; } - if (Object.keys(signal).length > 0) { - config.signal = signal; - } else { - delete config.signal; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); + const raw = `${JSON.stringify( + { channels: { signal } }, + null, + 2, + ).trimEnd()}\n`; + await state.client.request("config.patch", { raw, baseHash }); state.signalConfigStatus = "Saved. Restart gateway if needed."; } catch (err) { state.signalConfigStatus = String(err); @@ -86,4 +79,3 @@ export async function saveSignalConfig(state: ConnectionsState) { state.signalSaving = false; } } - diff --git a/ui/src/ui/controllers/connections.save-slack.ts b/ui/src/ui/controllers/connections.save-slack.ts index 19345505e..5e020119f 100644 --- a/ui/src/ui/controllers/connections.save-slack.ts +++ b/ui/src/ui/controllers/connections.save-slack.ts @@ -8,60 +8,58 @@ export async function saveSlackConfig(state: ConnectionsState) { state.slackSaving = true; state.slackConfigStatus = null; try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const slack = { ...(config.slack ?? {}) } as Record; + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.slackConfigStatus = "Config hash missing; reload and retry."; + return; + } + const slack: Record = {}; const form = state.slackForm; if (form.enabled) { - delete slack.enabled; + slack.enabled = null; } else { slack.enabled = false; } if (!state.slackTokenLocked) { const token = form.botToken.trim(); - if (token) slack.botToken = token; - else delete slack.botToken; + slack.botToken = token || null; } if (!state.slackAppTokenLocked) { const token = form.appToken.trim(); - if (token) slack.appToken = token; - else delete slack.appToken; + slack.appToken = token || null; } - const dm = { ...(slack.dm ?? {}) } as Record; + const dm: Record = {}; dm.enabled = form.dmEnabled; const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; + dm.allowFrom = allowFrom.length > 0 ? allowFrom : null; if (form.groupEnabled) { dm.groupEnabled = true; } else { - delete dm.groupEnabled; + dm.groupEnabled = null; } const groupChannels = parseList(form.groupChannels); - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) slack.dm = dm; - else delete slack.dm; + dm.groupChannels = groupChannels.length > 0 ? groupChannels : null; + slack.dm = dm; const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { slack.mediaMaxMb = mediaMaxMb; } else { - delete slack.mediaMaxMb; + slack.mediaMaxMb = null; } const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { slack.textChunkLimit = textChunkLimit; } else { - delete slack.textChunkLimit; + slack.textChunkLimit = null; } if (form.reactionNotifications === "own") { - delete slack.reactionNotifications; + slack.reactionNotifications = null; } else { slack.reactionNotifications = form.reactionNotifications; } @@ -69,27 +67,26 @@ export async function saveSlackConfig(state: ConnectionsState) { if (reactionAllowlist.length > 0) { slack.reactionAllowlist = reactionAllowlist; } else { - delete slack.reactionAllowlist; + slack.reactionAllowlist = null; } - const slash = { ...(slack.slashCommand ?? {}) } as Record; + const slash: Record = {}; if (form.slashEnabled) { slash.enabled = true; } else { - delete slash.enabled; + slash.enabled = null; } if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; + else slash.name = null; if (form.slashSessionPrefix.trim()) slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; + else slash.sessionPrefix = null; if (form.slashEphemeral) { - delete slash.ephemeral; + slash.ephemeral = null; } else { slash.ephemeral = false; } - if (Object.keys(slash).length > 0) slack.slashCommand = slash; - else delete slack.slashCommand; + slack.slashCommand = slash; const actions: Partial = {}; const applyAction = (key: keyof SlackActionForm) => { @@ -104,7 +101,7 @@ export async function saveSlackConfig(state: ConnectionsState) { if (Object.keys(actions).length > 0) { slack.actions = actions; } else { - delete slack.actions; + slack.actions = null; } const channels = form.channels @@ -123,17 +120,15 @@ export async function saveSlackConfig(state: ConnectionsState) { if (channels.length > 0) { slack.channels = Object.fromEntries(channels); } else { - delete slack.channels; + slack.channels = null; } - if (Object.keys(slack).length > 0) { - config.slack = slack; - } else { - delete config.slack; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); + const raw = `${JSON.stringify( + { channels: { slack } }, + null, + 2, + ).trimEnd()}\n`; + await state.client.request("config.patch", { raw, baseHash }); state.slackConfigStatus = "Saved. Restart gateway if needed."; } catch (err) { state.slackConfigStatus = String(err); diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 50e2472b4..467f5062f 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -165,56 +165,53 @@ export async function saveTelegramConfig(state: ConnectionsState) { } } const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const telegram = { ...(config.telegram ?? {}) } as Record; + const channels = (base.channels ?? {}) as Record; + const telegram = { + ...(channels.telegram ?? base.telegram ?? {}), + } as Record; if (!state.telegramTokenLocked) { const token = state.telegramForm.token.trim(); - if (token) telegram.botToken = token; - else delete telegram.botToken; + telegram.botToken = token || null; } - const groups = - telegram.groups && typeof telegram.groups === "object" - ? ({ ...(telegram.groups as Record) } as Record< - string, - unknown - >) - : {}; + const groupsPatch: Record = {}; if (state.telegramForm.groupsWildcardEnabled) { + const existingGroups = telegram.groups as Record | undefined; const defaultGroup = - groups["*"] && typeof groups["*"] === "object" - ? ({ ...(groups["*"] as Record) } as Record< + existingGroups?.["*"] && typeof existingGroups["*"] === "object" + ? ({ ...(existingGroups["*"] as Record) } as Record< string, unknown >) : {}; defaultGroup.requireMention = state.telegramForm.requireMention; - groups["*"] = defaultGroup; - telegram.groups = groups; - } else if (groups["*"]) { - delete groups["*"]; - if (Object.keys(groups).length > 0) telegram.groups = groups; - else delete telegram.groups; + groupsPatch["*"] = defaultGroup; + } else { + groupsPatch["*"] = null; } - delete telegram.requireMention; + telegram.groups = groupsPatch; + telegram.requireMention = null; const allowFrom = parseList(state.telegramForm.allowFrom); - if (allowFrom.length > 0) telegram.allowFrom = allowFrom; - else delete telegram.allowFrom; + telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null; const proxy = state.telegramForm.proxy.trim(); - if (proxy) telegram.proxy = proxy; - else delete telegram.proxy; + telegram.proxy = proxy || null; const webhookUrl = state.telegramForm.webhookUrl.trim(); - if (webhookUrl) telegram.webhookUrl = webhookUrl; - else delete telegram.webhookUrl; + telegram.webhookUrl = webhookUrl || null; const webhookSecret = state.telegramForm.webhookSecret.trim(); - if (webhookSecret) telegram.webhookSecret = webhookSecret; - else delete telegram.webhookSecret; + telegram.webhookSecret = webhookSecret || null; const webhookPath = state.telegramForm.webhookPath.trim(); - if (webhookPath) telegram.webhookPath = webhookPath; - else delete telegram.webhookPath; + telegram.webhookPath = webhookPath || null; - config.telegram = telegram; - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); + const baseHash = state.configSnapshot?.hash; + if (!baseHash) { + state.telegramConfigStatus = "Config hash missing; reload and retry."; + return; + } + const raw = `${JSON.stringify( + { channels: { telegram } }, + null, + 2, + ).trimEnd()}\n`; + await state.client.request("config.patch", { raw, baseHash }); state.telegramConfigStatus = "Saved. Restart gateway if needed."; } catch (err) { state.telegramConfigStatus = String(err); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 02e0431a7..3f1892594 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -214,6 +214,7 @@ export type ConfigSnapshot = { path?: string | null; exists?: boolean | null; raw?: string | null; + hash?: string | null; parsed?: unknown; valid?: boolean | null; config?: Record | null;