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