fix: prevent config clobbering

This commit is contained in:
Peter Steinberger
2026-01-15 04:05:01 +00:00
parent bd467ff765
commit 31d3aef8d6
29 changed files with 975 additions and 380 deletions

View File

@@ -22,6 +22,7 @@
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - 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”). - Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. - 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 ## 2026.1.14

View File

@@ -19,6 +19,7 @@ enum ConfigStore {
} }
private static let overrideStore = OverrideStore() private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool { private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides let overrides = await self.overrideStore.overrides
@@ -75,6 +76,7 @@ enum ConfigStore {
method: .configGet, method: .configGet,
params: nil, params: nil,
timeoutMs: 8000) timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:] return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch { } catch {
return nil return nil
@@ -83,17 +85,24 @@ enum ConfigStore {
@MainActor @MainActor
private static func saveToGateway(_ root: [String: Any]) async throws { 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]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [ throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.", 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( _ = try await GatewayConnection.shared.requestRaw(
method: .configSet, method: .configSet,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
_ = await self.loadFromGateway()
} }
#if DEBUG #if DEBUG

View File

@@ -12,6 +12,7 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json." ? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil : nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true self.configLoaded = true
self.applyUIConfig(snap) self.applyUIConfig(snap)
@@ -34,10 +35,22 @@ extension ConnectionsStore {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam 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) { private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = snap.config?["telegram"]?.dictionaryValue let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? "" 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.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? "" self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? "" self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -46,7 +59,7 @@ extension ConnectionsStore {
} }
private func applyDiscordConfig(_ snap: ConfigSnapshot) { 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.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? "" self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -122,7 +135,7 @@ extension ConnectionsStore {
} }
private func applySignalConfig(_ snap: ConfigSnapshot) { 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.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? "" self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? "" self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -139,7 +152,7 @@ extension ConnectionsStore {
} }
private func applyIMessageConfig(_ snap: ConfigSnapshot) { 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.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? "" self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? "" self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -150,6 +163,15 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"]) 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 { func saveTelegramConfig() async {
guard !self.isSavingConfig else { return } guard !self.isSavingConfig else { return }
self.isSavingConfig = true self.isSavingConfig = true
@@ -158,30 +180,24 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:] var telegram: [String: Any] = [:]
let token = self.trimmed(self.telegramToken) if !self.isTelegramTokenLocked {
if token.isEmpty { self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
} }
telegram["requireMention"] = NSNull()
telegram["requireMention"] = self.telegramRequireMention telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom) let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty { self.setPatchList(&telegram, key: "allowFrom", values: allow)
telegram.removeValue(forKey: "allowFrom") self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
} else { self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
telegram["allowFrom"] = allow self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
} self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy) await self.persistChannelPatch("telegram", payload: telegram)
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()
} }
func saveDiscordConfig() async { func saveDiscordConfig() async {
@@ -192,9 +208,9 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
let discord = self.buildDiscordConfig() let base = self.channelConfigRoot(for: "discord")
self.setSection("discord", payload: discord) let discord = self.buildDiscordPatch(base: base)
await self.persistConfig() await self.persistChannelPatch("discord", payload: discord)
} }
func saveSignalConfig() async { func saveSignalConfig() async {
@@ -205,42 +221,23 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:] var signal: [String: Any] = [:]
if self.signalEnabled { self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
signal.removeValue(forKey: "enabled") self.setPatchString(&signal, key: "account", value: self.signalAccount)
} else { self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
signal["enabled"] = false 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.setOptionalString(&signal, key: "account", value: self.signalAccount) self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl) self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost) self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort) self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath) self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
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)
let allow = self.splitCsv(self.signalAllowFrom) let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty { self.setPatchList(&signal, key: "allowFrom", values: allow)
signal.removeValue(forKey: "allowFrom") self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
} else {
signal["allowFrom"] = allow
}
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb) await self.persistChannelPatch("signal", payload: signal)
self.setSection("signal", payload: signal)
await self.persistConfig()
} }
func saveIMessageConfig() async { func saveIMessageConfig() async {
@@ -251,147 +248,168 @@ extension ConnectionsStore {
await self.loadConfig() await self.loadConfig()
} }
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:] var imessage: [String: Any] = [:]
if self.imessageEnabled { self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
imessage.removeValue(forKey: "enabled") self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
} else { self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService) let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" { if service.isEmpty || service == "auto" {
imessage.removeValue(forKey: "service") imessage["service"] = NSNull()
} else { } else {
imessage["service"] = service 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) let allow = self.splitCsv(self.imessageAllowFrom)
if allow.isEmpty { self.setPatchList(&imessage, key: "allowFrom", values: allow)
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments) self.setPatchBool(
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb) &imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setSection("imessage", payload: imessage) await self.persistChannelPatch("imessage", payload: imessage)
await self.persistConfig()
} }
private func buildDiscordConfig() -> [String: Any] { private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] var discord: [String: Any] = [:]
if self.discordEnabled { self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
discord.removeValue(forKey: "enabled") if !self.isDiscordTokenLocked {
} else { self.setPatchString(&discord, key: "token", value: self.discordToken)
discord["enabled"] = false
} }
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 discord["dm"] = dm
} else { } else {
discord.removeValue(forKey: "dm") discord["dm"] = NSNull()
} }
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb) self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true) self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false) self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode) let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" { if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord.removeValue(forKey: "replyToMode") discord["replyToMode"] = NSNull()
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
} else { } 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 discord["guilds"] = guilds
} else { } 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 discord["actions"] = actions
} else { } 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 discord["slashCommand"] = slash
} else { } else {
discord.removeValue(forKey: "slashCommand") discord["slashCommand"] = NSNull()
} }
return discord return discord
} }
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? { private func buildDiscordDmPatch() -> [String: Any]? {
var dm = base var dm: [String: Any] = [:]
if self.discordDmEnabled { self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
let allow = self.splitCsv(self.discordAllowFrom) let allow = self.splitCsv(self.discordAllowFrom)
if allow.isEmpty { self.setPatchList(&dm, key: "allowFrom", values: allow)
dm.removeValue(forKey: "allowFrom") self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
let groupChannels = self.splitCsv(self.discordGroupChannels) let groupChannels = self.splitCsv(self.discordGroupChannels)
if groupChannels.isEmpty { self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
return dm.isEmpty ? nil : dm return dm.isEmpty ? nil : dm
} }
private func buildDiscordGuildsConfig() -> [String: Any]? { private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in if self.discordGuilds.isEmpty {
let key = self.trimmed(entry.key) return NSNull()
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
} }
return guilds.isEmpty ? nil : guilds var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
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]? { private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var actions = base 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<String>()
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: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true) self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true) self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -410,52 +428,44 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions return actions.isEmpty ? nil : actions
} }
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? { private func buildDiscordSlashPatch() -> [String: Any]? {
var slash = base var slash: [String: Any] = [:]
if self.discordSlashEnabled { self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
slash["enabled"] = true self.setPatchString(&slash, key: "name", value: self.discordSlashName)
} else { self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
slash.removeValue(forKey: "enabled") self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
}
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
}
return slash.isEmpty ? nil : slash return slash.isEmpty ? nil : slash
} }
private func persistConfig() async { private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do { do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data( let data = try JSONSerialization.data(
withJSONObject: self.configRoot, withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys]) options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config." self.configStatus = "Failed to encode config."
return return
} }
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw( _ = try await GatewayConnection.shared.requestRaw(
method: .configSet, method: .configPatch,
params: params, params: params,
timeoutMs: 10000) timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json." self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true) await self.refresh(probe: true)
} catch { } catch {
self.configStatus = error.localizedDescription 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 { private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" } guard let values else { return "" }
let strings = values.compactMap { entry -> String? in let strings = values.compactMap { entry -> String? in
@@ -492,25 +502,29 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines) 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) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
} else { } else {
target[key] = trimmed 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) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
} else if let number = Double(trimmed) { return
}
if let number = Double(trimmed) {
target[key] = number target[key] = number
} else {
target[key] = NSNull()
} }
} }
private func setOptionalInt( private func setPatchInt(
_ target: inout [String: Any], _ target: inout [String: Any],
key: String, key: String,
value: String, value: String,
@@ -518,26 +532,39 @@ extension ConnectionsStore {
{ {
let trimmed = self.trimmed(value) let trimmed = self.trimmed(value)
if trimmed.isEmpty { if trimmed.isEmpty {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
guard let number = Int(trimmed) else { guard let number = Int(trimmed) else {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
let isValid = allowZero ? number >= 0 : number > 0 let isValid = allowZero ? number >= 0 : number > 0
guard isValid else { guard isValid else {
target.removeValue(forKey: key) target[key] = NSNull()
return return
} }
target[key] = number target[key] = number
} }
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) { private func setPatchBool(
if value { _ target: inout [String: Any],
target[key] = true key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else { } 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) defaultValue: Bool)
{ {
if value == defaultValue { if value == defaultValue {
actions.removeValue(forKey: key) actions[key] = NSNull()
} else { } else {
actions[key] = value actions[key] = value
} }

View File

@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
let path: String? let path: String?
let exists: Bool? let exists: Bool?
let raw: String? let raw: String?
let hash: String?
let parsed: AnyCodable? let parsed: AnyCodable?
let valid: Bool? let valid: Bool?
let config: [String: AnyCodable]? let config: [String: AnyCodable]?
@@ -307,6 +308,7 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>? var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:] var configRoot: [String: Any] = [:]
var configLoaded = false var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview self.isPreview = isPreview

View File

@@ -55,6 +55,7 @@ actor GatewayConnection {
case channelsStatus = "channels.status" case channelsStatus = "channels.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start" case wizardStart = "wizard.start"
case wizardNext = "wizard.next" case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel" case wizardCancel = "wizard.cancel"

View File

@@ -579,8 +579,12 @@ Subcommands:
Common RPCs: Common RPCs:
- `config.apply` (validate + write config + restart + wake) - `config.apply` (validate + write config + restart + wake)
- `config.patch` (merge a partial update without clobbering unrelated keys)
- `update.run` (run update + restart + wake) - `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 ## Models
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy. See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.

View File

@@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway
Params: Params:
- `raw` (string) — JSON5 payload for the entire config - `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 - `sessionKey` (optional) — last active session key for the wake-up ping
- `restartDelayMs` (optional) — delay before restart (default 2000) - `restartDelayMs` (optional) — delay before restart (default 2000)
Example (via `gateway call`): Example (via `gateway call`):
```bash ```bash
clawdbot gateway call config.get --params '{}' # capture payload.hash
clawdbot gateway call config.apply --params '{ clawdbot gateway call config.apply --params '{
"raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n", "raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
"baseHash": "<hash-from-config.get>",
"sessionKey": "agent:main:whatsapp:dm:+15555550123", "sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000 "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": "<hash-from-config.get>"
}'
```
## Minimal config (recommended starting point) ## Minimal config (recommended starting point)
```json5 ```json5

View File

@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
## What it can do (today) ## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- Stream tool calls + live tool output cards in Chat (agent events) - 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`) - Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - 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`) - Nodes: list + caps (`node.list`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - 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 - 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`) - 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`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`)

View File

@@ -6,7 +6,12 @@ import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.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", () => { describe("gateway tool", () => {
@@ -71,11 +76,13 @@ describe("gateway tool", () => {
raw, raw,
}); });
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(callGatewayTool).toHaveBeenCalledWith( expect(callGatewayTool).toHaveBeenCalledWith(
"config.apply", "config.apply",
expect.any(Object), expect.any(Object),
expect.objectContaining({ expect.objectContaining({
raw: raw.trim(), raw: raw.trim(),
baseHash: "hash-1",
sessionKey: "agent:main:whatsapp:dm:+15555550123", sessionKey: "agent:main:whatsapp:dm:+15555550123",
}), }),
); );

View File

@@ -1,3 +1,5 @@
import crypto from "node:crypto";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
@@ -33,6 +35,7 @@ const GatewayToolSchema = Type.Object({
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
// config.apply // config.apply
raw: Type.Optional(Type.String()), raw: Type.Optional(Type.String()),
baseHash: Type.Optional(Type.String()),
// config.apply, update.run // config.apply, update.run
sessionKey: Type.Optional(Type.String()), sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()), note: Type.Optional(Type.String()),
@@ -125,6 +128,24 @@ export function createGatewayTool(opts?: {
} }
if (action === "config.apply") { if (action === "config.apply") {
const raw = readStringParam(params, "raw", { required: true }); 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 = const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim() typeof params.sessionKey === "string" && params.sessionKey.trim()
? params.sessionKey.trim() ? params.sessionKey.trim()
@@ -137,6 +158,7 @@ export function createGatewayTool(opts?: {
: undefined; : undefined;
const result = await callGatewayTool("config.apply", gatewayOpts, { const result = await callGatewayTool("config.apply", gatewayOpts, {
raw, raw,
baseHash,
sessionKey, sessionKey,
note, note,
restartDelayMs, restartDelayMs,

View File

@@ -51,6 +51,10 @@ const SHELL_ENV_EXPECTED_KEYS = [
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string }; 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 = { export type ConfigIoDeps = {
fs?: typeof fs; fs?: typeof fs;
json5?: typeof JSON5; json5?: typeof JSON5;
@@ -263,6 +267,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> { async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const exists = deps.fs.existsSync(configPath); const exists = deps.fs.existsSync(configPath);
if (!exists) { if (!exists) {
const hash = hashConfigRaw(null);
const config = applyTalkApiKey( const config = applyTalkApiKey(
applyModelDefaults( applyModelDefaults(
applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))), applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))),
@@ -276,6 +281,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: {}, parsed: {},
valid: true, valid: true,
config, config,
hash,
issues: [], issues: [],
legacyIssues, legacyIssues,
}; };
@@ -283,6 +289,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
try { try {
const raw = deps.fs.readFileSync(configPath, "utf-8"); const raw = deps.fs.readFileSync(configPath, "utf-8");
const hash = hashConfigRaw(raw);
const parsedRes = parseConfigJson5(raw, deps.json5); const parsedRes = parseConfigJson5(raw, deps.json5);
if (!parsedRes.ok) { if (!parsedRes.ok) {
return { return {
@@ -292,6 +299,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: {}, parsed: {},
valid: false, valid: false,
config: {}, config: {},
hash,
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
legacyIssues: [], legacyIssues: [],
}; };
@@ -316,6 +324,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: parsedRes.parsed, parsed: parsedRes.parsed,
valid: false, valid: false,
config: {}, config: {},
hash,
issues: [{ path: "", message }], issues: [{ path: "", message }],
legacyIssues: [], legacyIssues: [],
}; };
@@ -338,6 +347,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: parsedRes.parsed, parsed: parsedRes.parsed,
valid: false, valid: false,
config: resolvedConfig, config: resolvedConfig,
hash,
issues: validated.issues, issues: validated.issues,
legacyIssues, legacyIssues,
}; };
@@ -363,6 +373,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
), ),
), ),
), ),
hash,
issues: [], issues: [],
legacyIssues, legacyIssues,
}; };
@@ -374,6 +385,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parsed: {}, parsed: {},
valid: false, valid: false,
config: {}, config: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }], issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [], legacyIssues: [],
}; };

28
src/config/merge-patch.ts Normal file
View File

@@ -0,0 +1,28 @@
type PlainObject = Record<string, unknown>;
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;
}

View File

@@ -93,6 +93,7 @@ export type ConfigFileSnapshot = {
parsed: unknown; parsed: unknown;
valid: boolean; valid: boolean;
config: ClawdbotConfig; config: ClawdbotConfig;
hash?: string;
issues: ConfigValidationIssue[]; issues: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[]; legacyIssues: LegacyConfigIssue[];
}; };

