Skip to main content

bamboo_config/
patch.rs

1//! Config patch domain logic.
2//!
3//! Pure business rules for interpreting, sanitizing, and merging
4//! partial config patches. Used by the server's config management endpoints.
5
6use serde_json::{Map, Value};
7
8use crate::Config;
9
10/// Detect whether a string value looks like a masked/placeholder API key.
11///
12/// Only a value consisting entirely of `*`/`.` characters counts (the redaction
13/// placeholder `****...****`, or truncated/retyped variants of it). Substring
14/// matching is deliberately avoided: the UI prefills the placeholder into the
15/// editable field, so a paste that doesn't fully clear it yields values like
16/// `****...****sk-new…` — treating those as "keep existing key" silently
17/// discards the user's new token (#430).
18pub fn is_masked_api_key(value: &str) -> bool {
19    let v = value.trim();
20    // Empty string is treated as an explicit "clear" signal (we control all clients).
21    !v.is_empty() && v.chars().all(|c| c == '*' || c == '.')
22}
23
24/// Decide whether `obj[field]` expresses a secret update **intent** — a
25/// genuine new value or an explicit clear — as opposed to "leave alone"
26/// (field absent) or "keep existing" (a masked placeholder string).
27///
28/// Three ways a client can write a secret field, and what each means:
29/// - absent → not an intent (existing #521/#516 behavior, unchanged).
30/// - a masked placeholder string (`is_masked_api_key`) → not an intent, the
31///   caller resolves it back to the live plaintext (`preserve_masked_*`).
32/// - anything else — a real new value, an explicit `""`, OR an explicit
33///   JSON `null` (#505's RFC-7386-style delete) → **is** an intent. `""`
34///   and `null` are equivalent clear signals here: [`deep_merge_json`]
35///   removes a `null` field from the merge target the same way it would
36///   settle on an empty string for a plain scalar, and treating both as
37///   "the caller explicitly asked to clear this" keeps the intent set in
38///   sync with what the merge is about to do — a `null` clear must be
39///   registered as an intent or `preserve_unpatched_provider_secrets` (which
40///   only skips fields the intents mark as touched) would resurrect the
41///   value the caller just deleted.
42fn is_secret_field_intent(obj: &Map<String, Value>, field: &str) -> bool {
43    match obj.get(field) {
44        None => false,
45        Some(Value::Null) => true,
46        Some(value) => match value.as_str() {
47            Some(s) => !is_masked_api_key(s),
48            // Not a string and not null (shouldn't happen from a
49            // well-behaved client) — no coherent intent to extract.
50            None => false,
51        },
52    }
53}
54
55/// Extract API-key update intents from a config patch.
56///
57/// Masked placeholders are ignored — they signal "keep existing key". An
58/// explicit `null` is treated the same as an explicit `""` clear (#505) —
59/// see [`is_secret_field_intent`].
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
61pub struct ProviderApiKeyIntents {
62    pub providers: std::collections::BTreeSet<String>,
63    pub provider_instances: std::collections::BTreeSet<String>,
64}
65
66pub fn provider_api_key_intents(patch_obj: &Map<String, Value>) -> ProviderApiKeyIntents {
67    let mut intents = ProviderApiKeyIntents::default();
68
69    if let Some(root) = patch_obj.get("providers").and_then(|v| v.as_object()) {
70        for (provider_name, provider_patch) in root.iter() {
71            let Some(obj) = provider_patch.as_object() else {
72                continue;
73            };
74            if is_secret_field_intent(obj, "api_key") {
75                intents.providers.insert(provider_name.clone());
76            }
77        }
78    }
79
80    if let Some(root) = patch_obj
81        .get("provider_instances")
82        .and_then(|v| v.as_object())
83    {
84        for (instance_id, instance_patch) in root.iter() {
85            let Some(obj) = instance_patch.as_object() else {
86                continue;
87            };
88            if is_secret_field_intent(obj, "api_key") {
89                intents.provider_instances.insert(instance_id.clone());
90            }
91        }
92    }
93
94    intents
95}
96
97/// Reload strategy to apply after a config patch.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum ReloadMode {
100    None,
101    /// Attempt reload, but do not fail the request if reload fails.
102    BestEffort,
103    /// Reload must succeed; otherwise the request fails.
104    Strict,
105}
106
107/// Side-effects determined from a config patch.
108#[derive(Debug, Clone, Copy)]
109pub struct PatchEffects {
110    pub reload_provider: ReloadMode,
111    pub reconcile_mcp: bool,
112}
113
114/// Which config domains are touched by a patch.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
116pub struct DomainChanges {
117    pub provider: bool,
118    pub proxy: bool,
119    pub setup: bool,
120    pub mcp: bool,
121    pub keyword_masking: bool,
122    pub hooks: bool,
123    pub model_mapping: bool,
124}
125
126/// Classify which config domains are affected by a patch.
127pub fn domains_for_root_patch(patch_obj: &Map<String, Value>) -> DomainChanges {
128    let mut changes = DomainChanges::default();
129
130    for key in patch_obj.keys() {
131        match key.as_str() {
132            // Provider domain
133            "provider"
134            | "providers"
135            | "provider_instances"
136            | "default_provider_instance"
137            | "model"
138            | "defaults"
139            | "features" => changes.provider = true,
140
141            // Proxy domain
142            "http_proxy"
143            | "https_proxy"
144            | "proxy_auth"
145            | "proxy_auth_encrypted"
146            | "http_proxy_auth_encrypted"
147            | "https_proxy_auth_encrypted" => changes.proxy = true,
148
149            // Setup domain (stored under Config.extra via serde flatten)
150            "setup" => changes.setup = true,
151
152            // MCP domain
153            "mcp" | "mcpServers" => changes.mcp = true,
154
155            // Other known config domains
156            "keyword_masking" => changes.keyword_masking = true,
157            "hooks" => changes.hooks = true,
158            "anthropic_model_mapping" | "gemini_model_mapping" => changes.model_mapping = true,
159
160            _ => {}
161        }
162    }
163
164    changes
165}
166
167/// Determine what side-effects a config patch should trigger.
168pub fn effects_for_root_patch(patch_obj: &Map<String, Value>) -> PatchEffects {
169    let domains = domains_for_root_patch(patch_obj);
170
171    let touches_provider = domains.provider || domains.hooks || domains.keyword_masking;
172    let touches_proxy = domains.proxy;
173    let touches_mcp = domains.mcp;
174
175    PatchEffects {
176        reload_provider: if touches_provider || touches_proxy {
177            ReloadMode::BestEffort
178        } else {
179            ReloadMode::None
180        },
181        // SSE-based MCP servers are HTTP clients and must respect proxy settings.
182        // Reconcile so proxy changes take effect without a restart.
183        reconcile_mcp: touches_mcp || touches_proxy,
184    }
185}
186
187/// Remove forbidden fields from a config patch before application.
188///
189/// Strips encrypted auth material, data_dir, and MCP secret fields
190/// that should never be set directly by clients.
191pub fn sanitize_root_patch(patch_obj: &mut Map<String, Value>) {
192    // Never allow clients to modify proxy auth fields or data_dir via this endpoint.
193    patch_obj.remove("proxy_auth");
194    patch_obj.remove("proxy_auth_encrypted");
195    // Legacy/compat proxy auth keys (written by older Bodhi/Tauri builds).
196    patch_obj.remove("http_proxy_auth_encrypted");
197    patch_obj.remove("https_proxy_auth_encrypted");
198    patch_obj.remove("data_dir");
199
200    // Never allow clients to set encrypted key material directly.
201    if let Some(providers) = patch_obj
202        .get_mut("providers")
203        .and_then(|v| v.as_object_mut())
204    {
205        for (_provider_name, provider_cfg) in providers.iter_mut() {
206            let Some(obj) = provider_cfg.as_object_mut() else {
207                continue;
208            };
209            obj.remove("api_key_encrypted");
210        }
211    }
212
213    if let Some(provider_instances) = patch_obj
214        .get_mut("provider_instances")
215        .and_then(|v| v.as_object_mut())
216    {
217        for (_instance_id, instance_cfg) in provider_instances.iter_mut() {
218            let Some(obj) = instance_cfg.as_object_mut() else {
219                continue;
220            };
221            obj.remove("api_key_encrypted");
222        }
223    }
224
225    // Never allow clients to set encrypted notification-channel secrets directly.
226    if let Some(notifications) = patch_obj
227        .get_mut("notifications")
228        .and_then(|v| v.as_object_mut())
229    {
230        if let Some(ntfy) = notifications
231            .get_mut("ntfy")
232            .and_then(|v| v.as_object_mut())
233        {
234            ntfy.remove("token_encrypted");
235        }
236        if let Some(bark) = notifications
237            .get_mut("bark")
238            .and_then(|v| v.as_object_mut())
239        {
240            bark.remove("device_key_encrypted");
241        }
242    }
243
244    // Never allow clients to set encrypted bamboo-connect platform secrets
245    // (token, Feishu app_secret) directly.
246    if let Some(platforms) = patch_obj
247        .get_mut("connect")
248        .and_then(|c| c.get_mut("platforms"))
249        .and_then(|v| v.as_array_mut())
250    {
251        for platform in platforms.iter_mut() {
252            if let Some(obj) = platform.as_object_mut() {
253                obj.remove("token_encrypted");
254                obj.remove("app_secret_encrypted");
255            }
256        }
257    }
258
259    // Never allow clients to set encrypted secret material directly.
260    //
261    // Canonical MCP format:
262    //   "mcpServers": { "<id>": { env_encrypted, headers[*].value_encrypted, ... } }
263    if let Some(mcp_servers) = patch_obj
264        .get_mut("mcpServers")
265        .and_then(|v| v.as_object_mut())
266    {
267        for (_id, server) in mcp_servers.iter_mut() {
268            let Some(server_obj) = server.as_object_mut() else {
269                continue;
270            };
271            server_obj.remove("env_encrypted");
272            if let Some(headers) = server_obj.get_mut("headers").and_then(|v| v.as_array_mut()) {
273                for header in headers.iter_mut() {
274                    let Some(header_obj) = header.as_object_mut() else {
275                        continue;
276                    };
277                    header_obj.remove("value_encrypted");
278                }
279            }
280        }
281    }
282
283    // Legacy MCP shape:
284    //   "mcp": { "servers": [ { transport: { env_encrypted / headers[*].value_encrypted } } ] }
285    if let Some(servers) = patch_obj
286        .get_mut("mcp")
287        .and_then(|m| m.get_mut("servers"))
288        .and_then(|v| v.as_array_mut())
289    {
290        for server in servers.iter_mut() {
291            let Some(server_obj) = server.as_object_mut() else {
292                continue;
293            };
294            let Some(transport) = server_obj
295                .get_mut("transport")
296                .and_then(|v| v.as_object_mut())
297            else {
298                continue;
299            };
300
301            match transport.get("type").and_then(|v| v.as_str()) {
302                Some("stdio") => {
303                    transport.remove("env_encrypted");
304                }
305                Some("sse") => {
306                    if let Some(headers) =
307                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
308                    {
309                        for header in headers.iter_mut() {
310                            let Some(header_obj) = header.as_object_mut() else {
311                                continue;
312                            };
313                            header_obj.remove("value_encrypted");
314                        }
315                    }
316                }
317                _ => {}
318            }
319        }
320    }
321}
322
323/// Replace masked API key placeholders in a patch with the current config's plain keys.
324///
325/// The UI sends masked values (e.g. `****...****`) to indicate "do not change this key".
326/// This function resolves those back to the existing plain-text key from the live config.
327pub fn preserve_masked_provider_api_keys(patch_obj: &mut Map<String, Value>, current: &Config) {
328    if let Some(patch_providers) = patch_obj
329        .get_mut("providers")
330        .and_then(|v| v.as_object_mut())
331    {
332        for (provider_name, provider_patch) in patch_providers.iter_mut() {
333            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
334                continue;
335            };
336
337            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
338                continue;
339            };
340            if !is_masked_api_key(api_key) {
341                continue;
342            }
343
344            let existing_plain = match provider_name.as_str() {
345                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
346                "anthropic" => current
347                    .providers
348                    .anthropic
349                    .as_ref()
350                    .map(|c| c.api_key.clone()),
351                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
352                "bodhi" => current.providers.bodhi.as_ref().map(|c| c.api_key.clone()),
353                _ => None,
354            };
355
356            if let Some(existing_plain) = existing_plain {
357                if !existing_plain.trim().is_empty() {
358                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
359                } else {
360                    patch_cfg_obj.remove("api_key");
361                }
362            } else {
363                patch_cfg_obj.remove("api_key");
364            }
365        }
366    }
367
368    if let Some(patch_instances) = patch_obj
369        .get_mut("provider_instances")
370        .and_then(|v| v.as_object_mut())
371    {
372        for (instance_id, instance_patch) in patch_instances.iter_mut() {
373            let Some(patch_cfg_obj) = instance_patch.as_object_mut() else {
374                continue;
375            };
376
377            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
378                continue;
379            };
380            if !is_masked_api_key(api_key) {
381                continue;
382            }
383
384            let existing_plain = current
385                .provider_instances
386                .get(instance_id)
387                .map(|instance| instance.api_key.clone());
388
389            if let Some(existing_plain) = existing_plain {
390                if !existing_plain.trim().is_empty() {
391                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
392                } else {
393                    patch_cfg_obj.remove("api_key");
394                }
395            } else {
396                patch_cfg_obj.remove("api_key");
397            }
398        }
399    }
400}
401
402/// Carry provider secrets that the merge round-trip dropped forward from the
403/// live config.
404///
405/// `config_manager::build_merged_config` serializes the live config before
406/// merging a patch, which drops every `#[serde(skip_serializing)]` plaintext
407/// `api_key`. Hydration afterwards only restores ciphertext-backed keys — but
408/// the live config can legitimately hold a plaintext-only secret whose
409/// `api_key_encrypted` is still `None` (ciphertext is only ever computed on
410/// `save_to_dir`'s save-time clone; a provider instance freshly created via
411/// the instance CRUD endpoints stays plaintext-only in memory). The merged
412/// config then ends up with NEITHER field and the key is silently lost on the
413/// next persist: config.json loses `api_key_encrypted` while config.json.bak
414/// keeps it (#516).
415///
416/// Restores `api_key`/`api_key_encrypted` from `current` for every legacy
417/// provider and provider instance that the patch did not explicitly set or
418/// clear (per `intents`), whenever the merge left neither field behind.
419/// Generalizes the env-sourced rescue of
420/// [`Config::preserve_env_sourced_provider_keys`] (#373).
421pub fn preserve_unpatched_provider_secrets(
422    merged: &mut Config,
423    current: &Config,
424    intents: &ProviderApiKeyIntents,
425) {
426    macro_rules! carry_forward {
427        ($field:ident) => {
428            if !intents.providers.contains(stringify!($field)) {
429                if let (Some(new_cfg), Some(prev)) = (
430                    merged.providers.$field.as_mut(),
431                    current.providers.$field.as_ref(),
432                ) {
433                    if new_cfg.api_key.trim().is_empty()
434                        && new_cfg.api_key_encrypted.is_none()
435                        && (!prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some())
436                    {
437                        new_cfg.api_key = prev.api_key.clone();
438                        new_cfg.api_key_encrypted = prev.api_key_encrypted.clone();
439                    }
440                }
441            }
442        };
443    }
444    carry_forward!(openai);
445    carry_forward!(anthropic);
446    carry_forward!(gemini);
447    carry_forward!(bodhi);
448
449    for (id, instance) in merged.provider_instances.iter_mut() {
450        if intents.provider_instances.contains(id) {
451            continue;
452        }
453        if !instance.api_key.trim().is_empty() || instance.api_key_encrypted.is_some() {
454            continue;
455        }
456        if let Some(prev) = current.provider_instances.get(id) {
457            if !prev.api_key.trim().is_empty() || prev.api_key_encrypted.is_some() {
458                instance.api_key = prev.api_key.clone();
459                instance.api_key_encrypted = prev.api_key_encrypted.clone();
460            }
461        }
462    }
463}
464
465/// Make an explicit `api_key: ""` clear actually clear.
466///
467/// The merge round-trip carries the live config's `api_key_encrypted` into the
468/// merged value; hydration would then refill the plaintext from it and the
469/// subsequent sync/save would re-encrypt — silently undoing the clear. For
470/// every provider/instance the patch explicitly touched whose merged plaintext
471/// is empty (a clear, not a set), drop the round-tripped ciphertext BEFORE
472/// hydration so nothing refills the key (#516).
473pub fn clear_provider_ciphertext_for_explicit_clears(
474    merged: &mut Config,
475    intents: &ProviderApiKeyIntents,
476) {
477    macro_rules! clear_ciphertext {
478        ($field:ident) => {
479            if intents.providers.contains(stringify!($field)) {
480                if let Some(cfg) = merged.providers.$field.as_mut() {
481                    if cfg.api_key.trim().is_empty() {
482                        cfg.api_key_encrypted = None;
483                    }
484                }
485            }
486        };
487    }
488    clear_ciphertext!(openai);
489    clear_ciphertext!(anthropic);
490    clear_ciphertext!(gemini);
491    clear_ciphertext!(bodhi);
492
493    for id in intents.provider_instances.iter() {
494        if let Some(instance) = merged.provider_instances.get_mut(id) {
495            if instance.api_key.trim().is_empty() {
496                instance.api_key_encrypted = None;
497            }
498        }
499    }
500}
501
502/// Extract notification-channel secret update intents (ntfy `token`, Bark
503/// `device_key`) from a config patch.
504///
505/// Mirrors [`provider_api_key_intents`]: masked placeholders are ignored
506/// (they signal "keep existing secret"); an explicit empty string OR an
507/// explicit JSON `null` (#505) is a genuine intent (a clear), same as a
508/// genuine new value (a set) — see [`is_secret_field_intent`]. Must be
509/// read from the patch AFTER [`preserve_masked_notification_secrets`] has
510/// resolved masked placeholders — same calling convention as the provider
511/// intents (#521).
512#[derive(Debug, Clone, Default, PartialEq, Eq)]
513pub struct NotificationSecretIntents {
514    pub ntfy_token: bool,
515    pub bark_device_key: bool,
516}
517
518pub fn notification_secret_intents(patch_obj: &Map<String, Value>) -> NotificationSecretIntents {
519    let mut intents = NotificationSecretIntents::default();
520
521    let Some(notifications) = patch_obj.get("notifications").and_then(|v| v.as_object()) else {
522        return intents;
523    };
524
525    if let Some(ntfy) = notifications.get("ntfy").and_then(|v| v.as_object()) {
526        intents.ntfy_token = is_secret_field_intent(ntfy, "token");
527    }
528
529    if let Some(bark) = notifications.get("bark").and_then(|v| v.as_object()) {
530        intents.bark_device_key = is_secret_field_intent(bark, "device_key");
531    }
532
533    intents
534}
535
536/// Make an explicit `token: ""` / `device_key: ""` clear actually clear.
537///
538/// Generalizes [`clear_provider_ciphertext_for_explicit_clears`] (#521) to
539/// notification-channel secrets: `notifications.ntfy.token_encrypted` /
540/// `notifications.bark.device_key_encrypted` are NOT `skip_serializing` (only
541/// `skip_serializing_if = "Option::is_none"`), so the merge round-trip in
542/// `config_manager::build_merged_config` carries the live config's ciphertext
543/// straight into the merged value even when the patch explicitly cleared the
544/// plaintext. `hydrate_notifications_from_encrypted` would then refill the
545/// plaintext from that ciphertext, and the subsequent
546/// `Config::refresh_notifications_encrypted` (which deliberately preserves
547/// ciphertext when plaintext is empty, #268) would leave it in place —
548/// silently undoing the clear. For every field the patch explicitly touched
549/// whose merged plaintext is empty (a clear, not a set), drop the
550/// round-tripped ciphertext BEFORE hydration so nothing refills it.
551pub fn clear_notification_ciphertext_for_explicit_clears(
552    merged: &mut Config,
553    intents: &NotificationSecretIntents,
554) {
555    if intents.ntfy_token
556        && merged
557            .notifications
558            .ntfy
559            .token
560            .as_deref()
561            .unwrap_or("")
562            .trim()
563            .is_empty()
564    {
565        merged.notifications.ntfy.token_encrypted = None;
566    }
567
568    if intents.bark_device_key
569        && merged
570            .notifications
571            .bark
572            .device_key
573            .as_deref()
574            .unwrap_or("")
575            .trim()
576            .is_empty()
577    {
578        merged.notifications.bark.device_key_encrypted = None;
579    }
580}
581
582/// Extract bamboo-connect platform secret update intents (`token`,
583/// `app_secret`) from a config patch, keyed by the platform's position in the
584/// patch's `connect.platforms` array.
585///
586/// `connect.platforms` is a full-array replace (the settings UI always
587/// round-trips the whole list back in the same order it was fetched in — see
588/// [`preserve_masked_connect_secrets`]'s module docs), so
589/// `config_manager::build_merged_config`'s merged `connect.platforms[i]`
590/// always originates verbatim from the patch's `connect.platforms[i]` —
591/// position `i` in the patch and position `i` in the merged config always
592/// refer to the same logical entry, regardless of how that entry's position
593/// relates to `current.connect.platforms` (id/type resolution, #490/#492/#496,
594/// happens earlier and only rewrites the VALUE at a given patch index, never
595/// its position). Masked placeholders are ignored — mirrors
596/// [`provider_api_key_intents`]; an explicit `null` is treated the same as
597/// an explicit `""` clear (#505) — see [`is_secret_field_intent`]. Must be
598/// read from the patch AFTER [`preserve_masked_connect_secrets`] has
599/// resolved masked placeholders (#521).
600#[derive(Debug, Clone, Default, PartialEq, Eq)]
601pub struct ConnectSecretIntents {
602    pub token: std::collections::BTreeSet<usize>,
603    pub app_secret: std::collections::BTreeSet<usize>,
604}
605
606pub fn connect_secret_intents(patch_obj: &Map<String, Value>) -> ConnectSecretIntents {
607    let mut intents = ConnectSecretIntents::default();
608
609    let Some(platforms) = patch_obj
610        .get("connect")
611        .and_then(|c| c.get("platforms"))
612        .and_then(|v| v.as_array())
613    else {
614        return intents;
615    };
616
617    for (index, platform) in platforms.iter().enumerate() {
618        let Some(obj) = platform.as_object() else {
619            continue;
620        };
621
622        if is_secret_field_intent(obj, "token") {
623            intents.token.insert(index);
624        }
625
626        if is_secret_field_intent(obj, "app_secret") {
627            intents.app_secret.insert(index);
628        }
629    }
630
631    intents
632}
633
634/// Make an explicit `token: ""` / `app_secret: ""` clear actually clear.
635///
636/// Generalizes [`clear_provider_ciphertext_for_explicit_clears`] (#521) to
637/// bamboo-connect platform secrets — same rationale as
638/// [`clear_notification_ciphertext_for_explicit_clears`]:
639/// `token_encrypted`/`app_secret_encrypted` are not `skip_serializing`, so an
640/// explicit clear's round-tripped ciphertext must be dropped BEFORE
641/// hydration or it silently refills the plaintext hydration cleared.
642pub fn clear_connect_ciphertext_for_explicit_clears(
643    merged: &mut Config,
644    intents: &ConnectSecretIntents,
645) {
646    for &index in intents.token.iter() {
647        if let Some(platform) = merged.connect.platforms.get_mut(index) {
648            if platform.token.as_deref().unwrap_or("").trim().is_empty() {
649                platform.token_encrypted = None;
650            }
651        }
652    }
653
654    for &index in intents.app_secret.iter() {
655        if let Some(platform) = merged.connect.platforms.get_mut(index) {
656            if platform
657                .app_secret
658                .as_deref()
659                .unwrap_or("")
660                .trim()
661                .is_empty()
662            {
663                platform.app_secret_encrypted = None;
664            }
665        }
666    }
667}
668
669/// Replace masked notification-channel secret placeholders (ntfy `token`, Bark
670/// `device_key`) in a patch with the current config's plaintext values.
671///
672/// Mirrors [`preserve_masked_provider_api_keys`]: the UI sends the masked
673/// placeholder to mean "do not change this secret"; this resolves that back to
674/// the live plaintext so the merge doesn't wipe it. A masked value with no
675/// existing plaintext (nothing configured yet) is dropped from the patch
676/// entirely, same as an unset key.
677pub fn preserve_masked_notification_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
678    let Some(notifications) = patch_obj
679        .get_mut("notifications")
680        .and_then(|v| v.as_object_mut())
681    else {
682        return;
683    };
684
685    if let Some(ntfy) = notifications
686        .get_mut("ntfy")
687        .and_then(|v| v.as_object_mut())
688    {
689        preserve_masked_secret_field(ntfy, "token", current.notifications.ntfy.token.as_deref());
690    }
691
692    if let Some(bark) = notifications
693        .get_mut("bark")
694        .and_then(|v| v.as_object_mut())
695    {
696        preserve_masked_secret_field(
697            bark,
698            "device_key",
699            current.notifications.bark.device_key.as_deref(),
700        );
701    }
702}
703
704/// Replace masked bamboo-connect platform `token` placeholders in a patch with
705/// the current config's plaintext values.
706///
707/// Mirrors [`preserve_masked_notification_secrets`]. `connect.platforms` is a
708/// list (not a single object like ntfy/bark), so each patch entry is resolved
709/// against a `current.connect.platforms` entry via three strategies, tried in
710/// order:
711///
712/// 1. **Id match (#496)** — if the patch entry carries an `id` and some entry
713///    in `current` has the same [`crate::ConnectPlatformConfig::id`] AND a
714///    consistent `platform_type`, that entry is authoritative. A stable
715///    server-assigned id unambiguously identifies the same logical platform
716///    regardless of position — this is the only strategy that correctly
717///    disambiguates two entries that share the same `platform_type` and have
718///    been reordered relative to each other (the scenario #490/#492's
719///    type+positional fallback cannot fully resolve; see the regression test
720///    `preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered`
721///    below). The id match is guarded by the same type-consistency check as
722///    strategy 2: a patch entry whose `type` DISAGREES with the id-matched
723///    entry's `platform_type` (e.g. a stale/reused id after a client-side
724///    bug, or a flow that repurposes an entry's identity for a different
725///    platform) must not inherit the differently-typed entry's secret — a
726///    Telegram bot token pasted into a Feishu adapter is never right. Such
727///    a mismatch falls through to strategies 2/3 (which will also refuse a
728///    cross-type resolution, dropping the mask — same as "nothing configured
729///    yet"). A patch entry with an `id` but no `type` at all can't be
730///    checked and resolves on id alone, mirroring strategy 2's handling of
731///    a missing `type`. Legacy entries without an id (or a patch from a
732///    client that doesn't echo it back) simply fall through to strategy 2.
733/// 2. **Positional + type guard** — patch index `i` is resolved against
734///    `current.connect.platforms[i]`, guarded by `type` equality at that
735///    index. This mirrors how the settings UI round-trips the list (it
736///    always sends the full array back in the same order it was fetched in
737///    — the same convention `env_vars`' full-array replace relies on).
738/// 3. **Type-based fallback (#490)** — when the positional entry's type
739///    disagrees with the patch entry's `type` (or the patch index is beyond
740///    `current.connect.platforms`), fall back to
741///    `current.connect.platforms.iter().find(|p| p.platform_type == patch_type)`.
742///    This is safe because `multi_bot_guard` (#462) means only the FIRST
743///    entry of a given type is ever started, so resolving to any same-typed
744///    entry is strictly better than silently wiping the secret. Only when no
745///    entry of that type exists anywhere in `current` does the mask get
746///    dropped, same as "nothing configured yet".
747pub fn preserve_masked_connect_secrets(patch_obj: &mut Map<String, Value>, current: &Config) {
748    let Some(platforms) = patch_obj
749        .get_mut("connect")
750        .and_then(|c| c.get_mut("platforms"))
751        .and_then(|v| v.as_array_mut())
752    else {
753        return;
754    };
755
756    for (index, platform) in platforms.iter_mut().enumerate() {
757        let Some(obj) = platform.as_object_mut() else {
758            continue;
759        };
760        let patch_type = obj.get("type").and_then(|v| v.as_str());
761        let patch_id = obj.get("id").and_then(|v| v.as_str());
762
763        // Strategy 1 (#496): an id match is position-independent, but it is
764        // still guarded by the SAME type-consistency check strategies 2/3
765        // enforce — an id pointing at a differently-typed entry (stale or
766        // repurposed id) must not leak that entry's secret into an adapter
767        // of another type. A patch entry with no "type" can't be checked and
768        // resolves on id alone (mirrors strategy 2's missing-"type" case).
769        let by_id = patch_id.and_then(|patch_id| {
770            current.connect.platforms.iter().find(|p| {
771                p.id.as_deref() == Some(patch_id)
772                    && match patch_type {
773                        Some(patch_type) => patch_type == p.platform_type,
774                        None => true,
775                    }
776            })
777        });
778
779        // Strategies 2/3 (#490/#492): positional + type guard, falling back
780        // to a type-only lookup — unchanged, and only consulted when there
781        // was no id, the id didn't match anything in `current` (e.g. a
782        // stale id echoed after the entry was removed), or the id-matched
783        // entry failed the type-consistency guard above.
784        let existing = current.connect.platforms.get(index);
785        // A patch entry that names a "type" disagreeing with `current`'s
786        // entry at the same index (or an index beyond `current`'s array)
787        // means the array was reordered/shrunk since the client fetched it
788        // — the position no longer identifies the same logical platform, so
789        // don't resolve the mask against it positionally. A patch entry with
790        // no "type" at all (shouldn't happen with a well-behaved client,
791        // which always echoes the whole object back) can't be checked and
792        // falls back to the pre-existing positional-only behavior (no
793        // type-based fallback either, since there's no type to search by).
794        let guarded = by_id.or_else(|| {
795            existing
796                .filter(|p| match patch_type {
797                    Some(patch_type) => patch_type == p.platform_type,
798                    None => true,
799                })
800                .or_else(|| {
801                    patch_type.and_then(|patch_type| {
802                        current
803                            .connect
804                            .platforms
805                            .iter()
806                            .find(|p| p.platform_type == patch_type)
807                    })
808                })
809        });
810        preserve_masked_secret_field(obj, "token", guarded.and_then(|p| p.token.as_deref()));
811        preserve_masked_secret_field(
812            obj,
813            "app_secret",
814            guarded.and_then(|p| p.app_secret.as_deref()),
815        );
816    }
817}
818
819/// Resolve a single masked secret field in place: replace a masked placeholder
820/// with `existing_plain`, or drop the field if nothing is configured yet.
821/// A non-masked value (a genuine new secret, or an explicit empty-string
822/// clear) is left untouched.
823fn preserve_masked_secret_field(
824    obj: &mut Map<String, Value>,
825    field: &str,
826    existing_plain: Option<&str>,
827) {
828    let Some(value) = obj.get(field).and_then(|v| v.as_str()) else {
829        return;
830    };
831    if !is_masked_api_key(value) {
832        return;
833    }
834
835    match existing_plain {
836        Some(plain) if !plain.trim().is_empty() => {
837            obj.insert(field.to_string(), Value::String(plain.to_string()));
838        }
839        _ => {
840            obj.remove(field);
841        }
842    }
843}
844
845/// Deep merge `src` into `dst`, recursively combining objects and replacing leaf
846/// values — [RFC 7386 JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7386)
847/// semantics, generalized to arbitrary depth (#505).
848///
849/// ## Semantics (opt-in per value)
850///
851/// | Patch value at key `k`                        | Effect on `dst`                                    |
852/// |------------------------------------------------|-----------------------------------------------------|
853/// | key `k` absent from the patch object            | `dst[k]` unchanged (back-compat, unaffected by this)|
854/// | `k: null`                                       | `dst[k]` **removed** — see below for what that means|
855/// | `k: <object>`, `dst[k]` also an object          | recursively merged (this table applied one level down)|
856/// | `k: <object>`, `dst[k]` absent/non-object       | `dst[k]` set verbatim to the patch object            |
857/// | `k: <scalar \| array>`                          | `dst[k]` replaced verbatim (arrays are leaf values — RFC 7386 never merges into an array; a `null` *inside* an array is a literal element, not a delete marker)|
858///
859/// Removing `dst[k]` (the `null` row) has an effect that depends on what `k`
860/// deserializes into on the Rust side, because "removed" means the merged
861/// JSON object no longer carries that key at all — the subsequent
862/// `serde_json::from_value::<Config>` falls back to that field's own
863/// `#[serde(default)]`:
864/// - `Option<T>` field → default is `None` → **the value is cleared/unset**.
865///   This is the fix issue #505 asks for (e.g. `subagents.claude_code_binary:
866///   null` un-sets a previously-written override).
867/// - `Vec<T>` / `HashMap<K, V>` field reached directly (i.e. `null` sits at
868///   the position of the *whole* collection, not one of its entries) →
869///   default is empty → **the whole collection resets to empty/default**.
870///   Per the design note in #505: a `null` *inside* an array never reaches
871///   this path (arrays are leaf-replaced, previous row), so "does a null
872///   inside an array delete that element" does not apply — only "does a
873///   null in place of the whole array delete the array" does, and the
874///   answer is yes.
875/// - Plain non-`Option` scalar/struct field with a `Default` impl → resets
876///   to that type's default (e.g. `notifications: null` resets the entire
877///   notifications subtree to defaults, `http_proxy: null` resets it to
878///   `""`).
879/// - A key one level inside a map keyed by dynamic strings (e.g.
880///   `provider_instances`, `mcpServers`) → since JSON can't distinguish a
881///   struct's named fields from a `HashMap`'s dynamic keys, the same rule
882///   removes just that one entry — **this is how a client deletes a single
883///   provider instance or MCP server** (`provider_instances: { "<id>": null
884///   }`).
885///
886/// A patch that wants to clear ONE field of a nested config (e.g. just
887/// `providers.openai.api_key`) should send `null` at that leaf, not at an
888/// enclosing object — `providers: { openai: null }` wipes the *entire*
889/// openai provider config (model, base_url, api_key, everything), not just
890/// the key. Both are valid, opt-in RFC 7386 semantics; callers choose their
891/// blast radius by choosing which level they null out.
892///
893/// ## Secret-field composition (must NOT be bypassed)
894///
895/// This function is intentionally unaware of which fields are secrets. The
896/// server layer (`config_manager::build_merged_config`) MUST extract secret
897/// clear/set *intents* from the RAW patch object (via
898/// [`provider_api_key_intents`], [`notification_secret_intents`],
899/// [`connect_secret_intents`]) **before** calling this function — those
900/// intent extractors now treat `null` on `api_key` / `token` / `device_key`
901/// / `app_secret` identically to an explicit `""`: both are a "clear"
902/// intent, distinct from an absent field ("leave alone") and from a masked
903/// placeholder ("keep existing"). This preserves the precedence chain
904/// established by #516/#517/#521/#522:
905/// 1. masked placeholder → resolved back to the current plaintext (a `null`
906///    is never masked — `is_masked_api_key` only matches strings — so this
907///    step is a no-op for a `null` clear, which is correct: nothing should
908///    resurrect a value the caller explicitly asked to delete).
909/// 2. explicit clear intent (`""` OR `null`) → the merge (this function)
910///    removes/empties the field, then `clear_*_ciphertext_for_explicit_clears`
911///    drops the round-tripped ciphertext so hydration can't refill it.
912/// 3. no intent at all (key absent from the patch) → `preserve_unpatched_*`
913///    carries the live secret forward across the serde round-trip.
914///
915/// Without step 2 recognizing `null` as a clear intent, a `null`-delete of a
916/// secret field would silently get UNDONE by step 3 (which only skips
917/// providers/instances the intents mark as explicitly touched) — the merge
918/// would drop the key, but `preserve_unpatched_provider_secrets` would then
919/// think the field was untouched and copy the old ciphertext straight back.
920pub fn deep_merge_json(dst: &mut Value, src: Value) {
921    match (dst, src) {
922        (Value::Object(dst_map), Value::Object(src_map)) => {
923            for (key, value) in src_map {
924                if value.is_null() {
925                    // RFC 7386: `null` deletes the member from the target
926                    // object. Absent from `dst` afterwards → the eventual
927                    // `serde_json::from_value` falls back to that field's
928                    // own `#[serde(default)]` (None for Option<T>, empty for
929                    // Vec/HashMap, the type default otherwise). A key that
930                    // was never in `dst` to begin with is simply a no-op,
931                    // matching RFC 7386 (deleting a non-existent member is
932                    // not an error).
933                    dst_map.remove(&key);
934                    continue;
935                }
936                match dst_map.get_mut(&key) {
937                    Some(existing) if existing.is_object() && value.is_object() => {
938                        deep_merge_json(existing, value);
939                    }
940                    _ => {
941                        dst_map.insert(key, value);
942                    }
943                }
944            }
945        }
946        (dst_slot, src_value) => {
947            *dst_slot = src_value;
948        }
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use serde_json::json;
956
957    #[test]
958    fn domains_for_root_patch_detects_proxy_and_provider() {
959        let patch = json!({
960            "provider": "openai",
961            "http_proxy": "http://proxy:8080",
962            "setup": { "completed": false },
963            "mcpServers": {}
964        });
965
966        let domains = domains_for_root_patch(patch.as_object().unwrap());
967        assert!(domains.provider);
968        assert!(domains.proxy);
969        assert!(domains.setup);
970        assert!(domains.mcp);
971    }
972
973    #[test]
974    fn domains_for_root_patch_detects_provider_instances() {
975        let patch = json!({
976            "provider_instances": {
977                "openai-work": { "provider_type": "openai" }
978            },
979            "default_provider_instance": "openai-work",
980            "defaults": {
981                "chat": { "provider": "openai-work", "model": "gpt-4o" }
982            },
983            "features": {
984                "provider_model_ref": true
985            }
986        });
987
988        let domains = domains_for_root_patch(patch.as_object().unwrap());
989        assert!(domains.provider);
990    }
991
992    #[test]
993    fn provider_api_key_intents_ignores_masked_placeholders() {
994        let patch = json!({
995            "providers": {
996                "openai": { "api_key": "****...****" },
997                "gemini": { "api_key": "sk-real" }
998            },
999            "provider_instances": {
1000                "work-openai": { "api_key": "****...****" },
1001                "personal-openai": { "api_key": "sk-live" }
1002            }
1003        });
1004        let intents = provider_api_key_intents(patch.as_object().unwrap());
1005        assert!(intents.providers.contains("gemini"));
1006        assert!(!intents.providers.contains("openai"));
1007        assert!(intents.provider_instances.contains("personal-openai"));
1008        assert!(!intents.provider_instances.contains("work-openai"));
1009    }
1010
1011    /// Build a provider instance via serde (the struct has no `Default`), so
1012    /// the tests stay robust to new fields.
1013    fn instance(api_key: &str, encrypted: Option<&str>) -> crate::ProviderInstanceConfig {
1014        serde_json::from_value(json!({
1015            "provider_type": "openai",
1016            "api_key": api_key,
1017            "api_key_encrypted": encrypted,
1018        }))
1019        .expect("valid instance")
1020    }
1021
1022    #[test]
1023    fn preserve_unpatched_provider_secrets_restores_roundtrip_dropped_keys() {
1024        // #516: the live config can hold a plaintext-only secret (ciphertext is
1025        // computed only on save_to_dir's save-time clone). The merge round-trip
1026        // drops the `skip_serializing` plaintext, leaving neither field.
1027        let mut current = Config::default();
1028        current
1029            .provider_instances
1030            .insert("uuid-1".to_string(), instance("sk-instance-live", None));
1031        current.providers.openai = Some(crate::OpenAIConfig {
1032            api_key: "sk-legacy-live".to_string(),
1033            ..Default::default()
1034        });
1035
1036        // Simulate the post-round-trip merge result: both fields lost.
1037        let mut merged = Config::default();
1038        merged
1039            .provider_instances
1040            .insert("uuid-1".to_string(), instance("", None));
1041        merged.providers.openai = Some(crate::OpenAIConfig::default());
1042
1043        preserve_unpatched_provider_secrets(
1044            &mut merged,
1045            &current,
1046            &ProviderApiKeyIntents::default(),
1047        );
1048
1049        assert_eq!(
1050            merged.provider_instances["uuid-1"].api_key,
1051            "sk-instance-live"
1052        );
1053        assert_eq!(
1054            merged.providers.openai.as_ref().unwrap().api_key,
1055            "sk-legacy-live"
1056        );
1057    }
1058
1059    #[test]
1060    fn preserve_unpatched_provider_secrets_carries_ciphertext_only_keys() {
1061        // A key whose plaintext failed to hydrate (#268) must still carry its
1062        // ciphertext forward instead of being dropped.
1063        let mut current = Config::default();
1064        current
1065            .provider_instances
1066            .insert("uuid-1".to_string(), instance("", Some("preexisting-ct")));
1067
1068        let mut merged = Config::default();
1069        merged
1070            .provider_instances
1071            .insert("uuid-1".to_string(), instance("", None));
1072
1073        preserve_unpatched_provider_secrets(
1074            &mut merged,
1075            &current,
1076            &ProviderApiKeyIntents::default(),
1077        );
1078
1079        assert_eq!(
1080            merged.provider_instances["uuid-1"]
1081                .api_key_encrypted
1082                .as_deref(),
1083            Some("preexisting-ct")
1084        );
1085    }
1086
1087    #[test]
1088    fn preserve_unpatched_provider_secrets_respects_explicit_intents() {
1089        // A provider/instance the patch explicitly set or cleared must not have
1090        // the old key resurrected.
1091        let mut current = Config::default();
1092        current
1093            .provider_instances
1094            .insert("uuid-1".to_string(), instance("sk-old", None));
1095
1096        let mut merged = Config::default();
1097        merged
1098            .provider_instances
1099            .insert("uuid-1".to_string(), instance("", None));
1100
1101        let mut intents = ProviderApiKeyIntents::default();
1102        intents.provider_instances.insert("uuid-1".to_string());
1103
1104        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
1105
1106        let cleared = &merged.provider_instances["uuid-1"];
1107        assert!(cleared.api_key.is_empty(), "explicit clear must win");
1108        assert!(cleared.api_key_encrypted.is_none());
1109    }
1110
1111    #[test]
1112    fn clear_provider_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1113        // Merged state right after deserialization of a clear patch: empty
1114        // plaintext from the patch, ciphertext carried over by the round-trip
1115        // of the live config. Hydration must find nothing to refill.
1116        let mut merged = Config::default();
1117        merged
1118            .provider_instances
1119            .insert("uuid-1".to_string(), instance("", Some("roundtripped-ct")));
1120        merged.providers.openai = Some(crate::OpenAIConfig {
1121            api_key_encrypted: Some("legacy-ct".to_string()),
1122            ..Default::default()
1123        });
1124
1125        let mut intents = ProviderApiKeyIntents::default();
1126        intents.provider_instances.insert("uuid-1".to_string());
1127        intents.providers.insert("openai".to_string());
1128
1129        clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1130
1131        assert!(merged.provider_instances["uuid-1"]
1132            .api_key_encrypted
1133            .is_none());
1134        assert!(merged
1135            .providers
1136            .openai
1137            .as_ref()
1138            .unwrap()
1139            .api_key_encrypted
1140            .is_none());
1141    }
1142
1143    #[test]
1144    fn clear_provider_ciphertext_leaves_set_intents_and_unpatched_alone() {
1145        // A SET intent (non-empty merged plaintext) keeps its ciphertext (the
1146        // later sync overwrites it), and an instance without any intent is
1147        // untouched.
1148        let mut merged = Config::default();
1149        merged
1150            .provider_instances
1151            .insert("uuid-1".to_string(), instance("sk-new", Some("stale-ct")));
1152        merged
1153            .provider_instances
1154            .insert("uuid-2".to_string(), instance("", Some("kept-ct")));
1155
1156        let mut intents = ProviderApiKeyIntents::default();
1157        intents.provider_instances.insert("uuid-1".to_string());
1158
1159        clear_provider_ciphertext_for_explicit_clears(&mut merged, &intents);
1160
1161        assert!(merged.provider_instances["uuid-1"]
1162            .api_key_encrypted
1163            .is_some());
1164        assert_eq!(
1165            merged.provider_instances["uuid-2"]
1166                .api_key_encrypted
1167                .as_deref(),
1168            Some("kept-ct")
1169        );
1170    }
1171
1172    // ── #521: notification-secret clear intent ─────────────────────────
1173
1174    #[test]
1175    fn notification_secret_intents_ignores_masked_placeholders() {
1176        let patch = json!({
1177            "notifications": {
1178                "ntfy": { "token": "****...****" },
1179                "bark": { "device_key": "tk-real-new-value" }
1180            }
1181        });
1182        let intents = notification_secret_intents(patch.as_object().unwrap());
1183        assert!(!intents.ntfy_token, "masked placeholder is not an intent");
1184        assert!(intents.bark_device_key, "a real value is an intent");
1185    }
1186
1187    #[test]
1188    fn notification_secret_intents_detects_explicit_clear() {
1189        let patch = json!({
1190            "notifications": {
1191                "ntfy": { "token": "" },
1192                "bark": { "device_key": "" }
1193            }
1194        });
1195        let intents = notification_secret_intents(patch.as_object().unwrap());
1196        assert!(intents.ntfy_token, "empty string is a clear intent");
1197        assert!(intents.bark_device_key, "empty string is a clear intent");
1198    }
1199
1200    #[test]
1201    fn notification_secret_intents_empty_when_untouched() {
1202        let patch = json!({ "notifications": { "ntfy": { "enabled": true } } });
1203        let intents = notification_secret_intents(patch.as_object().unwrap());
1204        assert!(!intents.ntfy_token);
1205        assert!(!intents.bark_device_key);
1206    }
1207
1208    #[test]
1209    fn clear_notification_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1210        // Merged state right after deserialization of a clear patch: empty
1211        // plaintext from the patch, ciphertext carried over by the round-trip
1212        // of the live config. Hydration must find nothing to refill (#521).
1213        let mut merged = Config::default();
1214        merged.notifications.ntfy.token = None;
1215        merged.notifications.ntfy.token_encrypted = Some("roundtripped-ntfy-ct".to_string());
1216        merged.notifications.bark.device_key = None;
1217        merged.notifications.bark.device_key_encrypted = Some("roundtripped-bark-ct".to_string());
1218
1219        let intents = NotificationSecretIntents {
1220            ntfy_token: true,
1221            bark_device_key: true,
1222        };
1223        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1224
1225        assert!(merged.notifications.ntfy.token_encrypted.is_none());
1226        assert!(merged.notifications.bark.device_key_encrypted.is_none());
1227    }
1228
1229    #[test]
1230    fn clear_notification_ciphertext_leaves_set_intents_and_unpatched_alone() {
1231        let mut merged = Config::default();
1232        // A SET intent (non-empty merged plaintext) keeps its ciphertext — the
1233        // later refresh overwrites it with the new value's encryption.
1234        merged.notifications.ntfy.token = Some("brand-new-token".to_string());
1235        merged.notifications.ntfy.token_encrypted = Some("stale-ct".to_string());
1236        // No intent at all: untouched regardless of plaintext/ciphertext state.
1237        merged.notifications.bark.device_key = None;
1238        merged.notifications.bark.device_key_encrypted = Some("kept-ct".to_string());
1239
1240        let intents = NotificationSecretIntents {
1241            ntfy_token: true,
1242            bark_device_key: false,
1243        };
1244        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
1245
1246        assert_eq!(
1247            merged.notifications.ntfy.token_encrypted.as_deref(),
1248            Some("stale-ct")
1249        );
1250        assert_eq!(
1251            merged.notifications.bark.device_key_encrypted.as_deref(),
1252            Some("kept-ct")
1253        );
1254    }
1255
1256    // ── #521: connect-secret clear intent ───────────────────────────────
1257
1258    #[test]
1259    fn connect_secret_intents_ignores_masked_and_detects_clear_by_position() {
1260        let patch = json!({
1261            "connect": {
1262                "platforms": [
1263                    { "type": "telegram", "token": "****...****" },
1264                    { "type": "telegram", "token": "" },
1265                    { "type": "feishu", "app_secret": "real-new-secret" }
1266                ]
1267            }
1268        });
1269        let intents = connect_secret_intents(patch.as_object().unwrap());
1270        assert!(
1271            !intents.token.contains(&0),
1272            "masked placeholder is not an intent"
1273        );
1274        assert!(intents.token.contains(&1), "empty string is a clear intent");
1275        assert!(intents.app_secret.contains(&2), "a real value is an intent");
1276    }
1277
1278    #[test]
1279    fn connect_secret_intents_empty_when_no_platforms_patched() {
1280        let patch = json!({ "http_proxy": "http://example.invalid:8080" });
1281        let intents = connect_secret_intents(patch.as_object().unwrap());
1282        assert!(intents.token.is_empty());
1283        assert!(intents.app_secret.is_empty());
1284    }
1285
1286    #[test]
1287    fn clear_connect_ciphertext_drops_roundtripped_ciphertext_on_clear_intents() {
1288        // Merged state right after deserialization of a clear patch: empty
1289        // plaintext, ciphertext carried over by the round-trip of the live
1290        // config at the SAME position the patch's full-array-replace put it
1291        // (#521).
1292        let mut merged = Config::default();
1293        merged.connect.platforms = vec![
1294            connect_platform("telegram", ""), // will be overwritten below
1295            feishu_platform(""),
1296        ];
1297        merged.connect.platforms[0].token = None;
1298        merged.connect.platforms[0].token_encrypted = Some("roundtripped-token-ct".to_string());
1299        merged.connect.platforms[1].app_secret = None;
1300        merged.connect.platforms[1].app_secret_encrypted =
1301            Some("roundtripped-secret-ct".to_string());
1302
1303        let mut intents = ConnectSecretIntents::default();
1304        intents.token.insert(0);
1305        intents.app_secret.insert(1);
1306
1307        clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1308
1309        assert!(merged.connect.platforms[0].token_encrypted.is_none());
1310        assert!(merged.connect.platforms[1].app_secret_encrypted.is_none());
1311    }
1312
1313    #[test]
1314    fn clear_connect_ciphertext_leaves_set_intents_and_unpatched_alone() {
1315        let mut merged = Config::default();
1316        merged.connect.platforms = vec![connect_platform("telegram", "brand-new-token")];
1317        merged.connect.platforms[0].token_encrypted = Some("stale-ct".to_string());
1318        // A second platform with no clear intent (untouched by the patch)
1319        // must keep its ciphertext regardless of its plaintext state.
1320        merged
1321            .connect
1322            .platforms
1323            .push(connect_platform("feishu", ""));
1324        merged.connect.platforms[1].token = None;
1325        merged.connect.platforms[1].token_encrypted = Some("kept-ct".to_string());
1326
1327        let mut intents = ConnectSecretIntents::default();
1328        intents.token.insert(0);
1329
1330        clear_connect_ciphertext_for_explicit_clears(&mut merged, &intents);
1331
1332        assert_eq!(
1333            merged.connect.platforms[0].token_encrypted.as_deref(),
1334            Some("stale-ct")
1335        );
1336        assert_eq!(
1337            merged.connect.platforms[1].token_encrypted.as_deref(),
1338            Some("kept-ct")
1339        );
1340    }
1341
1342    #[test]
1343    fn is_masked_api_key_requires_placeholder_only_values() {
1344        // The redaction placeholder and all-asterisk/dot variants are masked.
1345        assert!(is_masked_api_key("****...****"));
1346        assert!(is_masked_api_key("********"));
1347        assert!(is_masked_api_key("  ****...****  "));
1348
1349        // Empty is a "clear" signal, not a mask.
1350        assert!(!is_masked_api_key(""));
1351        assert!(!is_masked_api_key("   "));
1352
1353        // A placeholder with a real key pasted after it must NOT be treated as
1354        // masked — that silently discards the user's new token (#430).
1355        assert!(!is_masked_api_key("****...****sk-newkey123"));
1356        assert!(!is_masked_api_key("sk-newkey123****...****"));
1357
1358        // Real keys containing dots or asterisks among other characters are keys.
1359        assert!(!is_masked_api_key("id.secret...suffix"));
1360        assert!(!is_masked_api_key("sk-live-abc"));
1361    }
1362
1363    #[test]
1364    fn sanitize_root_patch_strips_notification_encrypted_fields() {
1365        let mut patch = json!({
1366            "notifications": {
1367                "ntfy": { "token": "new-token", "token_encrypted": "client-supplied-cipher" },
1368                "bark": { "device_key": "new-key", "device_key_encrypted": "client-supplied-cipher" }
1369            }
1370        });
1371        let obj = patch.as_object_mut().unwrap();
1372        sanitize_root_patch(obj);
1373
1374        assert!(!obj["notifications"]["ntfy"]
1375            .as_object()
1376            .unwrap()
1377            .contains_key("token_encrypted"));
1378        assert!(!obj["notifications"]["bark"]
1379            .as_object()
1380            .unwrap()
1381            .contains_key("device_key_encrypted"));
1382        // Plaintext fields the client legitimately sent are untouched.
1383        assert_eq!(obj["notifications"]["ntfy"]["token"], "new-token");
1384        assert_eq!(obj["notifications"]["bark"]["device_key"], "new-key");
1385    }
1386
1387    #[test]
1388    fn preserve_masked_notification_secrets_keeps_existing_plaintext() {
1389        let mut current = Config::default();
1390        current.notifications.ntfy.token = Some("existing-ntfy-token".to_string());
1391        current.notifications.bark.device_key = Some("existing-bark-key".to_string());
1392
1393        let mut patch: Map<String, Value> = serde_json::from_str(
1394            r#"{"notifications":{"ntfy":{"token":"****...****"},"bark":{"device_key":"****...****"}}}"#,
1395        )
1396        .unwrap();
1397
1398        preserve_masked_notification_secrets(&mut patch, &current);
1399
1400        assert_eq!(
1401            patch["notifications"]["ntfy"]["token"],
1402            "existing-ntfy-token"
1403        );
1404        assert_eq!(
1405            patch["notifications"]["bark"]["device_key"],
1406            "existing-bark-key"
1407        );
1408    }
1409
1410    #[test]
1411    fn preserve_masked_notification_secrets_drops_mask_when_nothing_configured() {
1412        let current = Config::default();
1413        let mut patch: Map<String, Value> =
1414            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"****...****"}}}"#).unwrap();
1415
1416        preserve_masked_notification_secrets(&mut patch, &current);
1417
1418        assert!(!patch["notifications"]["ntfy"]
1419            .as_object()
1420            .unwrap()
1421            .contains_key("token"));
1422    }
1423
1424    #[test]
1425    fn preserve_masked_notification_secrets_leaves_real_values_untouched() {
1426        let current = Config::default();
1427        let mut patch: Map<String, Value> =
1428            serde_json::from_str(r#"{"notifications":{"ntfy":{"token":"tk-real-new-value"}}}"#)
1429                .unwrap();
1430
1431        preserve_masked_notification_secrets(&mut patch, &current);
1432
1433        assert_eq!(patch["notifications"]["ntfy"]["token"], "tk-real-new-value");
1434    }
1435
1436    fn connect_platform(platform_type: &str, token: &str) -> crate::ConnectPlatformConfig {
1437        crate::ConnectPlatformConfig {
1438            id: None,
1439            platform_type: platform_type.to_string(),
1440            token: Some(token.to_string()),
1441            token_encrypted: None,
1442            app_id: None,
1443            app_secret: None,
1444            app_secret_encrypted: None,
1445            domain: None,
1446            allow_from: Vec::new(),
1447            admin_from: Vec::new(),
1448        }
1449    }
1450
1451    fn connect_platform_with_id(
1452        id: &str,
1453        platform_type: &str,
1454        token: &str,
1455    ) -> crate::ConnectPlatformConfig {
1456        crate::ConnectPlatformConfig {
1457            id: Some(id.to_string()),
1458            ..connect_platform(platform_type, token)
1459        }
1460    }
1461
1462    #[test]
1463    fn sanitize_root_patch_strips_connect_platform_encrypted_field() {
1464        let mut patch = json!({
1465            "connect": {
1466                "platforms": [
1467                    { "type": "telegram", "token": "new-token", "token_encrypted": "client-supplied-cipher" }
1468                ]
1469            }
1470        });
1471        let obj = patch.as_object_mut().unwrap();
1472        sanitize_root_patch(obj);
1473
1474        let platform = &obj["connect"]["platforms"][0];
1475        assert!(!platform
1476            .as_object()
1477            .unwrap()
1478            .contains_key("token_encrypted"));
1479        assert_eq!(platform["token"], "new-token");
1480    }
1481
1482    #[test]
1483    fn sanitize_root_patch_strips_connect_platform_app_secret_encrypted_field() {
1484        let mut patch = json!({
1485            "connect": {
1486                "platforms": [
1487                    {
1488                        "type": "feishu",
1489                        "app_id": "cli_x",
1490                        "app_secret": "new-secret",
1491                        "app_secret_encrypted": "client-supplied-cipher",
1492                        "domain": "feishu"
1493                    }
1494                ]
1495            }
1496        });
1497        let obj = patch.as_object_mut().unwrap();
1498        sanitize_root_patch(obj);
1499
1500        let platform = &obj["connect"]["platforms"][0];
1501        assert!(!platform
1502            .as_object()
1503            .unwrap()
1504            .contains_key("app_secret_encrypted"));
1505        assert_eq!(platform["app_secret"], "new-secret");
1506        assert_eq!(platform["app_id"], "cli_x");
1507    }
1508
1509    #[test]
1510    fn preserve_masked_connect_secrets_keeps_existing_plaintext_by_position() {
1511        let mut current = Config::default();
1512        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1513
1514        let mut patch: Map<String, Value> = serde_json::from_str(
1515            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1516        )
1517        .unwrap();
1518
1519        preserve_masked_connect_secrets(&mut patch, &current);
1520
1521        assert_eq!(
1522            patch["connect"]["platforms"][0]["token"],
1523            "existing-bot-token"
1524        );
1525    }
1526
1527    #[test]
1528    fn preserve_masked_connect_secrets_drops_mask_when_nothing_configured() {
1529        let current = Config::default();
1530        let mut patch: Map<String, Value> = serde_json::from_str(
1531            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1532        )
1533        .unwrap();
1534
1535        preserve_masked_connect_secrets(&mut patch, &current);
1536
1537        assert!(!patch["connect"]["platforms"][0]
1538            .as_object()
1539            .unwrap()
1540            .contains_key("token"));
1541    }
1542
1543    #[test]
1544    fn preserve_masked_connect_secrets_leaves_real_values_untouched() {
1545        let current = Config::default();
1546        let mut patch: Map<String, Value> = serde_json::from_str(
1547            r#"{"connect":{"platforms":[{"type":"telegram","token":"tg-real-new-value"}]}}"#,
1548        )
1549        .unwrap();
1550
1551        preserve_masked_connect_secrets(&mut patch, &current);
1552
1553        assert_eq!(
1554            patch["connect"]["platforms"][0]["token"],
1555            "tg-real-new-value"
1556        );
1557    }
1558
1559    /// Issue #454 follow-up: if the array was reordered/edited since the
1560    /// client fetched it — detectable here because the patch entry's "type"
1561    /// disagrees with `current`'s entry at the same index — a masked token
1562    /// must NOT be resolved against the wrong (now co-located) platform's
1563    /// plaintext; it must drop, exactly like "nothing configured yet".
1564    #[test]
1565    fn preserve_masked_connect_secrets_drops_mask_when_type_at_index_disagrees() {
1566        let mut current = Config::default();
1567        current.connect.platforms = vec![connect_platform("telegram", "telegram-secret-token")];
1568
1569        // The patch's entry at index 0 claims to be a DIFFERENT platform type
1570        // than what's actually at `current.connect.platforms[0]` — e.g. a
1571        // reorder/insert raced the fetch that pre-filled this mask.
1572        let mut patch: Map<String, Value> = serde_json::from_str(
1573            r#"{"connect":{"platforms":[{"type":"feishu","token":"****...****"}]}}"#,
1574        )
1575        .unwrap();
1576
1577        preserve_masked_connect_secrets(&mut patch, &current);
1578
1579        assert!(
1580            !patch["connect"]["platforms"][0]
1581                .as_object()
1582                .unwrap()
1583                .contains_key("token"),
1584            "masked token must not be resolved against a different platform's secret"
1585        );
1586    }
1587
1588    /// #490: when the type at an index mismatches, the guard now falls back
1589    /// to a type-based lookup across all of `current.connect.platforms`
1590    /// rather than dropping outright. Here index 1's patch entry claims
1591    /// "telegram" (matching index 0's type, not index 1's) — the mismatch at
1592    /// index 1 is detected, but since a "telegram" entry DOES exist
1593    /// elsewhere in `current` (at index 0), the mask resolves to it. This is
1594    /// safe because `multi_bot_guard` (#462) only ever starts the first
1595    /// entry of a given type, so resolving to any same-typed entry is
1596    /// strictly better than wiping the secret.
1597    #[test]
1598    fn preserve_masked_connect_secrets_type_mismatch_falls_back_to_type_lookup() {
1599        let mut current = Config::default();
1600        current.connect.platforms = vec![
1601            connect_platform("telegram", "bot-a-token"),
1602            connect_platform("feishu", "feishu-token"),
1603        ];
1604
1605        let mut patch: Map<String, Value> = serde_json::from_str(
1606            r#"{"connect":{"platforms":[
1607                {"type":"telegram","token":"tg-real-value"},
1608                {"type":"telegram","token":"****...****"}
1609            ]}}"#,
1610        )
1611        .unwrap();
1612
1613        preserve_masked_connect_secrets(&mut patch, &current);
1614
1615        assert_eq!(patch["connect"]["platforms"][0]["token"], "tg-real-value");
1616        assert_eq!(
1617            patch["connect"]["platforms"][1]["token"], "bot-a-token",
1618            "index 1's mismatched type must fall back to the same-typed entry found elsewhere in current"
1619        );
1620    }
1621
1622    /// A patch entry with a matching "type" at its index is unaffected by
1623    /// the new guard — this is the common case and must keep working exactly
1624    /// as before.
1625    #[test]
1626    fn preserve_masked_connect_secrets_keeps_plaintext_when_type_at_index_matches() {
1627        let mut current = Config::default();
1628        current.connect.platforms = vec![
1629            connect_platform("telegram", "bot-a-token"),
1630            connect_platform("telegram", "bot-b-token"),
1631        ];
1632
1633        let mut patch: Map<String, Value> = serde_json::from_str(
1634            r#"{"connect":{"platforms":[
1635                {"type":"telegram","token":"****...****"},
1636                {"type":"telegram","token":"****...****"}
1637            ]}}"#,
1638        )
1639        .unwrap();
1640
1641        preserve_masked_connect_secrets(&mut patch, &current);
1642
1643        assert_eq!(patch["connect"]["platforms"][0]["token"], "bot-a-token");
1644        assert_eq!(patch["connect"]["platforms"][1]["token"], "bot-b-token");
1645    }
1646
1647    /// A patch entry missing "type" entirely (shouldn't happen with a
1648    /// well-behaved client) falls back to the pre-existing positional-only
1649    /// behavior rather than being treated as an automatic mismatch.
1650    #[test]
1651    fn preserve_masked_connect_secrets_falls_back_to_positional_when_type_is_absent() {
1652        let mut current = Config::default();
1653        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1654
1655        let mut patch: Map<String, Value> =
1656            serde_json::from_str(r#"{"connect":{"platforms":[{"token":"****...****"}]}}"#).unwrap();
1657
1658        preserve_masked_connect_secrets(&mut patch, &current);
1659
1660        assert_eq!(
1661            patch["connect"]["platforms"][0]["token"],
1662            "existing-bot-token"
1663        );
1664    }
1665
1666    /// #496 — the exact scenario the issue is about: two entries share the
1667    /// same `platform_type` ("telegram") AND have been reordered, so the
1668    /// type+positional guard alone can't tell they were swapped (the type at
1669    /// each index still matches "telegram" either way). Without an id, this
1670    /// class of reorder can attach the wrong sibling's secret. With a
1671    /// server-assigned id present on both, resolution is unambiguous
1672    /// regardless of position.
1673    #[test]
1674    fn preserve_masked_connect_secrets_resolves_duplicate_type_by_id_even_when_reordered() {
1675        let mut current = Config::default();
1676        current.connect.platforms = vec![
1677            connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1678            connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1679        ];
1680
1681        // The client echoes the array back SWAPPED: id-b now at index 0,
1682        // id-a now at index 1. Positional+type resolution alone would
1683        // (wrongly) resolve index 0 against current[0] (id-a's token) since
1684        // both are "telegram" — the id lets us catch the swap.
1685        let mut patch: Map<String, Value> = serde_json::from_str(
1686            r#"{"connect":{"platforms":[
1687                {"id":"id-b","type":"telegram","token":"****...****"},
1688                {"id":"id-a","type":"telegram","token":"****...****"}
1689            ]}}"#,
1690        )
1691        .unwrap();
1692
1693        preserve_masked_connect_secrets(&mut patch, &current);
1694
1695        assert_eq!(
1696            patch["connect"]["platforms"][0]["token"], "bot-b-token",
1697            "index 0 (id-b) must resolve to id-b's own token, not the positionally-co-located id-a"
1698        );
1699        assert_eq!(
1700            patch["connect"]["platforms"][1]["token"], "bot-a-token",
1701            "index 1 (id-a) must resolve to id-a's own token"
1702        );
1703    }
1704
1705    /// A patch entry whose `id` doesn't match anything in `current` (e.g. a
1706    /// brand-new entry a client invented an id for, or a stale id echoed
1707    /// after the entry was removed) falls through to the existing
1708    /// positional/type-based resolution rather than treating the mismatch
1709    /// as fatal.
1710    #[test]
1711    fn preserve_masked_connect_secrets_falls_back_when_id_not_found_in_current() {
1712        let mut current = Config::default();
1713        current.connect.platforms = vec![connect_platform("telegram", "existing-bot-token")];
1714
1715        let mut patch: Map<String, Value> = serde_json::from_str(
1716            r#"{"connect":{"platforms":[{"id":"unknown-id","type":"telegram","token":"****...****"}]}}"#,
1717        )
1718        .unwrap();
1719
1720        preserve_masked_connect_secrets(&mut patch, &current);
1721
1722        assert_eq!(
1723            patch["connect"]["platforms"][0]["token"], "existing-bot-token",
1724            "an id absent from `current` falls back to the positional+type guard"
1725        );
1726    }
1727
1728    /// PR #510 review: the id branch is guarded by the same type-consistency
1729    /// check as the positional branch. A patch entry whose `id` matches a
1730    /// `current` entry of a DIFFERENT `platform_type` (stale/reused id, or a
1731    /// flow repurposing an entry's identity for another platform) must NOT
1732    /// inherit that entry's secret — with no same-typed entry anywhere in
1733    /// `current`, the mask drops, same as "nothing configured yet".
1734    #[test]
1735    fn preserve_masked_connect_secrets_rejects_id_match_when_type_differs() {
1736        let mut current = Config::default();
1737        current.connect.platforms = vec![connect_platform_with_id(
1738            "id-a",
1739            "telegram",
1740            "telegram-secret-token",
1741        )];
1742
1743        // The patch reuses stored entry id-a's id but claims to be a Feishu
1744        // adapter — resolving the mask against the Telegram entry would paste
1745        // a Telegram bot token into a Feishu config.
1746        let mut patch: Map<String, Value> = serde_json::from_str(
1747            r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1748        )
1749        .unwrap();
1750
1751        preserve_masked_connect_secrets(&mut patch, &current);
1752
1753        assert!(
1754            !patch["connect"]["platforms"][0]
1755                .as_object()
1756                .unwrap()
1757                .contains_key("token"),
1758            "an id match with a disagreeing type must not resolve the mask from that entry"
1759        );
1760    }
1761
1762    /// The type-mismatched-id fall-through still reaches strategies 2/3: if a
1763    /// same-typed entry DOES exist elsewhere in `current`, the type-based
1764    /// fallback (#490) resolves the mask against it — the bad id only
1765    /// disqualifies the id branch, it doesn't poison the whole resolution.
1766    #[test]
1767    fn preserve_masked_connect_secrets_type_mismatched_id_still_falls_through_to_type_lookup() {
1768        let mut current = Config::default();
1769        current.connect.platforms = vec![
1770            connect_platform_with_id("id-a", "telegram", "telegram-secret-token"),
1771            connect_platform_with_id("id-b", "feishu", "feishu-secret-token"),
1772        ];
1773
1774        // id-a belongs to the telegram entry, but the patch entry says
1775        // "feishu" (at index 0, where current has telegram): the id branch
1776        // and the positional branch both refuse, and the type-based fallback
1777        // resolves against the feishu entry at index 1.
1778        let mut patch: Map<String, Value> = serde_json::from_str(
1779            r#"{"connect":{"platforms":[{"id":"id-a","type":"feishu","token":"****...****"}]}}"#,
1780        )
1781        .unwrap();
1782
1783        preserve_masked_connect_secrets(&mut patch, &current);
1784
1785        assert_eq!(
1786            patch["connect"]["platforms"][0]["token"], "feishu-secret-token",
1787            "the fall-through must resolve via the type lookup, never via the mismatched id"
1788        );
1789    }
1790
1791    /// A patch entry with an `id` but no `type` at all can't be
1792    /// type-checked and resolves on id alone — mirrors the positional
1793    /// branch's handling of a missing `type` (documented in the id-branch
1794    /// guard). Placed at an out-of-range index to prove it's the id doing
1795    /// the work, not position.
1796    #[test]
1797    fn preserve_masked_connect_secrets_id_without_type_resolves_on_id_alone() {
1798        let mut current = Config::default();
1799        current.connect.platforms = vec![
1800            connect_platform_with_id("id-a", "telegram", "bot-a-token"),
1801            connect_platform_with_id("id-b", "telegram", "bot-b-token"),
1802        ];
1803
1804        let mut patch: Map<String, Value> = serde_json::from_str(
1805            r#"{"connect":{"platforms":[{"id":"id-b","token":"****...****"}]}}"#,
1806        )
1807        .unwrap();
1808
1809        preserve_masked_connect_secrets(&mut patch, &current);
1810
1811        assert_eq!(
1812            patch["connect"]["platforms"][0]["token"], "bot-b-token",
1813            "with no type to check, the id match resolves to its own entry, not the positional one"
1814        );
1815    }
1816
1817    /// Back-compat: a patch entry with no "id" field at all (legacy client,
1818    /// or an entry created before ids existed) is unaffected by the new
1819    /// id-matching branch and goes straight to the existing
1820    /// positional/type-based resolution — exercised here with `current`
1821    /// entries that DO have ids (post-migration) to prove the id branch is
1822    /// skipped cleanly rather than erroring on the missing field.
1823    #[test]
1824    fn preserve_masked_connect_secrets_ignores_id_branch_when_patch_omits_id() {
1825        let mut current = Config::default();
1826        current.connect.platforms = vec![connect_platform_with_id(
1827            "id-a",
1828            "telegram",
1829            "existing-bot-token",
1830        )];
1831
1832        let mut patch: Map<String, Value> = serde_json::from_str(
1833            r#"{"connect":{"platforms":[{"type":"telegram","token":"****...****"}]}}"#,
1834        )
1835        .unwrap();
1836
1837        preserve_masked_connect_secrets(&mut patch, &current);
1838
1839        assert_eq!(
1840            patch["connect"]["platforms"][0]["token"],
1841            "existing-bot-token"
1842        );
1843    }
1844
1845    fn feishu_platform(app_secret: &str) -> crate::ConnectPlatformConfig {
1846        crate::ConnectPlatformConfig {
1847            id: None,
1848            platform_type: "feishu".to_string(),
1849            token: None,
1850            token_encrypted: None,
1851            app_id: Some("cli_x".to_string()),
1852            app_secret: Some(app_secret.to_string()),
1853            app_secret_encrypted: None,
1854            domain: Some("lark".to_string()),
1855            allow_from: Vec::new(),
1856            admin_from: Vec::new(),
1857        }
1858    }
1859
1860    #[test]
1861    fn preserve_masked_connect_secrets_keeps_existing_app_secret_by_position() {
1862        let mut current = Config::default();
1863        current.connect.platforms = vec![feishu_platform("existing-app-secret")];
1864
1865        let mut patch: Map<String, Value> = serde_json::from_str(
1866            r#"{"connect":{"platforms":[{"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}]}}"#,
1867        )
1868        .unwrap();
1869
1870        preserve_masked_connect_secrets(&mut patch, &current);
1871
1872        assert_eq!(
1873            patch["connect"]["platforms"][0]["app_secret"],
1874            "existing-app-secret"
1875        );
1876        // app_id/domain are untouched by the secret-preserve pass.
1877        assert_eq!(patch["connect"]["platforms"][0]["app_id"], "cli_x");
1878        assert_eq!(patch["connect"]["platforms"][0]["domain"], "lark");
1879    }
1880
1881    #[test]
1882    fn preserve_masked_connect_secrets_drops_app_secret_mask_when_nothing_configured() {
1883        let current = Config::default();
1884        let mut patch: Map<String, Value> = serde_json::from_str(
1885            r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"****...****"}]}}"#,
1886        )
1887        .unwrap();
1888
1889        preserve_masked_connect_secrets(&mut patch, &current);
1890
1891        assert!(!patch["connect"]["platforms"][0]
1892            .as_object()
1893            .unwrap()
1894            .contains_key("app_secret"));
1895    }
1896
1897    #[test]
1898    fn preserve_masked_connect_secrets_leaves_real_app_secret_untouched() {
1899        let current = Config::default();
1900        let mut patch: Map<String, Value> = serde_json::from_str(
1901            r#"{"connect":{"platforms":[{"type":"feishu","app_secret":"feishu-real-new-value"}]}}"#,
1902        )
1903        .unwrap();
1904
1905        preserve_masked_connect_secrets(&mut patch, &current);
1906
1907        assert_eq!(
1908            patch["connect"]["platforms"][0]["app_secret"],
1909            "feishu-real-new-value"
1910        );
1911    }
1912
1913    /// The #454 type-at-index guard applies to app_secret exactly as it does
1914    /// to token: a reordered array must not resolve a masked app_secret
1915    /// against whatever platform now sits at that index.
1916    #[test]
1917    fn preserve_masked_connect_secrets_drops_app_secret_mask_when_type_at_index_disagrees() {
1918        let mut current = Config::default();
1919        current.connect.platforms = vec![feishu_platform("feishu-secret")];
1920
1921        let mut patch: Map<String, Value> = serde_json::from_str(
1922            r#"{"connect":{"platforms":[{"type":"telegram","app_secret":"****...****"}]}}"#,
1923        )
1924        .unwrap();
1925
1926        preserve_masked_connect_secrets(&mut patch, &current);
1927
1928        assert!(
1929            !patch["connect"]["platforms"][0]
1930                .as_object()
1931                .unwrap()
1932                .contains_key("app_secret"),
1933            "masked app_secret must not be resolved against a different platform's secret"
1934        );
1935    }
1936
1937    /// #490 regression: the exact scenario from the issue. Stored platforms
1938    /// are `[telegram, feishu]`; the client disables telegram and echoes
1939    /// back only the (still-masked) feishu entry, which now shifts to index
1940    /// 0. The positional guard sees telegram≠feishu at index 0 and used to
1941    /// drop the mask outright, silently wiping the feishu app_secret on
1942    /// save. It must now fall back to a type-based lookup and resolve to the
1943    /// stored feishu plaintext.
1944    #[test]
1945    fn preserve_masked_connect_secrets_resolves_by_type_when_preceding_entry_removed() {
1946        let mut current = Config::default();
1947        current.connect.platforms = vec![
1948            connect_platform("telegram", "telegram-token"),
1949            feishu_platform("existing-app-secret"),
1950        ];
1951
1952        // telegram was removed client-side; only the feishu entry (still
1953        // masked) is echoed back, now at index 0.
1954        let mut patch: Map<String, Value> = serde_json::from_str(
1955            r#"{"connect":{"platforms":[
1956                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
1957            ]}}"#,
1958        )
1959        .unwrap();
1960
1961        preserve_masked_connect_secrets(&mut patch, &current);
1962
1963        assert_eq!(
1964            patch["connect"]["platforms"][0]["app_secret"], "existing-app-secret",
1965            "masked app_secret must resolve via type fallback after a preceding entry was removed"
1966        );
1967    }
1968
1969    /// #490: an index beyond `current.connect.platforms` (the patch array
1970    /// grew, e.g. a new platform was added client-side before saving) must
1971    /// also fall back to the type-based lookup rather than dropping the mask
1972    /// when a same-typed entry exists elsewhere in `current`.
1973    #[test]
1974    fn preserve_masked_connect_secrets_resolves_by_type_when_index_out_of_range() {
1975        let mut current = Config::default();
1976        current.connect.platforms = vec![
1977            connect_platform("telegram", "telegram-token"),
1978            feishu_platform("existing-app-secret"),
1979        ];
1980
1981        // Patch has 3 entries; index 2 is beyond `current`'s 2-entry array
1982        // (a new telegram entry was inserted at index 1 client-side), but
1983        // its masked feishu app_secret should still resolve by type.
1984        let mut patch: Map<String, Value> = serde_json::from_str(
1985            r#"{"connect":{"platforms":[
1986                {"type":"telegram","token":"tg-real-value"},
1987                {"type":"telegram","token":"new-bot-token"},
1988                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
1989            ]}}"#,
1990        )
1991        .unwrap();
1992
1993        preserve_masked_connect_secrets(&mut patch, &current);
1994
1995        assert_eq!(
1996            patch["connect"]["platforms"][2]["app_secret"], "existing-app-secret",
1997            "masked app_secret at an out-of-range index must resolve via type fallback"
1998        );
1999    }
2000
2001    /// #490: the type-based fallback only searches by type — if the patched
2002    /// type doesn't exist anywhere in `current`, the mask still drops, same
2003    /// as before. Unchanged behavior, exercised here with a multi-entry
2004    /// `current` (not just the empty-config case already covered above).
2005    #[test]
2006    fn preserve_masked_connect_secrets_drops_mask_when_type_absent_from_current_entirely() {
2007        let mut current = Config::default();
2008        current.connect.platforms = vec![connect_platform("telegram", "telegram-token")];
2009
2010        // No feishu entry exists anywhere in `current` — the type-based
2011        // fallback has nothing to resolve against.
2012        let mut patch: Map<String, Value> = serde_json::from_str(
2013            r#"{"connect":{"platforms":[
2014                {"type":"feishu","app_id":"cli_x","app_secret":"****...****","domain":"lark"}
2015            ]}}"#,
2016        )
2017        .unwrap();
2018
2019        preserve_masked_connect_secrets(&mut patch, &current);
2020
2021        assert!(
2022            !patch["connect"]["platforms"][0]
2023                .as_object()
2024                .unwrap()
2025                .contains_key("app_secret"),
2026            "mask must still drop when no entry of that type exists anywhere in current"
2027        );
2028    }
2029
2030    // ── #505: RFC 7386-style null-delete ────────────────────────────────
2031    //
2032    // Full round-trip helper: serialize `current`, deep-merge `patch` into
2033    // it, and deserialize the result back into a `Config` — exactly what
2034    // `config_manager::build_merged_config` does around `deep_merge_json`,
2035    // minus the secret-specific composition (covered separately below).
2036    fn merge_and_deserialize(current: &Config, patch: Value) -> Config {
2037        let mut merged = serde_json::to_value(current).unwrap();
2038        deep_merge_json(&mut merged, patch);
2039        serde_json::from_value(merged).expect("merged config should deserialize")
2040    }
2041
2042    #[test]
2043    fn null_deletes_option_scalar_leaf() {
2044        // The exact case #505 was filed for: an Option<String> field that
2045        // was written once can never be un-set through plain overwrite
2046        // semantics. A null leaf deletes just that field.
2047        let mut current = Config::default();
2048        current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2049        current.subagents.claude_code_model = Some("claude-sonnet".to_string());
2050
2051        let merged = merge_and_deserialize(
2052            &current,
2053            json!({ "subagents": { "claude_code_binary": null } }),
2054        );
2055
2056        assert_eq!(merged.subagents.claude_code_binary, None);
2057        // Surgical: a sibling Option field in the same object that the
2058        // patch didn't touch survives untouched.
2059        assert_eq!(
2060            merged.subagents.claude_code_model,
2061            Some("claude-sonnet".to_string())
2062        );
2063    }
2064
2065    #[test]
2066    fn absent_key_leaves_value_unchanged_back_compat() {
2067        // Back-compat is a hard requirement: omitting a key from the patch
2068        // must never be interpreted as a delete, regardless of the new null
2069        // semantics living right next to it.
2070        let mut current = Config::default();
2071        current.subagents.claude_code_binary = Some("/usr/local/bin/claude".to_string());
2072        current.subagents.max_concurrent = Some(4);
2073
2074        // The patch touches a sibling field only; claude_code_binary is
2075        // simply not mentioned.
2076        let merged =
2077            merge_and_deserialize(&current, json!({ "subagents": { "max_concurrent": 16 } }));
2078
2079        assert_eq!(
2080            merged.subagents.claude_code_binary,
2081            Some("/usr/local/bin/claude".to_string()),
2082            "an omitted key must leave the existing value untouched"
2083        );
2084        assert_eq!(merged.subagents.max_concurrent, Some(16));
2085    }
2086
2087    #[test]
2088    fn null_on_whole_object_subtree_resets_it_to_defaults() {
2089        // Before #505, `null` on a non-Option struct field (like
2090        // `notifications`) crashed the ENTIRE patch with a deserialize
2091        // error ("invalid type: null, expected struct..."). Deleting the
2092        // key now falls back to that field's own `#[serde(default)]`,
2093        // resetting the whole subtree rather than erroring.
2094        let mut current = Config::default();
2095        current.notifications.ntfy.token = Some("existing-token".to_string());
2096        current.notifications.ntfy.enabled = true;
2097
2098        let merged = merge_and_deserialize(&current, json!({ "notifications": null }));
2099
2100        assert_eq!(merged.notifications, crate::NotificationsConfig::default());
2101    }
2102
2103    #[test]
2104    fn null_deletes_one_hashmap_entry_keeps_siblings() {
2105        // The other half of the issue's motivating gap: a client could never
2106        // delete a single map entry (provider instance, MCP server, ...) —
2107        // sending null for one entry used to crash deserialization of the
2108        // WHOLE map. Now it deletes just that entry.
2109        //
2110        // Note: `api_key` is `#[serde(skip_serializing)]` (plaintext never
2111        // round-trips through `serde_json::to_value` — that's the unrelated
2112        // #516 quirk `preserve_unpatched_provider_secrets` exists to paper
2113        // over), so this test asserts survival via `label`, a plain
2114        // serialized field, to isolate the hashmap-entry-delete mechanic
2115        // being tested here from that separate secret-round-trip concern
2116        // (covered by its own tests below).
2117        fn labeled_instance(label: &str) -> crate::ProviderInstanceConfig {
2118            serde_json::from_value(json!({
2119                "provider_type": "openai",
2120                "label": label,
2121            }))
2122            .expect("valid instance")
2123        }
2124
2125        let mut current = Config::default();
2126        current
2127            .provider_instances
2128            .insert("uuid-1".to_string(), labeled_instance("Work"));
2129        current
2130            .provider_instances
2131            .insert("uuid-2".to_string(), labeled_instance("Personal"));
2132
2133        let merged = merge_and_deserialize(
2134            &current,
2135            json!({ "provider_instances": { "uuid-1": null } }),
2136        );
2137
2138        assert!(!merged.provider_instances.contains_key("uuid-1"));
2139        assert_eq!(
2140            merged
2141                .provider_instances
2142                .get("uuid-2")
2143                .and_then(|i| i.label.as_deref()),
2144            Some("Personal"),
2145            "sibling map entries the patch didn't touch must survive"
2146        );
2147    }
2148
2149    #[test]
2150    fn null_on_whole_array_field_resets_it_to_empty() {
2151        // Design decision (#505): arrays are leaf-replaced, never merged
2152        // element-by-element. A `null` standing in for the WHOLE array
2153        // deletes it (falls back to Vec's default, i.e. empty) — but this
2154        // is a distinct case from "null as one element inside a
2155        // surviving array" (covered by the next test), which is NOT a
2156        // delete marker.
2157        let mut current = Config::default();
2158        current.connect.platforms = vec![connect_platform("telegram", "tok")];
2159
2160        let merged = merge_and_deserialize(&current, json!({ "connect": { "platforms": null } }));
2161
2162        assert!(merged.connect.platforms.is_empty());
2163    }
2164
2165    #[test]
2166    fn null_inside_a_surviving_array_is_a_literal_element_not_a_delete_marker() {
2167        // RFC 7386 never recurses into arrays — they're leaf values, always
2168        // replaced wholesale. So a patch that sends `[..., null, ...]` does
2169        // NOT delete an element out of the existing array; the whole array
2170        // is replaced verbatim, null and all. Demonstrated here against a
2171        // `Vec<String>` field: since `String` (not `Option<String>`) can't
2172        // hold a null, the round-trip surfaces a normal type error instead
2173        // of silently dropping an element — proving the null was carried
2174        // through literally, not specially interpreted.
2175        let mut current = Config::default();
2176        current.subagents.worker_args = Some(vec!["subagent-worker".to_string()]);
2177
2178        let mut merged = serde_json::to_value(&current).unwrap();
2179        deep_merge_json(
2180            &mut merged,
2181            json!({ "subagents": { "worker_args": ["a", null, "b"] } }),
2182        );
2183
2184        // The array in the merged JSON is the patch's array verbatim,
2185        // literal null included — not silently filtered.
2186        assert_eq!(
2187            merged["subagents"]["worker_args"],
2188            json!(["a", null, "b"]),
2189            "arrays are leaf-replaced verbatim; a null element is not deleted"
2190        );
2191        let result: Result<Config, _> = serde_json::from_value(merged);
2192        assert!(
2193            result.is_err(),
2194            "a literal null inside a Vec<String> is a type error, not an element delete"
2195        );
2196    }
2197
2198    #[test]
2199    fn sentinel_string_values_still_work_after_null_delete_support() {
2200        // Lotus #80: `subagents.executor = "bamboo_runtime"` is a plain
2201        // string sentinel value (not related to null-delete at all) that
2202        // must keep working exactly as a normal overwrite.
2203        let current = Config::default();
2204
2205        let merged = merge_and_deserialize(
2206            &current,
2207            json!({ "subagents": { "executor": "bamboo_runtime" } }),
2208        );
2209
2210        assert_eq!(
2211            merged.subagents.executor,
2212            Some("bamboo_runtime".to_string())
2213        );
2214    }
2215
2216    #[test]
2217    fn subagents_max_concurrent_null_clears_it_like_todays_lotus_ui() {
2218        // Existing-null-usage survey finding: Lotus's SystemSettingsConfigTab
2219        // already sends `{"subagents":{"max_concurrent": null}}` today (the
2220        // AntD InputNumber reports `null` when the field is cleared — see
2221        // `SystemSettingsConfigTab.tsx`), relying on serde_json's built-in
2222        // `Option<T>` + `null` -> `None` handling as an ACCIDENT of the old
2223        // "just overwrite the leaf with whatever JSON value arrived"
2224        // catch-all. The new delete-the-key implementation must reproduce
2225        // that exact end result (None) so this already-shipped Lotus flow
2226        // keeps working unchanged.
2227        let mut current = Config::default();
2228        current.subagents.max_concurrent = Some(4);
2229
2230        let merged =
2231            merge_and_deserialize(&current, json!({ "subagents": { "max_concurrent": null } }));
2232
2233        assert_eq!(merged.subagents.max_concurrent, None);
2234    }
2235
2236    // ── #505: secret-field composition (intents recognize null as clear) ──
2237
2238    #[test]
2239    fn provider_api_key_intents_treats_null_as_clear_intent() {
2240        let patch = json!({
2241            "providers": { "openai": { "api_key": null } },
2242            "provider_instances": { "uuid-1": { "api_key": null } }
2243        });
2244        let intents = provider_api_key_intents(patch.as_object().unwrap());
2245        assert!(
2246            intents.providers.contains("openai"),
2247            "a null api_key must register as an explicit clear intent, \
2248             same as an empty string — otherwise preserve_unpatched_provider_secrets \
2249             would resurrect the deleted key"
2250        );
2251        assert!(intents.provider_instances.contains("uuid-1"));
2252    }
2253
2254    #[test]
2255    fn provider_api_key_intents_null_and_empty_string_are_equivalent_intents() {
2256        let null_patch = json!({ "providers": { "openai": { "api_key": null } } });
2257        let empty_patch = json!({ "providers": { "openai": { "api_key": "" } } });
2258        assert_eq!(
2259            provider_api_key_intents(null_patch.as_object().unwrap()),
2260            provider_api_key_intents(empty_patch.as_object().unwrap()),
2261            "null and \"\" must be recognized as the same clear intent"
2262        );
2263    }
2264
2265    #[test]
2266    fn null_api_key_does_not_get_resurrected_by_preserve_unpatched_secrets() {
2267        // End-to-end composition proof for the precedence rule documented on
2268        // `deep_merge_json`: null-delete (step 2) must not be undone by the
2269        // unpatched-secret carry-forward (step 3). This mirrors
2270        // `preserve_unpatched_provider_secrets_respects_explicit_intents`
2271        // above, but with a `null` clear instead of `""`.
2272        let mut current = Config::default();
2273        current
2274            .provider_instances
2275            .insert("uuid-1".to_string(), instance("sk-old", None));
2276
2277        let patch = json!({ "provider_instances": { "uuid-1": { "api_key": null } } });
2278        let intents = provider_api_key_intents(patch.as_object().unwrap());
2279        assert!(intents.provider_instances.contains("uuid-1"));
2280
2281        let mut merged = merge_and_deserialize(&current, patch);
2282        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
2283
2284        assert_eq!(
2285            merged.provider_instances["uuid-1"].api_key, "",
2286            "a null-delete of api_key must stick, not get resurrected from `current`"
2287        );
2288    }
2289
2290    #[test]
2291    fn notification_secret_intents_treats_null_as_clear_intent() {
2292        let patch = json!({
2293            "notifications": { "ntfy": { "token": null }, "bark": { "device_key": null } }
2294        });
2295        let intents = notification_secret_intents(patch.as_object().unwrap());
2296        assert!(intents.ntfy_token);
2297        assert!(intents.bark_device_key);
2298    }
2299
2300    #[test]
2301    fn connect_secret_intents_treats_null_as_clear_intent() {
2302        let patch = json!({
2303            "connect": { "platforms": [ { "type": "telegram", "token": null } ] }
2304        });
2305        let intents = connect_secret_intents(patch.as_object().unwrap());
2306        assert!(intents.token.contains(&0));
2307    }
2308
2309    #[test]
2310    fn null_ntfy_token_clears_roundtripped_ciphertext_via_clear_intents() {
2311        // Full composition: the merge leaves `token_encrypted` behind (only
2312        // `token` was null-deleted, not the whole `ntfy` object), so
2313        // `clear_notification_ciphertext_for_explicit_clears` must strip it
2314        // once the intent is recognized — otherwise hydration would refill
2315        // the "deleted" plaintext straight from the stale ciphertext.
2316        let mut current = Config::default();
2317        current.notifications.ntfy.token = Some("existing-token".to_string());
2318        current.notifications.ntfy.token_encrypted = Some("existing-ct".to_string());
2319
2320        let patch = json!({ "notifications": { "ntfy": { "token": null } } });
2321        let intents = notification_secret_intents(patch.as_object().unwrap());
2322        assert!(intents.ntfy_token);
2323
2324        let mut merged = merge_and_deserialize(&current, patch);
2325        assert_eq!(
2326            merged.notifications.ntfy.token_encrypted.as_deref(),
2327            Some("existing-ct"),
2328            "the merge alone leaves the round-tripped ciphertext behind"
2329        );
2330
2331        clear_notification_ciphertext_for_explicit_clears(&mut merged, &intents);
2332
2333        assert_eq!(merged.notifications.ntfy.token, None);
2334        assert!(
2335            merged.notifications.ntfy.token_encrypted.is_none(),
2336            "the clear-intent pass must drop the stale ciphertext so hydration can't refill it"
2337        );
2338    }
2339
2340    #[test]
2341    fn whole_providers_null_wipes_everything_without_resurrecting_secrets() {
2342        // Coarse-grained blast radius: `{"providers": null}` deletes the
2343        // ENTIRE providers subtree (not just one provider's key). Confirms
2344        // this doesn't accidentally resurrect secrets: intents are empty
2345        // (the raw patch's "providers" is a scalar null, not an object, so
2346        // `provider_api_key_intents` finds no per-provider object to walk),
2347        // but `preserve_unpatched_provider_secrets`'s carry-forward only
2348        // fires when the MERGED side still has a `Some(..)` provider config
2349        // to carry into — which a whole-subtree wipe doesn't leave behind.
2350        let mut current = Config::default();
2351        current.providers.openai = Some(crate::OpenAIConfig {
2352            api_key: "sk-legacy-live".to_string(),
2353            ..Default::default()
2354        });
2355
2356        let patch = json!({ "providers": null });
2357        let intents = provider_api_key_intents(patch.as_object().unwrap());
2358        assert!(intents.providers.is_empty());
2359
2360        let mut merged = merge_and_deserialize(&current, patch);
2361        assert!(
2362            merged.providers.openai.is_none(),
2363            "the whole providers subtree must reset to default"
2364        );
2365
2366        preserve_unpatched_provider_secrets(&mut merged, &current, &intents);
2367
2368        assert!(
2369            merged.providers.openai.is_none(),
2370            "carry-forward must not resurrect a provider the patch wiped out entirely"
2371        );
2372    }
2373}