View File

@@ -27,6 +27,8 @@ import {
ConfigApplyParamsSchema, ConfigApplyParamsSchema,
type ConfigGetParams, type ConfigGetParams,
ConfigGetParamsSchema, ConfigGetParamsSchema,
type ConfigPatchParams,
ConfigPatchParamsSchema,
type ConfigSchemaParams, type ConfigSchemaParams,
ConfigSchemaParamsSchema, ConfigSchemaParamsSchema,
type ConfigSchemaResponse, type ConfigSchemaResponse,
@@ -201,6 +203,7 @@ export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema); export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema); export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema); export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
export const validateConfigPatchParams = ajv.compile<ConfigPatchParams>(ConfigPatchParamsSchema);
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(ConfigSchemaParamsSchema); export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(ConfigSchemaParamsSchema);
export const validateWizardStartParams = ajv.compile<WizardStartParams>(WizardStartParamsSchema); export const validateWizardStartParams = ajv.compile<WizardStartParams>(WizardStartParamsSchema);
export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNextParamsSchema); export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNextParamsSchema);
@@ -272,6 +275,7 @@ export {
ConfigGetParamsSchema, ConfigGetParamsSchema,
ConfigSetParamsSchema, ConfigSetParamsSchema,
ConfigApplyParamsSchema, ConfigApplyParamsSchema,
ConfigPatchParamsSchema,
ConfigSchemaParamsSchema, ConfigSchemaParamsSchema,
ConfigSchemaResponseSchema, ConfigSchemaResponseSchema,
WizardStartParamsSchema, WizardStartParamsSchema,
@@ -338,6 +342,7 @@ export type {
ConfigGetParams, ConfigGetParams,
ConfigSetParams, ConfigSetParams,
ConfigApplyParams, ConfigApplyParams,
ConfigPatchParams,
ConfigSchemaParams, ConfigSchemaParams,
ConfigSchemaResponse, ConfigSchemaResponse,
WizardStartParams, WizardStartParams,

View File

@@ -7,6 +7,7 @@ export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: fal
export const ConfigSetParamsSchema = Type.Object( export const ConfigSetParamsSchema = Type.Object(
{ {
raw: NonEmptyString, raw: NonEmptyString,
baseHash: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@@ -14,6 +15,7 @@ export const ConfigSetParamsSchema = Type.Object(
export const ConfigApplyParamsSchema = Type.Object( export const ConfigApplyParamsSchema = Type.Object(
{ {
raw: NonEmptyString, raw: NonEmptyString,
baseHash: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(Type.String()), sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()), note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })), restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -21,6 +23,14 @@ export const ConfigApplyParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const ConfigPatchParamsSchema = Type.Object(
{
raw: NonEmptyString,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false }); export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false });
export const UpdateRunParamsSchema = Type.Object( export const UpdateRunParamsSchema = Type.Object(

View File

@@ -30,6 +30,7 @@ import {
import { import {
ConfigApplyParamsSchema, ConfigApplyParamsSchema,
ConfigGetParamsSchema, ConfigGetParamsSchema,
ConfigPatchParamsSchema,
ConfigSchemaParamsSchema, ConfigSchemaParamsSchema,
ConfigSchemaResponseSchema, ConfigSchemaResponseSchema,
ConfigSetParamsSchema, ConfigSetParamsSchema,
@@ -131,6 +132,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ConfigGetParams: ConfigGetParamsSchema, ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema, ConfigSetParams: ConfigSetParamsSchema,
ConfigApplyParams: ConfigApplyParamsSchema, ConfigApplyParams: ConfigApplyParamsSchema,
ConfigPatchParams: ConfigPatchParamsSchema,
ConfigSchemaParams: ConfigSchemaParamsSchema, ConfigSchemaParams: ConfigSchemaParamsSchema,
ConfigSchemaResponse: ConfigSchemaResponseSchema, ConfigSchemaResponse: ConfigSchemaResponseSchema,
WizardStartParams: WizardStartParamsSchema, WizardStartParams: WizardStartParamsSchema,

View File

@@ -28,6 +28,7 @@ import type {
import type { import type {
ConfigApplyParamsSchema, ConfigApplyParamsSchema,
ConfigGetParamsSchema, ConfigGetParamsSchema,
ConfigPatchParamsSchema,
ConfigSchemaParamsSchema, ConfigSchemaParamsSchema,
ConfigSchemaResponseSchema, ConfigSchemaResponseSchema,
ConfigSetParamsSchema, ConfigSetParamsSchema,
@@ -124,6 +125,7 @@ export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>; export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>; export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>; export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
export type ConfigPatchParams = Static<typeof ConfigPatchParamsSchema>;
export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>; export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>;
export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>; export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>;
export type WizardStartParams = Static<typeof WizardStartParamsSchema>; export type WizardStartParams = Static<typeof WizardStartParamsSchema>;

View File

@@ -7,17 +7,63 @@ import {
validateConfigObject, validateConfigObject,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { applyLegacyMigrations } from "../config/legacy.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { buildConfigSchema } from "../config/schema.js"; import { buildConfigSchema } from "../config/schema.js";
import { loadClawdbotPlugins } from "../plugins/loader.js"; import { loadClawdbotPlugins } from "../plugins/loader.js";
import { import {
ErrorCodes, ErrorCodes,
formatValidationErrors, formatValidationErrors,
validateConfigGetParams, validateConfigGetParams,
validateConfigPatchParams,
validateConfigSchemaParams, validateConfigSchemaParams,
validateConfigSetParams, validateConfigSetParams,
} from "./protocol/index.js"; } from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.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<ReturnType<typeof readConfigFileSnapshot>>,
): { 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 ( export const handleConfigBridgeMethods: BridgeMethodHandler = async (
_ctx, _ctx,
_nodeId, _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; const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") { if (typeof rawValue !== "string") {
return { 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: default:
return null; return null;
} }

View File

@@ -10,6 +10,7 @@ const BASE_METHODS = [
"config.get", "config.get",
"config.set", "config.set",
"config.apply", "config.apply",
"config.patch",
"config.schema", "config.schema",
"wizard.start", "wizard.start",
"wizard.next", "wizard.next",

View File

@@ -7,6 +7,8 @@ import {
validateConfigObject, validateConfigObject,
writeConfigFile, writeConfigFile,
} from "../../config/config.js"; } from "../../config/config.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { buildConfigSchema } from "../../config/schema.js"; import { buildConfigSchema } from "../../config/schema.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { import {
@@ -21,11 +23,62 @@ import {
formatValidationErrors, formatValidationErrors,
validateConfigApplyParams, validateConfigApplyParams,
validateConfigGetParams, validateConfigGetParams,
validateConfigPatchParams,
validateConfigSchemaParams, validateConfigSchemaParams,
validateConfigSetParams, validateConfigSetParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.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<ReturnType<typeof readConfigFileSnapshot>>,
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 = { export const configHandlers: GatewayRequestHandlers = {
"config.get": async ({ params, respond }) => { "config.get": async ({ params, respond }) => {
if (!validateConfigGetParams(params)) { if (!validateConfigGetParams(params)) {
@@ -93,6 +146,10 @@ export const configHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const snapshot = await readConfigFileSnapshot();
if (!requireConfigBaseHash(params, snapshot, respond)) {
return;
}
const rawValue = (params as { raw?: unknown }).raw; const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") { if (typeof rawValue !== "string") {
respond( respond(
@@ -129,6 +186,80 @@ export const configHandlers: GatewayRequestHandlers = {
undefined, 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 }) => { "config.apply": async ({ params, respond }) => {
if (!validateConfigApplyParams(params)) { if (!validateConfigApplyParams(params)) {
respond( respond(
@@ -141,6 +272,10 @@ export const configHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const snapshot = await readConfigFileSnapshot();
if (!requireConfigBaseHash(params, snapshot, respond)) {
return;
}
const rawValue = (params as { raw?: unknown }).raw; const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") { if (typeof rawValue !== "string") {
respond( respond(

View File

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

View File

@@ -133,10 +133,12 @@ describe("applyConfigSnapshot", () => {
const state = createState(); const state = createState();
applyConfigSnapshot(state, { applyConfigSnapshot(state, {
config: { config: {
telegram: {}, channels: {
discord: {}, telegram: {},
signal: {}, discord: {},
imessage: {}, signal: {},
imessage: {},
},
}, },
valid: true, valid: true,
issues: [], issues: [],
@@ -171,7 +173,7 @@ describe("updateConfigFormValue", () => {
it("seeds from snapshot when form is null", () => { it("seeds from snapshot when form is null", () => {
const state = createState(); const state = createState();
state.configSnapshot = { state.configSnapshot = {
config: { telegram: { botToken: "t" }, gateway: { mode: "local" } }, config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } },
valid: true, valid: true,
issues: [], issues: [],
raw: "{}", raw: "{}",
@@ -181,7 +183,7 @@ describe("updateConfigFormValue", () => {
expect(state.configFormDirty).toBe(true); expect(state.configFormDirty).toBe(true);
expect(state.configForm).toEqual({ expect(state.configForm).toEqual({
telegram: { botToken: "t" }, channels: { telegram: { botToken: "t" } },
gateway: { mode: "local", port: 18789 }, gateway: { mode: "local", port: 18789 },
}); });
}); });
@@ -212,11 +214,15 @@ describe("applyConfig", () => {
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123"; state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
state.configFormMode = "raw"; state.configFormMode = "raw";
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n"; state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
state.configSnapshot = {
hash: "hash-123",
};
await applyConfig(state); await applyConfig(state);
expect(request).toHaveBeenCalledWith("config.apply", { expect(request).toHaveBeenCalledWith("config.apply", {
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n", raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
baseHash: "hash-123",
sessionKey: "agent:main:whatsapp:dm:+15555550123", sessionKey: "agent:main:whatsapp:dm:+15555550123",
}); });
}); });

View File

@@ -115,11 +115,12 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : []; state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
const config = snapshot.config ?? {}; const config = snapshot.config ?? {};
const telegram = (config.telegram ?? {}) as Record<string, unknown>; const channels = (config.channels ?? {}) as Record<string, unknown>;
const discord = (config.discord ?? {}) as Record<string, unknown>; const telegram = (channels.telegram ?? config.telegram ?? {}) as Record<string, unknown>;
const slack = (config.slack ?? {}) as Record<string, unknown>; const discord = (channels.discord ?? config.discord ?? {}) as Record<string, unknown>;
const signal = (config.signal ?? {}) as Record<string, unknown>; const slack = (channels.slack ?? config.slack ?? {}) as Record<string, unknown>;
const imessage = (config.imessage ?? {}) as Record<string, unknown>; const signal = (channels.signal ?? config.signal ?? {}) as Record<string, unknown>;
const imessage = (channels.imessage ?? config.imessage ?? {}) as Record<string, unknown>;
const toList = (value: unknown) => const toList = (value: unknown) =>
Array.isArray(value) Array.isArray(value)
? value ? value
@@ -406,7 +407,12 @@ export async function saveConfig(state: ConfigState) {
state.configFormMode === "form" && state.configForm state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm) ? serializeConfigForm(state.configForm)
: state.configRaw; : 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; state.configFormDirty = false;
await loadConfig(state); await loadConfig(state);
} catch (err) { } catch (err) {
@@ -425,8 +431,14 @@ export async function applyConfig(state: ConfigState) {
state.configFormMode === "form" && state.configForm state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm) ? serializeConfigForm(state.configForm)
: state.configRaw; : state.configRaw;
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
return;
}
await state.client.request("config.apply", { await state.client.request("config.apply", {
raw, raw,
baseHash,
sessionKey: state.applySessionKey, sessionKey: state.applySessionKey,
}); });
state.configFormDirty = false; state.configFormDirty = false;

View File

@@ -13,70 +13,68 @@ export async function saveDiscordConfig(state: ConnectionsState) {
state.discordSaving = true; state.discordSaving = true;
state.discordConfigStatus = null; state.discordConfigStatus = null;
try { try {
const base = state.configSnapshot?.config ?? {}; const baseHash = state.configSnapshot?.hash;
const config = { ...base } as Record<string, unknown>; if (!baseHash) {
const discord = { ...(config.discord ?? {}) } as Record<string, unknown>; state.discordConfigStatus = "Config hash missing; reload and retry.";
return;
}
const discord: Record<string, unknown> = {};
const form = state.discordForm; const form = state.discordForm;
if (form.enabled) { if (form.enabled) {
delete discord.enabled; discord.enabled = null;
} else { } else {
discord.enabled = false; discord.enabled = false;
} }
if (!state.discordTokenLocked) { if (!state.discordTokenLocked) {
const token = form.token.trim(); const token = form.token.trim();
if (token) discord.token = token; discord.token = token || null;
else delete discord.token;
} }
const allowFrom = parseList(form.allowFrom); const allowFrom = parseList(form.allowFrom);
const groupChannels = parseList(form.groupChannels); const groupChannels = parseList(form.groupChannels);
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>; const dm: Record<string, unknown> = {
if (form.dmEnabled) delete dm.enabled; enabled: form.dmEnabled ? null : false,
else dm.enabled = false; allowFrom: allowFrom.length > 0 ? allowFrom : null,
if (allowFrom.length > 0) dm.allowFrom = allowFrom; groupEnabled: form.groupEnabled ? true : null,
else delete dm.allowFrom; groupChannels: groupChannels.length > 0 ? groupChannels : null,
if (form.groupEnabled) dm.groupEnabled = true; };
else delete dm.groupEnabled; discord.dm = dm;
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 mediaMaxMb = Number(form.mediaMaxMb); const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
discord.mediaMaxMb = mediaMaxMb; discord.mediaMaxMb = mediaMaxMb;
} else { } else {
delete discord.mediaMaxMb; discord.mediaMaxMb = null;
} }
const historyLimitRaw = form.historyLimit.trim(); const historyLimitRaw = form.historyLimit.trim();
if (historyLimitRaw.length === 0) { if (historyLimitRaw.length === 0) {
delete discord.historyLimit; discord.historyLimit = null;
} else { } else {
const historyLimit = Number(historyLimitRaw); const historyLimit = Number(historyLimitRaw);
if (Number.isFinite(historyLimit) && historyLimit >= 0) { if (Number.isFinite(historyLimit) && historyLimit >= 0) {
discord.historyLimit = historyLimit; discord.historyLimit = historyLimit;
} else { } else {
delete discord.historyLimit; discord.historyLimit = null;
} }
} }
const chunkLimitRaw = form.textChunkLimit.trim(); const chunkLimitRaw = form.textChunkLimit.trim();
if (chunkLimitRaw.length === 0) { if (chunkLimitRaw.length === 0) {
delete discord.textChunkLimit; discord.textChunkLimit = null;
} else { } else {
const chunkLimit = Number(chunkLimitRaw); const chunkLimit = Number(chunkLimitRaw);
if (Number.isFinite(chunkLimit) && chunkLimit > 0) { if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
discord.textChunkLimit = chunkLimit; discord.textChunkLimit = chunkLimit;
} else { } else {
delete discord.textChunkLimit; discord.textChunkLimit = null;
} }
} }
if (form.replyToMode === "off") { if (form.replyToMode === "off") {
delete discord.replyToMode; discord.replyToMode = null;
} else { } else {
discord.replyToMode = form.replyToMode; discord.replyToMode = form.replyToMode;
} }
@@ -114,7 +112,7 @@ export async function saveDiscordConfig(state: ConnectionsState) {
guilds[key] = entry; guilds[key] = entry;
}); });
if (Object.keys(guilds).length > 0) discord.guilds = guilds; if (Object.keys(guilds).length > 0) discord.guilds = guilds;
else delete discord.guilds; else discord.guilds = null;
const actions: Partial<DiscordActionForm> = {}; const actions: Partial<DiscordActionForm> = {};
const applyAction = (key: keyof DiscordActionForm) => { const applyAction = (key: keyof DiscordActionForm) => {
@@ -139,36 +137,33 @@ export async function saveDiscordConfig(state: ConnectionsState) {
if (Object.keys(actions).length > 0) { if (Object.keys(actions).length > 0) {
discord.actions = actions; discord.actions = actions;
} else { } else {
delete discord.actions; discord.actions = null;
} }
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>; const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
if (form.slashEnabled) { if (form.slashEnabled) {
slash.enabled = true; slash.enabled = true;
} else { } else {
delete slash.enabled; slash.enabled = null;
} }
if (form.slashName.trim()) slash.name = form.slashName.trim(); if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name; else slash.name = null;
if (form.slashSessionPrefix.trim()) if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim(); slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix; else slash.sessionPrefix = null;
if (form.slashEphemeral) { if (form.slashEphemeral) {
delete slash.ephemeral; slash.ephemeral = null;
} else { } else {
slash.ephemeral = false; slash.ephemeral = false;
} }
if (Object.keys(slash).length > 0) discord.slashCommand = slash; discord.slashCommand = Object.keys(slash).length > 0 ? slash : null;
else delete discord.slashCommand;
if (Object.keys(discord).length > 0) { const raw = `${JSON.stringify(
config.discord = discord; { channels: { discord } },
} else { null,
delete config.discord; 2,
} ).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.discordConfigStatus = "Saved. Restart gateway if needed."; state.discordConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) { } catch (err) {
state.discordConfigStatus = String(err); state.discordConfigStatus = String(err);
@@ -176,4 +171,3 @@ export async function saveDiscordConfig(state: ConnectionsState) {
state.discordSaving = false; state.discordSaving = false;
} }
} }

View File

@@ -7,57 +7,53 @@ export async function saveIMessageConfig(state: ConnectionsState) {
state.imessageSaving = true; state.imessageSaving = true;
state.imessageConfigStatus = null; state.imessageConfigStatus = null;
try { try {
const base = state.configSnapshot?.config ?? {}; const baseHash = state.configSnapshot?.hash;
const config = { ...base } as Record<string, unknown>; if (!baseHash) {
const imessage = { ...(config.imessage ?? {}) } as Record<string, unknown>; state.imessageConfigStatus = "Config hash missing; reload and retry.";
return;
}
const imessage: Record<string, unknown> = {};
const form = state.imessageForm; const form = state.imessageForm;
if (form.enabled) { if (form.enabled) {
delete imessage.enabled; imessage.enabled = null;
} else { } else {
imessage.enabled = false; imessage.enabled = false;
} }
const cliPath = form.cliPath.trim(); const cliPath = form.cliPath.trim();
if (cliPath) imessage.cliPath = cliPath; imessage.cliPath = cliPath || null;
else delete imessage.cliPath;
const dbPath = form.dbPath.trim(); const dbPath = form.dbPath.trim();
if (dbPath) imessage.dbPath = dbPath; imessage.dbPath = dbPath || null;
else delete imessage.dbPath;
if (form.service === "auto") { if (form.service === "auto") {
delete imessage.service; imessage.service = null;
} else { } else {
imessage.service = form.service; imessage.service = form.service;
} }
const region = form.region.trim(); const region = form.region.trim();
if (region) imessage.region = region; imessage.region = region || null;
else delete imessage.region;
const allowFrom = parseList(form.allowFrom); const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) imessage.allowFrom = allowFrom; imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null;
else delete imessage.allowFrom;
if (form.includeAttachments) imessage.includeAttachments = true; imessage.includeAttachments = form.includeAttachments ? true : null;
else delete imessage.includeAttachments;
const mediaMaxMb = Number(form.mediaMaxMb); const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
imessage.mediaMaxMb = mediaMaxMb; imessage.mediaMaxMb = mediaMaxMb;
} else { } else {
delete imessage.mediaMaxMb; imessage.mediaMaxMb = null;
} }
if (Object.keys(imessage).length > 0) { const raw = `${JSON.stringify(
config.imessage = imessage; { channels: { imessage } },
} else { null,
delete config.imessage; 2,
} ).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.imessageConfigStatus = "Saved. Restart gateway if needed."; state.imessageConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) { } catch (err) {
state.imessageConfigStatus = String(err); state.imessageConfigStatus = String(err);
@@ -65,4 +61,3 @@ export async function saveIMessageConfig(state: ConnectionsState) {
state.imessageSaving = false; state.imessageSaving = false;
} }
} }

View File

@@ -7,42 +7,41 @@ export async function saveSignalConfig(state: ConnectionsState) {
state.signalSaving = true; state.signalSaving = true;
state.signalConfigStatus = null; state.signalConfigStatus = null;
try { try {
const base = state.configSnapshot?.config ?? {}; const baseHash = state.configSnapshot?.hash;
const config = { ...base } as Record<string, unknown>; if (!baseHash) {
const signal = { ...(config.signal ?? {}) } as Record<string, unknown>; state.signalConfigStatus = "Config hash missing; reload and retry.";
return;
}
const signal: Record<string, unknown> = {};
const form = state.signalForm; const form = state.signalForm;
if (form.enabled) { if (form.enabled) {
delete signal.enabled; signal.enabled = null;
} else { } else {
signal.enabled = false; signal.enabled = false;
} }
const account = form.account.trim(); const account = form.account.trim();
if (account) signal.account = account; signal.account = account || null;
else delete signal.account;
const httpUrl = form.httpUrl.trim(); const httpUrl = form.httpUrl.trim();
if (httpUrl) signal.httpUrl = httpUrl; signal.httpUrl = httpUrl || null;
else delete signal.httpUrl;
const httpHost = form.httpHost.trim(); const httpHost = form.httpHost.trim();
if (httpHost) signal.httpHost = httpHost; signal.httpHost = httpHost || null;
else delete signal.httpHost;
const httpPort = Number(form.httpPort); const httpPort = Number(form.httpPort);
if (Number.isFinite(httpPort) && httpPort > 0) { if (Number.isFinite(httpPort) && httpPort > 0) {
signal.httpPort = httpPort; signal.httpPort = httpPort;
} else { } else {
delete signal.httpPort; signal.httpPort = null;
} }
const cliPath = form.cliPath.trim(); const cliPath = form.cliPath.trim();
if (cliPath) signal.cliPath = cliPath; signal.cliPath = cliPath || null;
else delete signal.cliPath;
if (form.autoStart) { if (form.autoStart) {
delete signal.autoStart; signal.autoStart = null;
} else { } else {
signal.autoStart = false; signal.autoStart = false;
} }
@@ -50,35 +49,29 @@ export async function saveSignalConfig(state: ConnectionsState) {
if (form.receiveMode === "on-start" || form.receiveMode === "manual") { if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
signal.receiveMode = form.receiveMode; signal.receiveMode = form.receiveMode;
} else { } else {
delete signal.receiveMode; signal.receiveMode = null;
} }
if (form.ignoreAttachments) signal.ignoreAttachments = true; signal.ignoreAttachments = form.ignoreAttachments ? true : null;
else delete signal.ignoreAttachments; signal.ignoreStories = form.ignoreStories ? true : null;
if (form.ignoreStories) signal.ignoreStories = true; signal.sendReadReceipts = form.sendReadReceipts ? true : null;
else delete signal.ignoreStories;
if (form.sendReadReceipts) signal.sendReadReceipts = true;
else delete signal.sendReadReceipts;
const allowFrom = parseList(form.allowFrom); const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) signal.allowFrom = allowFrom; signal.allowFrom = allowFrom.length > 0 ? allowFrom : null;
else delete signal.allowFrom;
const mediaMaxMb = Number(form.mediaMaxMb); const mediaMaxMb = Number(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
signal.mediaMaxMb = mediaMaxMb; signal.mediaMaxMb = mediaMaxMb;
} else { } else {
delete signal.mediaMaxMb; signal.mediaMaxMb = null;
} }
if (Object.keys(signal).length > 0) { const raw = `${JSON.stringify(
config.signal = signal; { channels: { signal } },
} else { null,
delete config.signal; 2,
} ).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.signalConfigStatus = "Saved. Restart gateway if needed."; state.signalConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) { } catch (err) {
state.signalConfigStatus = String(err); state.signalConfigStatus = String(err);
@@ -86,4 +79,3 @@ export async function saveSignalConfig(state: ConnectionsState) {
state.signalSaving = false; state.signalSaving = false;
} }
} }

View File

@@ -8,60 +8,58 @@ export async function saveSlackConfig(state: ConnectionsState) {
state.slackSaving = true; state.slackSaving = true;
state.slackConfigStatus = null; state.slackConfigStatus = null;
try { try {
const base = state.configSnapshot?.config ?? {}; const baseHash = state.configSnapshot?.hash;
const config = { ...base } as Record<string, unknown>; if (!baseHash) {
const slack = { ...(config.slack ?? {}) } as Record<string, unknown>; state.slackConfigStatus = "Config hash missing; reload and retry.";
return;
}
const slack: Record<string, unknown> = {};
const form = state.slackForm; const form = state.slackForm;
if (form.enabled) { if (form.enabled) {
delete slack.enabled; slack.enabled = null;
} else { } else {
slack.enabled = false; slack.enabled = false;
} }
if (!state.slackTokenLocked) { if (!state.slackTokenLocked) {
const token = form.botToken.trim(); const token = form.botToken.trim();
if (token) slack.botToken = token; slack.botToken = token || null;
else delete slack.botToken;
} }
if (!state.slackAppTokenLocked) { if (!state.slackAppTokenLocked) {
const token = form.appToken.trim(); const token = form.appToken.trim();
if (token) slack.appToken = token; slack.appToken = token || null;
else delete slack.appToken;
} }
const dm = { ...(slack.dm ?? {}) } as Record<string, unknown>; const dm: Record<string, unknown> = {};
dm.enabled = form.dmEnabled; dm.enabled = form.dmEnabled;
const allowFrom = parseList(form.allowFrom); const allowFrom = parseList(form.allowFrom);
if (allowFrom.length > 0) dm.allowFrom = allowFrom; dm.allowFrom = allowFrom.length > 0 ? allowFrom : null;
else delete dm.allowFrom;
if (form.groupEnabled) { if (form.groupEnabled) {
dm.groupEnabled = true; dm.groupEnabled = true;
} else { } else {
delete dm.groupEnabled; dm.groupEnabled = null;
} }
const groupChannels = parseList(form.groupChannels); const groupChannels = parseList(form.groupChannels);
if (groupChannels.length > 0) dm.groupChannels = groupChannels; dm.groupChannels = groupChannels.length > 0 ? groupChannels : null;
else delete dm.groupChannels; slack.dm = dm;
if (Object.keys(dm).length > 0) slack.dm = dm;
else delete slack.dm;
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
slack.mediaMaxMb = mediaMaxMb; slack.mediaMaxMb = mediaMaxMb;
} else { } else {
delete slack.mediaMaxMb; slack.mediaMaxMb = null;
} }
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
slack.textChunkLimit = textChunkLimit; slack.textChunkLimit = textChunkLimit;
} else { } else {
delete slack.textChunkLimit; slack.textChunkLimit = null;
} }
if (form.reactionNotifications === "own") { if (form.reactionNotifications === "own") {
delete slack.reactionNotifications; slack.reactionNotifications = null;
} else { } else {
slack.reactionNotifications = form.reactionNotifications; slack.reactionNotifications = form.reactionNotifications;
} }
@@ -69,27 +67,26 @@ export async function saveSlackConfig(state: ConnectionsState) {
if (reactionAllowlist.length > 0) { if (reactionAllowlist.length > 0) {
slack.reactionAllowlist = reactionAllowlist; slack.reactionAllowlist = reactionAllowlist;
} else { } else {
delete slack.reactionAllowlist; slack.reactionAllowlist = null;
} }
const slash = { ...(slack.slashCommand ?? {}) } as Record<string, unknown>; const slash: Record<string, unknown> = {};
if (form.slashEnabled) { if (form.slashEnabled) {
slash.enabled = true; slash.enabled = true;
} else { } else {
delete slash.enabled; slash.enabled = null;
} }
if (form.slashName.trim()) slash.name = form.slashName.trim(); if (form.slashName.trim()) slash.name = form.slashName.trim();
else delete slash.name; else slash.name = null;
if (form.slashSessionPrefix.trim()) if (form.slashSessionPrefix.trim())
slash.sessionPrefix = form.slashSessionPrefix.trim(); slash.sessionPrefix = form.slashSessionPrefix.trim();
else delete slash.sessionPrefix; else slash.sessionPrefix = null;
if (form.slashEphemeral) { if (form.slashEphemeral) {
delete slash.ephemeral; slash.ephemeral = null;
} else { } else {
slash.ephemeral = false; slash.ephemeral = false;
} }
if (Object.keys(slash).length > 0) slack.slashCommand = slash; slack.slashCommand = slash;
else delete slack.slashCommand;
const actions: Partial<SlackActionForm> = {}; const actions: Partial<SlackActionForm> = {};
const applyAction = (key: keyof SlackActionForm) => { const applyAction = (key: keyof SlackActionForm) => {
@@ -104,7 +101,7 @@ export async function saveSlackConfig(state: ConnectionsState) {
if (Object.keys(actions).length > 0) { if (Object.keys(actions).length > 0) {
slack.actions = actions; slack.actions = actions;
} else { } else {
delete slack.actions; slack.actions = null;
} }
const channels = form.channels const channels = form.channels
@@ -123,17 +120,15 @@ export async function saveSlackConfig(state: ConnectionsState) {
if (channels.length > 0) { if (channels.length > 0) {
slack.channels = Object.fromEntries(channels); slack.channels = Object.fromEntries(channels);
} else { } else {
delete slack.channels; slack.channels = null;
} }
if (Object.keys(slack).length > 0) { const raw = `${JSON.stringify(
config.slack = slack; { channels: { slack } },
} else { null,
delete config.slack; 2,
} ).trimEnd()}\n`;
await state.client.request("config.patch", { raw, baseHash });
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
await state.client.request("config.set", { raw });
state.slackConfigStatus = "Saved. Restart gateway if needed."; state.slackConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) { } catch (err) {
state.slackConfigStatus = String(err); state.slackConfigStatus = String(err);

View File

@@ -165,56 +165,53 @@ export async function saveTelegramConfig(state: ConnectionsState) {
} }
} }
const base = state.configSnapshot?.config ?? {}; const base = state.configSnapshot?.config ?? {};
const config = { ...base } as Record<string, unknown>; const channels = (base.channels ?? {}) as Record<string, unknown>;
const telegram = { ...(config.telegram ?? {}) } as Record<string, unknown>; const telegram = {
...(channels.telegram ?? base.telegram ?? {}),
} as Record<string, unknown>;
if (!state.telegramTokenLocked) { if (!state.telegramTokenLocked) {
const token = state.telegramForm.token.trim(); const token = state.telegramForm.token.trim();
if (token) telegram.botToken = token; telegram.botToken = token || null;
else delete telegram.botToken;
} }
const groups = const groupsPatch: Record<string, unknown> = {};
telegram.groups && typeof telegram.groups === "object"
? ({ ...(telegram.groups as Record<string, unknown>) } as Record<
string,
unknown
>)
: {};
if (state.telegramForm.groupsWildcardEnabled) { if (state.telegramForm.groupsWildcardEnabled) {
const existingGroups = telegram.groups as Record<string, unknown> | undefined;
const defaultGroup = const defaultGroup =
groups["*"] && typeof groups["*"] === "object" existingGroups?.["*"] && typeof existingGroups["*"] === "object"
? ({ ...(groups["*"] as Record<string, unknown>) } as Record< ? ({ ...(existingGroups["*"] as Record<string, unknown>) } as Record<
string, string,
unknown unknown
>) >)
: {}; : {};
defaultGroup.requireMention = state.telegramForm.requireMention; defaultGroup.requireMention = state.telegramForm.requireMention;
groups["*"] = defaultGroup; groupsPatch["*"] = defaultGroup;
telegram.groups = groups; } else {
} else if (groups["*"]) { groupsPatch["*"] = null;
delete groups["*"];
if (Object.keys(groups).length > 0) telegram.groups = groups;
else delete telegram.groups;
} }
delete telegram.requireMention; telegram.groups = groupsPatch;
telegram.requireMention = null;
const allowFrom = parseList(state.telegramForm.allowFrom); const allowFrom = parseList(state.telegramForm.allowFrom);
if (allowFrom.length > 0) telegram.allowFrom = allowFrom; telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null;
else delete telegram.allowFrom;
const proxy = state.telegramForm.proxy.trim(); const proxy = state.telegramForm.proxy.trim();
if (proxy) telegram.proxy = proxy; telegram.proxy = proxy || null;
else delete telegram.proxy;
const webhookUrl = state.telegramForm.webhookUrl.trim(); const webhookUrl = state.telegramForm.webhookUrl.trim();
if (webhookUrl) telegram.webhookUrl = webhookUrl; telegram.webhookUrl = webhookUrl || null;
else delete telegram.webhookUrl;
const webhookSecret = state.telegramForm.webhookSecret.trim(); const webhookSecret = state.telegramForm.webhookSecret.trim();
if (webhookSecret) telegram.webhookSecret = webhookSecret; telegram.webhookSecret = webhookSecret || null;
else delete telegram.webhookSecret;
const webhookPath = state.telegramForm.webhookPath.trim(); const webhookPath = state.telegramForm.webhookPath.trim();
if (webhookPath) telegram.webhookPath = webhookPath; telegram.webhookPath = webhookPath || null;
else delete telegram.webhookPath;
config.telegram = telegram; const baseHash = state.configSnapshot?.hash;
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; if (!baseHash) {
await state.client.request("config.set", { raw }); 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."; state.telegramConfigStatus = "Saved. Restart gateway if needed.";
} catch (err) { } catch (err) {
state.telegramConfigStatus = String(err); state.telegramConfigStatus = String(err);

View File

@@ -214,6 +214,7 @@ export type ConfigSnapshot = {
path?: string | null; path?: string | null;
exists?: boolean | null; exists?: boolean | null;
raw?: string | null; raw?: string | null;
hash?: string | null;
parsed?: unknown; parsed?: unknown;
valid?: boolean | null; valid?: boolean | null;
config?: Record<string, unknown> | null; config?: Record<string, unknown> | null;