Skip to main content

zeroclaw_gateway/
api_config.rs

1//! Per-property CRUD endpoints for `/api/config/*`.
2//!
3//! These endpoints expose the same `Config::get_prop` / `set_prop` core that
4//! `zeroclaw config get/set/list/init/migrate` uses on the CLI. Both are thin
5//! frontends over the same mutation primitive.
6//!
7//! Returns structured `ConfigApiError` responses with stable codes the
8//! dashboard / scripts can match programmatically. Secret fields are
9//! write-only over HTTP per the secrets-handling boundary defined in
10//! the issue body.
11//!
12//! for the full surface and acceptance checklist.
13
14use axum::{
15    Json,
16    extract::{Query, State},
17    http::{HeaderMap, HeaderValue, StatusCode, header},
18    response::{IntoResponse, Response},
19};
20use serde::{Deserialize, Serialize};
21use zeroclaw_config::api_error::{ConfigApiCode, ConfigApiError};
22use zeroclaw_config::field_visibility;
23use zeroclaw_config::sections::section_for_path;
24use zeroclaw_config::traits::MaskSecrets;
25
26use super::AppState;
27use super::api::require_auth;
28
29// ── Request / response shapes ───────────────────────────────────────
30
31/// `?path=...` query parameter shared by GET / DELETE / OPTIONS-with-path.
32#[derive(Debug, Deserialize)]
33#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
34pub struct PropQuery {
35    pub path: String,
36}
37
38/// `?prefix=...` query parameter for list.
39#[derive(Debug, Deserialize, Default)]
40#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
41pub struct ListQuery {
42    #[serde(default)]
43    pub prefix: Option<String>,
44}
45
46/// PUT body. Value is `serde_json::Value` so typed values (booleans, arrays,
47/// numbers) round-trip correctly without going through the CLI's
48/// comma-delimited string parser.
49#[derive(Debug, Deserialize)]
50#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
51pub struct PropPutBody {
52    pub path: String,
53    pub value: serde_json::Value,
54    #[serde(default)]
55    pub comment: Option<String>,
56}
57
58/// One JSON Patch (RFC 6902) operation. We support a strict subset:
59/// `add`, `remove`, `replace`, `test`. `move` and `copy` are explicitly
60/// rejected at apply time with `op_not_supported` because safe reference-
61/// graph rewriting isn't part of this PR.
62///
63/// `comment` is a ZeroClaw extension — when provided it accompanies the
64/// resulting TOML write so future maintainers can see why a value was set.
65/// Honored once the comment-preserving write path is wired through (step 7);
66/// accepted here so the API shape doesn't churn.
67#[derive(Debug, Deserialize)]
68#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
69pub struct PatchOp {
70    pub op: String,
71    pub path: String,
72    #[serde(default)]
73    pub value: Option<serde_json::Value>,
74    #[serde(default)]
75    pub comment: Option<String>,
76}
77
78/// Single result entry in a successful PATCH response, one per applied op.
79#[derive(Debug, Serialize)]
80#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
81pub struct PatchOpResult {
82    pub op: String,
83    pub path: String,
84    /// The resulting value at the target path after the op applied.
85    /// `None` for secret paths (per the secrets-handling boundary), and for
86    /// `remove` ops where the field was reset to its default.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub value: Option<serde_json::Value>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub populated: Option<bool>,
91    /// Comment that was applied alongside this op (if any). Echoed so
92    /// clients can confirm the comment was actually written to disk
93    /// without having to round-trip through `GET` and parse the TOML.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub comment: Option<String>,
96}
97
98#[derive(Debug, Serialize)]
99#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
100pub struct PatchResponse {
101    pub saved: bool,
102    pub results: Vec<PatchOpResult>,
103    /// Non-fatal validation warnings against the post-save config state.
104    /// Empty when nothing is flagged. Surfaces what the CLI prints on
105    /// stderr so dashboard callers see the same signal — e.g. an
106    /// `agents.<x>.model_provider` referencing an unconfigured model_provider
107    /// returns HTTP 200 with the save committed, plus a structured
108    /// validation warning here.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
111}
112
113/// GET /api/config — compatibility whole-config read for older bundled
114/// dashboard pages. New clients should prefer the per-property API, but
115/// returning a masked snapshot here avoids a hard 405 when an older page is
116/// served by a newer gateway.
117pub async fn handle_config_get(State(state): State<AppState>, headers: HeaderMap) -> Response {
118    if let Err(e) = require_auth(&state, &headers) {
119        return e.into_response();
120    }
121
122    let mut cfg = state.config.read().clone();
123    cfg.mask_secrets();
124    Json(cfg).into_response()
125}
126
127fn parse_patch_ops(value: serde_json::Value) -> Result<Vec<PatchOp>, ConfigApiError> {
128    let ops = value.as_array().ok_or_else(|| {
129        ConfigApiError::new(
130            ConfigApiCode::ValueTypeMismatch,
131            "JSON Patch body must be a JSON array of operations",
132        )
133    })?;
134
135    let mut parsed = Vec::with_capacity(ops.len());
136    for (idx, op) in ops.iter().enumerate() {
137        let object = op.as_object().ok_or_else(|| {
138            ConfigApiError::new(
139                ConfigApiCode::ValueTypeMismatch,
140                format!("JSON Patch op[{idx}] must be an object"),
141            )
142            .with_op_index(idx)
143        })?;
144        let op_name = object.get("op").and_then(|v| v.as_str()).ok_or_else(|| {
145            ConfigApiError::new(
146                ConfigApiCode::ValueTypeMismatch,
147                format!("JSON Patch op[{idx}] requires string `op` field"),
148            )
149            .with_op_index(idx)
150        })?;
151        let path = object.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
152            ConfigApiError::new(
153                ConfigApiCode::ValueTypeMismatch,
154                format!("JSON Patch op[{idx}] requires string `path` field"),
155            )
156            .with_op_index(idx)
157        })?;
158        let comment = match object.get("comment") {
159            Some(value) => Some(
160                value
161                    .as_str()
162                    .ok_or_else(|| {
163                        ConfigApiError::new(
164                            ConfigApiCode::ValueTypeMismatch,
165                            format!("JSON Patch op[{idx}] `comment` field must be a string"),
166                        )
167                        .with_path(json_pointer_to_dotted(path))
168                        .with_op_index(idx)
169                    })?
170                    .to_string(),
171            ),
172            None => None,
173        };
174
175        parsed.push(PatchOp {
176            op: op_name.to_string(),
177            path: path.to_string(),
178            value: object.get("value").cloned(),
179            comment,
180        });
181    }
182
183    Ok(parsed)
184}
185
186/// Response for a non-secret GET / PUT / DELETE.
187#[derive(Debug, Serialize)]
188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
189pub struct PropResponse {
190    pub path: String,
191    pub value: serde_json::Value,
192    /// Non-fatal validation warnings against the current config state.
193    /// On GET this surfaces warnings present in the loaded config; on PUT
194    /// this surfaces warnings against the post-save state. Empty when
195    /// nothing is flagged.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
198}
199
200/// Response for a secret GET / PUT / DELETE — never carries the value or its
201/// length. `populated: true` means the secret has a non-empty value on disk;
202/// `populated: false` means the field is unset or empty.
203#[derive(Debug, Serialize)]
204#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
205pub struct SecretResponse {
206    pub path: String,
207    pub populated: bool,
208}
209
210/// Single entry in the list response. Secrets carry only `path + populated`;
211/// non-secrets additionally carry `value`.
212///
213/// `kind` and `type_hint` are the wire form of the field's declared
214/// `PropKind` plus its Rust type signature. Frontends bind input renderers
215/// to these directly (no value-sniffing). `enum_variants` is populated for
216/// fields whose macro derive surfaces a variant list (drives `select`
217/// option rendering).
218#[derive(Debug, Serialize)]
219#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
220pub struct ListEntry {
221    pub path: String,
222    pub category: String,
223    /// Stable kind tag — `string`, `bool`, `integer`, `float`, `enum`,
224    /// `string-array`. Lowercase-kebab so it can be used directly as a CSS
225    /// class or React key.
226    pub kind: &'static str,
227    /// Rust type signature, e.g. `Option<String>`, `Vec<String>`, `u64`.
228    /// Render in tooltips / hover state for the technically-curious.
229    pub type_hint: &'static str,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub value: Option<serde_json::Value>,
232    pub populated: bool,
233    pub is_secret: bool,
234    /// Whether this field was populated by a `ZEROCLAW_*` env-var override
235    /// at load time. The dashboard renders the 💉 badge and a persistent
236    /// warning *"Edits here won't take effect — overridden by ZEROCLAW_..."*
237    /// when this is `true`.
238    #[serde(default, skip_serializing_if = "is_false")]
239    pub is_env_overridden: bool,
240    /// Variants for `enum`-kind fields — non-empty means the frontend should
241    /// render a `<select>` with these options. Empty for non-enum fields.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub enum_variants: Vec<String>,
244    /// Onboard section name derived from the path's first segment via
245    /// `Section::from_path`. `None` for paths that aren't part of any wizard
246    /// section. The dashboard groups list entries by this for per-section
247    /// rendering — same source the CLI wizard uses, no schema attribute.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub section: Option<&'static str>,
250    /// Tab grouping label from the field's `#[tab(...)]` annotation
251    /// (`ConfigTab::label`). Absent for `ConfigTab::None`. Surfaces group
252    /// list entries into a tab bar by this; the agent edit form depends on
253    /// it to split General / Providers / Channels / etc.
254    #[serde(skip_serializing_if = "str::is_empty")]
255    pub tab: &'static str,
256}
257
258/// Stable wire-form name for a `PropKind` variant. Matches the lower-kebab
259/// convention the rest of the API uses for stable string IDs.
260fn prop_kind_wire(kind: zeroclaw_config::traits::PropKind) -> &'static str {
261    use zeroclaw_config::traits::PropKind;
262    match kind {
263        PropKind::String => "string",
264        PropKind::Bool => "bool",
265        PropKind::Integer => "integer",
266        PropKind::Float => "float",
267        PropKind::Enum => "enum",
268        PropKind::StringArray => "string-array",
269        PropKind::ObjectArray => "object-array",
270        PropKind::Object => "object",
271    }
272}
273
274#[derive(Debug, Serialize)]
275#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
276pub struct ListResponse {
277    pub entries: Vec<ListEntry>,
278    /// Properties where in-memory and on-disk values disagree. Empty when the
279    /// daemon's view matches the file. Each entry follows the `DriftEntry`
280    /// shape (secrets carry only `{path, secret: true, drifted: true}`).
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub drifted: Vec<DriftEntry>,
283}
284
285/// One drift entry surfaced when in-memory `Config` diverges from the on-disk
286/// `config.toml` (some other process — typically a hand-edit while the daemon
287/// was stopped — wrote the file). For non-secret fields, both values are
288/// surfaced so the dashboard can show a clean diff. For secret fields, only
289/// the boolean `drifted` is surfaced — the secret values themselves never
290/// leave the server.
291#[derive(Debug, Serialize)]
292#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
293pub struct DriftEntry {
294    pub path: String,
295    /// `true` for secret fields where values cannot be exposed.
296    #[serde(default, skip_serializing_if = "is_false")]
297    pub secret: bool,
298    /// Always `true` when surfaced. Present so secret entries unambiguously
299    /// communicate the drift signal in shape `{path, secret: true, drifted: true}`.
300    pub drifted: bool,
301    /// In-memory value (the daemon's view). Absent for secrets.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub in_memory_value: Option<serde_json::Value>,
304    /// On-disk value (what the file contains right now). Absent for secrets.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub on_disk_value: Option<serde_json::Value>,
307}
308
309fn is_false(b: &bool) -> bool {
310    !*b
311}
312
313// ── Error helpers ───────────────────────────────────────────────────
314
315/// Convert a `ConfigApiError` into an axum `Response` with the correct status.
316fn error_response(err: ConfigApiError) -> Response {
317    let status =
318        StatusCode::from_u16(err.code.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
319    (status, axum::Json(err)).into_response()
320}
321
322/// Wrap an `anyhow::Error` from `Config::set_prop` / `get_prop` into a
323/// `ConfigApiError`. Path-not-found errors get the specific code; everything
324/// else falls through to ValidationFailed.
325fn map_prop_error(err: anyhow::Error, path: &str) -> ConfigApiError {
326    let msg = err.to_string();
327    if msg.starts_with("Unknown property") {
328        ConfigApiError::path_not_found(path)
329    } else {
330        ConfigApiError::from_validation(err).with_path(path)
331    }
332}
333
334// ── Helpers ─────────────────────────────────────────────────────────
335
336// Typed-value coercion lives in `zeroclaw_config::typed_value` — both the
337// gateway PATCH/PUT handlers and the CLI `config patch` flow consume it.
338// Single source of truth for the "JSON in, set_prop string out, validated
339// against the declared PropKind" contract.
340use zeroclaw_config::typed_value::coerce_for_set_prop as json_to_setprop_string;
341
342/// Look up the prop_field metadata for a path. Used by the per-prop GET / PUT
343/// handlers to decide whether the field is a secret.
344fn lookup_prop_field(
345    config: &zeroclaw_config::schema::Config,
346    path: &str,
347) -> Option<zeroclaw_config::traits::PropFieldInfo> {
348    config
349        .prop_fields()
350        .into_iter()
351        .find(|info| info.name == path)
352        .or_else(|| {
353            zeroclaw_config::schema::Config::prop_is_secret(path).then(|| {
354                zeroclaw_config::traits::PropFieldInfo {
355                    name: path.to_string(),
356                    category: "Secrets",
357                    display_value: zeroclaw_config::traits::UNSET_DISPLAY.to_string(),
358                    type_hint: "String",
359                    kind: zeroclaw_config::traits::PropKind::String,
360                    is_secret: true,
361                    enum_variants: None,
362                    description: "",
363                    derived_from_secret: false,
364                    credential_class: Some(
365                        zeroclaw_config::traits::CredentialSurfaceClass::EncryptedSecret,
366                    ),
367                    tab: zeroclaw_config::traits::ConfigTab::None,
368                }
369            })
370        })
371}
372
373/// Save the config and refresh in-memory state. Captures a snapshot of the
374/// pre-write disk state and reverts to it if the save itself fails, so that
375/// on-disk and in-memory state stay consistent under any failure mode.
376///
377/// On the happy path: validate (caller's responsibility) → save to disk →
378/// swap in-memory → respond OK.
379///
380/// On save failure: best-effort restore the pre-write disk content (when
381/// readable), keep in-memory state untouched, return `reload_failed`.
382/// Run `validate()` and partition errors: if the failure path overlaps
383/// a dirty path on the working config, the save is rejected
384/// (`Err(Response)`); otherwise the error is downgraded to a
385/// non-fatal warning attached to the response. Saving a single field
386/// shouldn't be blocked by an unrelated pre-existing validation
387/// problem elsewhere in the config.
388fn scoped_validate(
389    working: &zeroclaw_config::schema::Config,
390) -> Result<Vec<zeroclaw_config::validation_warnings::ValidationWarning>, ConfigApiError> {
391    if let Err(e) = working.validate() {
392        let api_err = ConfigApiError::from_validation(e);
393        let err_path = api_err.path.as_deref().unwrap_or("");
394        let touches_dirty = !err_path.is_empty()
395            && working.dirty_paths.iter().any(|d| {
396                err_path == d.as_str()
397                    || err_path.starts_with(&format!("{d}."))
398                    || d.starts_with(&format!("{err_path}."))
399            });
400        if touches_dirty || err_path.is_empty() {
401            return Err(api_err);
402        }
403        ::zeroclaw_log::record!(
404            WARN,
405            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
406                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
407                .with_attrs(::serde_json::json!({"path": err_path})),
408            &format!(
409                "validate() failed on a path outside this PATCH's dirty set; saving anyway and \
410             surfacing as a warning: {}",
411                api_err.message
412            )
413        );
414        return Ok(vec![
415            zeroclaw_config::validation_warnings::ValidationWarning::new(
416                "pre_existing_validation_error",
417                api_err.message,
418                err_path.to_string(),
419            ),
420        ]);
421    }
422    Ok(Vec::new())
423}
424
425async fn persist_and_swap(
426    state: &AppState,
427    mut new_config: zeroclaw_config::schema::Config,
428) -> Result<(), ConfigApiError> {
429    let config_path = new_config.config_path.clone();
430
431    // Snapshot pre-write disk state (used for revert on save failure). When
432    // the file doesn't exist yet, snapshot is None — we'll remove the file
433    // again on rollback so a failed first-write doesn't leak partial state.
434    let snapshot = if config_path.exists() {
435        // best-effort; if we can't read, we can't revert
436        tokio::fs::read(&config_path).await.ok()
437    } else {
438        None
439    };
440
441    if let Err(e) = new_config.save_dirty().await {
442        if let Some(prev) = snapshot {
443            let _ = tokio::fs::write(&config_path, prev).await;
444        } else if config_path.exists() {
445            let _ = tokio::fs::remove_file(&config_path).await;
446        }
447        return Err(ConfigApiError::new(
448            ConfigApiCode::ReloadFailed,
449            format!("save failed: {e}"),
450        ));
451    }
452
453    *state.config.write() = new_config;
454    state
455        .pending_reload
456        .store(true, std::sync::atomic::Ordering::Relaxed);
457    Ok(())
458}
459
460/// Fields the gateway owns end-to-end (mints, rotates, persists itself).
461/// They're skipped by [`compute_drift`] so the dashboard doesn't surface a
462/// banner the operator can't act on. Add new entries here when a similar
463/// gateway-managed field lands (e.g. webhook secret rotation).
464fn is_gateway_managed_field(name: &str) -> bool {
465    // Match the prop-field name actually emitted by the `Configurable` derive,
466    // which preserves the Rust field's snake_case (`paired_tokens`), not kebab.
467    matches!(name, "gateway.paired_tokens")
468}
469
470/// Compute drift between the in-memory config and what's on disk right now.
471/// Returns one entry per drifted property; empty when in-memory and disk
472/// agree (or when the on-disk file can't be parsed).
473///
474/// **Secrets:** never surface values. We compare in-memory and on-disk
475/// representations server-side — for secret paths, the comparison happens
476/// over the raw display strings (which include the encrypted form on disk
477/// vs. the decrypted form in memory, so most secret drift is false-positive
478/// against `Configurable`'s display layer). To stay honest about that, the
479/// on-disk side is round-tripped through the full deserializer + decrypt
480/// pass before comparison, so we only surface drift the daemon would
481/// actually pick up on its next read of the file.
482pub async fn compute_drift(in_memory: &zeroclaw_config::schema::Config) -> Vec<DriftEntry> {
483    let path = &in_memory.config_path;
484    if !path.exists() {
485        return Vec::new();
486    }
487
488    let raw = match tokio::fs::read_to_string(path).await {
489        Ok(s) => s,
490        Err(_) => return Vec::new(),
491    };
492
493    // Re-parse the on-disk form into a fresh Config for value-by-value comparison.
494    let on_disk: zeroclaw_config::schema::Config =
495        match toml::from_str::<zeroclaw_config::schema::Config>(&raw) {
496            Ok(mut cfg) => {
497                cfg.config_path = path.clone();
498                cfg
499            }
500            Err(_) => return Vec::new(),
501        };
502
503    let in_memory_props: std::collections::HashMap<String, zeroclaw_config::traits::PropFieldInfo> =
504        in_memory
505            .prop_fields()
506            .into_iter()
507            .map(|p| (p.name.clone(), p))
508            .collect();
509    let on_disk_props: std::collections::HashMap<String, zeroclaw_config::traits::PropFieldInfo> =
510        on_disk
511            .prop_fields()
512            .into_iter()
513            .map(|p| (p.name.clone(), p))
514            .collect();
515
516    let mut drift: Vec<DriftEntry> = Vec::new();
517    let mut all_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
518    all_names.extend(in_memory_props.keys().map(String::as_str));
519    all_names.extend(on_disk_props.keys().map(String::as_str));
520    for name in all_names {
521        // Gateway-managed internal state isn't operator-edited and the
522        // gateway persists it itself via `persist_pairing_tokens` /
523        // similar paths. Surfacing it as drift confuses operators who
524        // can't fix it from the dashboard and the banner sticks until
525        // the daemon happens to rewrite the file.
526        if is_gateway_managed_field(name) {
527            continue;
528        }
529        let mem = in_memory_props.get(name);
530        let disk = on_disk_props.get(name);
531        let mem_display = mem
532            .map(|p| p.display_value.as_str())
533            .unwrap_or(zeroclaw_config::traits::UNSET_DISPLAY);
534        let disk_display = disk
535            .map(|p| p.display_value.as_str())
536            .unwrap_or(zeroclaw_config::traits::UNSET_DISPLAY);
537        if mem_display == disk_display {
538            continue;
539        }
540        let is_sensitive = mem
541            .or(disk)
542            .map(|p| p.is_secret || p.derived_from_secret)
543            .unwrap_or(false);
544        if is_sensitive {
545            use sha2::{Digest, Sha256};
546            let mem_hash = Sha256::digest(mem_display.as_bytes());
547            let disk_hash = Sha256::digest(disk_display.as_bytes());
548            if mem_hash == disk_hash {
549                continue;
550            }
551            drift.push(DriftEntry {
552                path: name.to_string(),
553                secret: true,
554                drifted: true,
555                in_memory_value: None,
556                on_disk_value: None,
557            });
558        } else {
559            drift.push(DriftEntry {
560                path: name.to_string(),
561                secret: false,
562                drifted: true,
563                in_memory_value: Some(serde_json::Value::String(mem_display.to_string())),
564                on_disk_value: Some(serde_json::Value::String(disk_display.to_string())),
565            });
566        }
567    }
568
569    // Stable order so callers can diff snapshots.
570    drift.sort_by(|a, b| a.path.cmp(&b.path));
571    drift
572}
573
574// ── Handlers ────────────────────────────────────────────────────────
575
576/// GET /api/config/prop?path=agents.researcher.model_provider
577///
578/// Returns the user's current value for non-secret fields. For secret fields,
579/// returns `{path, populated}` only — the value, length, and any encoded form
580/// are deliberately withheld per the secrets-handling boundary.
581pub async fn handle_prop_get(
582    State(state): State<AppState>,
583    headers: HeaderMap,
584    Query(q): Query<PropQuery>,
585) -> Response {
586    if let Err(e) = require_auth(&state, &headers) {
587        return e.into_response();
588    }
589
590    let config = state.config.read().clone();
591    let info = match lookup_prop_field(&config, &q.path) {
592        Some(info) => info,
593        None => return error_response(ConfigApiError::path_not_found(&q.path)),
594    };
595
596    if info.is_secret || info.derived_from_secret {
597        let populated = info.display_value != zeroclaw_config::traits::UNSET_DISPLAY;
598        return axum::Json(SecretResponse {
599            path: q.path,
600            populated,
601        })
602        .into_response();
603    }
604
605    match config.get_prop(&q.path) {
606        Ok(value_str) => {
607            // get_prop returns the display string; surface it as JSON.
608            // For typed-value fidelity, callers should hit OPTIONS to learn
609            // the type and parse client-side. Future iterations can route
610            // typed values through serde directly.
611            let warnings = config.collect_warnings();
612            axum::Json(PropResponse {
613                path: q.path,
614                value: serde_json::Value::String(value_str),
615                warnings,
616            })
617            .into_response()
618        }
619        Err(e) => error_response(map_prop_error(e, &q.path)),
620    }
621}
622
623/// PUT /api/config/prop with body `{path, value, comment?}`
624///
625/// Sets the value via `Config::set_prop`, validates the resulting whole-config
626/// state, persists, and swaps in-memory. For secret fields, response carries
627/// only `{path, populated: true}` — never echoes the value back.
628pub async fn handle_prop_put(
629    State(state): State<AppState>,
630    headers: HeaderMap,
631    axum::Json(body): axum::Json<PropPutBody>,
632) -> Response {
633    if let Err(e) = require_auth(&state, &headers) {
634        return e.into_response();
635    }
636
637    let mut new_config = state.config.read().clone();
638    new_config.ensure_map_key_for_path(&body.path);
639    let info = match lookup_prop_field(&new_config, &body.path) {
640        Some(info) => info,
641        None => return error_response(ConfigApiError::path_not_found(&body.path)),
642    };
643
644    let value_str = match json_to_setprop_string(&body.value, Some(info.kind)) {
645        Ok(s) => s,
646        Err(e) => return error_response(e.with_path(&body.path)),
647    };
648
649    // Reject the masked sentinel for secrets — surfaces occasionally
650    // echo the masked display value back when no real edit happened.
651    // Letting that through would overwrite the live secret with the
652    // literal masked string.
653    let is_sensitive = info.is_secret || info.derived_from_secret;
654    if is_sensitive
655        && (value_str == zeroclaw_config::traits::MASKED_SECRET
656            || value_str == "****"
657            || value_str.is_empty())
658    {
659        return error_response(
660            ConfigApiError::new(
661                ConfigApiCode::ValidationFailed,
662                format!(
663                    "Refusing to overwrite secret `{}` with a masked or empty value",
664                    body.path
665                ),
666            )
667            .with_path(&body.path),
668        );
669    }
670
671    if let Err(e) = new_config.set_prop_persistent(&body.path, &value_str) {
672        return error_response(map_prop_error(e, &body.path));
673    }
674
675    let scoped_validation_warnings = match scoped_validate(&new_config) {
676        Ok(ws) => ws,
677        Err(err) => return error_response(err),
678    };
679
680    let config_path = new_config.config_path.clone();
681    let mut warnings = new_config.collect_warnings();
682    warnings.extend(scoped_validation_warnings);
683    if let Err(e) = persist_and_swap(&state, new_config).await {
684        return error_response(e);
685    }
686    if let Some(comment) = body.comment.as_ref() {
687        let annotations = [(body.path.clone(), comment.clone())];
688        if let Err(e) =
689            zeroclaw_config::comment_writer::apply_comments(&config_path, &annotations).await
690        {
691            ::zeroclaw_log::record!(
692                WARN,
693                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
694                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
695                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
696                "failed to apply PUT comment to config.toml"
697            );
698        }
699    }
700
701    if info.is_secret || info.derived_from_secret {
702        axum::Json(SecretResponse {
703            path: body.path,
704            populated: !value_str.is_empty(),
705        })
706        .into_response()
707    } else {
708        axum::Json(PropResponse {
709            path: body.path,
710            value: serde_json::Value::String(value_str),
711            warnings,
712        })
713        .into_response()
714    }
715}
716
717/// DELETE /api/config/prop?path=channels.matrix.allowed-users
718///
719/// Resets the field to its declared default. For `Option<T>` fields, this
720/// sets to `None`. For secrets, response carries only `{path, populated: false}`.
721///
722/// The current implementation routes through `set_prop` with an empty string,
723/// which exercises the same validator path. A more semantically pure reset
724/// (re-deriving the field's literal default) is a refinement for a later step.
725pub async fn handle_prop_delete(
726    State(state): State<AppState>,
727    headers: HeaderMap,
728    Query(q): Query<PropQuery>,
729) -> Response {
730    if let Err(e) = require_auth(&state, &headers) {
731        return e.into_response();
732    }
733
734    let mut new_config = state.config.read().clone();
735    let info = match lookup_prop_field(&new_config, &q.path) {
736        Some(info) => info,
737        None => return error_response(ConfigApiError::path_not_found(&q.path)),
738    };
739
740    if let Err(e) = new_config.set_prop_persistent(&q.path, "") {
741        return error_response(map_prop_error(e, &q.path));
742    }
743
744    let scoped_validation_warnings = match scoped_validate(&new_config) {
745        Ok(ws) => ws,
746        Err(err) => return error_response(err),
747    };
748
749    let mut warnings = new_config.collect_warnings();
750    warnings.extend(scoped_validation_warnings);
751    if let Err(e) = persist_and_swap(&state, new_config).await {
752        return error_response(e);
753    }
754
755    if info.is_secret || info.derived_from_secret {
756        axum::Json(SecretResponse {
757            path: q.path,
758            populated: false,
759        })
760        .into_response()
761    } else {
762        axum::Json(PropResponse {
763            path: q.path,
764            value: serde_json::Value::Null,
765            warnings,
766        })
767        .into_response()
768    }
769}
770
771/// GET /api/config/list?prefix=model_providers
772///
773/// Enumerates every property the schema exposes. Secret entries appear as
774/// `{path, populated}` with `value: None`; non-secrets carry the display
775/// value. Optional `prefix` query filters entries whose path starts with it.
776pub async fn handle_list(
777    State(state): State<AppState>,
778    headers: HeaderMap,
779    Query(q): Query<ListQuery>,
780) -> Response {
781    if let Err(e) = require_auth(&state, &headers) {
782        return e.into_response();
783    }
784
785    let config = state.config.read().clone();
786    let prefix = q.prefix.as_deref();
787
788    // Drop fields that don't apply to the current shape of the config —
789    // azure_* on a non-azure model_provider, qdrant.* when memory.backend is
790    // sqlite, etc. Keeps the form scoped to relevant inputs only.
791    let excluded = field_visibility::excluded_paths(&config, prefix.unwrap_or(""));
792
793    let entries: Vec<ListEntry> = config
794        .prop_fields()
795        .into_iter()
796        .filter(|info| match prefix {
797            Some(p) => field_visibility::path_matches_prefix(&info.name, p),
798            None => true,
799        })
800        .filter(|info| !field_visibility::is_excluded(&info.name, &excluded))
801        .map(|info| {
802            let populated = info.display_value != zeroclaw_config::traits::UNSET_DISPLAY;
803            let is_sensitive = info.is_secret || info.derived_from_secret;
804            let value = if is_sensitive {
805                None
806            } else {
807                Some(serde_json::Value::String(info.display_value.clone()))
808            };
809            let section = section_for_path(&info.name).map(|s| s.as_str());
810            let enum_variants = info.enum_variants.map(|f| f()).unwrap_or_default();
811            let is_env_overridden = config.prop_is_env_overridden(&info.name);
812            ListEntry {
813                path: info.name,
814                category: info.category.to_string(),
815                kind: prop_kind_wire(info.kind),
816                type_hint: info.type_hint,
817                value,
818                populated,
819                is_secret: is_sensitive,
820                is_env_overridden,
821                enum_variants,
822                section,
823                tab: info.tab.label(),
824            }
825        })
826        .collect();
827
828    let drifted = compute_drift(&config).await;
829    axum::Json(ListResponse { entries, drifted }).into_response()
830}
831
832#[derive(Debug, Serialize)]
833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
834pub struct DriftResponse {
835    pub drifted: Vec<DriftEntry>,
836}
837
838/// `GET /api/config/drift` — explicit drift summary for clients that want just
839/// the diff. Same `DriftEntry` shape used in `ListResponse.drifted`.
840pub async fn handle_drift(State(state): State<AppState>, headers: HeaderMap) -> Response {
841    if let Err(e) = require_auth(&state, &headers) {
842        return e.into_response();
843    }
844    let config = state.config.read().clone();
845    let drifted = compute_drift(&config).await;
846    axum::Json(DriftResponse { drifted }).into_response()
847}
848
849#[derive(Debug, Serialize)]
850#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
851pub struct ReloadStatusResponse {
852    /// `true` when one or more config writes have landed since the last
853    /// `/admin/reload`. Distinct from disk-vs-memory drift: this fires on
854    /// in-process PATCHes even though `persist_and_swap` updates the
855    /// in-memory config, because some subsystems (channels, providers,
856    /// scheduler) need to be re-instantiated to actually apply the change.
857    pub pending_reload: bool,
858}
859
860/// `GET /api/config/reload-status` — pending-reload flag for the dashboard's
861/// reload banner. Goes true on any config write, false on `/admin/reload`.
862pub async fn handle_reload_status(State(state): State<AppState>, headers: HeaderMap) -> Response {
863    if let Err(e) = require_auth(&state, &headers) {
864        return e.into_response();
865    }
866    let pending_reload = state
867        .pending_reload
868        .load(std::sync::atomic::Ordering::Relaxed);
869    axum::Json(ReloadStatusResponse { pending_reload }).into_response()
870}
871
872#[derive(Debug, Deserialize)]
873#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
874pub struct MapKeyQuery {
875    /// Map-keyed section path, e.g. `providers.models`, `agents`, `risk_profiles`.
876    pub path: String,
877    /// New key to insert under that section, e.g. `anthropic`.
878    pub key: String,
879}
880
881#[derive(Debug, Serialize)]
882#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
883pub struct MapKeyResponse {
884    pub path: String,
885    pub key: String,
886    pub created: bool,
887}
888
889#[derive(Debug, Serialize)]
890#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
891pub struct TemplatesResponse {
892    pub templates: Vec<TemplateEntry>,
893}
894
895#[derive(Debug, Serialize)]
896#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
897pub struct TemplateEntry {
898    pub path: &'static str,
899    /// `map` for `HashMap<String, T>`, `list` for `Vec<T>`.
900    pub kind: &'static str,
901    /// Rust type name of the value, e.g. `ModelProviderConfig`.
902    pub value_type: &'static str,
903    /// Doc comment from the schema (description of what gets added).
904    pub description: &'static str,
905}
906
907/// `GET /api/config/templates` — enumerate every map-keyed and list-shaped
908/// section the dashboard can offer "+ Add" affordances for. Discovered
909/// from the `Configurable` derive's `map_key_sections()` — single source of
910/// truth, no hand-maintained list. Adding a new `HashMap<String, T>` or
911/// `#[nested] Vec<T>` field anywhere in the schema makes it appear here
912/// automatically.
913pub async fn handle_templates(State(state): State<AppState>, headers: HeaderMap) -> Response {
914    if let Err(e) = require_auth(&state, &headers) {
915        return e.into_response();
916    }
917    let _ = state; // templates are static per build, but auth-gated for consistency
918
919    let templates: Vec<TemplateEntry> = zeroclaw_config::schema::Config::map_key_sections()
920        .into_iter()
921        .map(|s| TemplateEntry {
922            path: s.path,
923            kind: match s.kind {
924                zeroclaw_config::traits::MapKeyKind::Map => "map",
925                zeroclaw_config::traits::MapKeyKind::List => "list",
926            },
927            value_type: s.value_type,
928            description: s.description,
929        })
930        .collect();
931
932    axum::Json(TemplatesResponse { templates }).into_response()
933}
934
935#[derive(Debug, Deserialize)]
936#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
937pub struct MapPathQuery {
938    pub path: String,
939}
940
941/// `GET /api/config/map-keys?path=<section>` — list the current alias keys at
942/// a map-keyed section path, e.g. `channels.discord` → `["default","work"]`.
943pub async fn handle_get_map_keys(
944    State(state): State<AppState>,
945    headers: HeaderMap,
946    Query(q): Query<MapPathQuery>,
947) -> Response {
948    if let Err(e) = require_auth(&state, &headers) {
949        return e.into_response();
950    }
951    let cfg = state.config.read().clone();
952    match cfg.get_map_keys(&q.path) {
953        Some(keys) => {
954            axum::Json(serde_json::json!({ "path": q.path, "keys": keys })).into_response()
955        }
956        None => error_response(
957            ConfigApiError::new(
958                ConfigApiCode::PathNotFound,
959                format!("no map-keyed section at `{}`", q.path),
960            )
961            .with_path(&q.path),
962        ),
963    }
964}
965
966/// `DELETE /api/config/map-key?path=<section>&key=<alias>` — remove an alias
967/// from a map-keyed section. Persists on success.
968pub async fn handle_delete_map_key(
969    State(state): State<AppState>,
970    headers: HeaderMap,
971    Query(q): Query<MapKeyQuery>,
972) -> Response {
973    if let Err(e) = require_auth(&state, &headers) {
974        return e.into_response();
975    }
976    let mut working = state.config.read().clone();
977    let removed = match working.delete_map_key(&q.path, &q.key) {
978        Ok(b) => b,
979        Err(msg) => {
980            return error_response(
981                ConfigApiError::new(ConfigApiCode::PathNotFound, msg).with_path(&q.path),
982            );
983        }
984    };
985    if removed {
986        // Agent alias removal archives `agents/<alias>/workspace/` to
987        // `agents/_deleted/<alias>-<ts>/` rather than rm -rf. The
988        // workspace may contain operator notes, IDENTITY.md edits, etc.
989        // Memory database rows for the alias are also purged through
990        // the storage adapter so a future reuse of the same alias
991        // doesn't inherit stale rows.
992        if q.path == "agents" {
993            let workspace = working.agent_workspace_dir(&q.key);
994            if workspace.exists()
995                && let Some(parent) = workspace.parent()
996            {
997                let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
998                let archive_root = parent.join("_deleted");
999                let archive_dir = archive_root.join(format!("{}-{ts}", q.key));
1000                if let Err(err) = tokio::fs::create_dir_all(&archive_root).await {
1001                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": q.key, "archive": archive_root.display().to_string(), "err": err.to_string()})), "agent alias removed from config but archive dir creation failed");
1002                } else if let Err(err) = tokio::fs::rename(&workspace, &archive_dir).await {
1003                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": q.key, "from": workspace.display().to_string(), "to": archive_dir.display().to_string(), "err": err.to_string()})), "agent alias removed from config but workspace archive failed");
1004                } else {
1005                    ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"agent": q.key, "archive": archive_dir.display().to_string()})), "agent workspace archived after alias removal");
1006                }
1007            }
1008            match state.mem.purge_agent(&q.key).await {
1009                Ok(rows) if rows > 0 => ::zeroclaw_log::record!(
1010                    INFO,
1011                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1012                        .with_attrs(::serde_json::json!({"agent": q.key, "rows": rows})),
1013                    "agent memory rows purged after alias removal"
1014                ),
1015                Ok(_) => {}
1016                Err(err) => ::zeroclaw_log::record!(
1017                    WARN,
1018                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1019                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1020                        .with_attrs(::serde_json::json!({"agent": q.key, "err": err.to_string()})),
1021                    "purge_agent failed (backend may not support it)"
1022                ),
1023            }
1024        }
1025        working.mark_dirty(&format!("{}.{}", q.path, q.key));
1026        if let Err(e) = persist_and_swap(&state, working).await {
1027            return error_response(e);
1028        }
1029    }
1030    axum::Json(MapKeyResponse {
1031        path: q.path,
1032        key: q.key,
1033        created: false,
1034    })
1035    .into_response()
1036}
1037
1038/// `POST /api/config/map-key?path=<section>&key=<name>` — instantiate a new
1039/// entry under a map-keyed section with default values, or append to a
1040/// list-shaped one with `key` as the new entry's natural identifier.
1041/// Idempotent for Map kinds: returns `{created: false}` if the key already
1042/// exists.
1043///
1044/// Dispatch happens via `Config::create_map_key()` — emitted by the
1045/// `Configurable` derive, single source of truth. Adding a new
1046/// `HashMap<String, T>` or `#[nested] Vec<T>` field to the schema makes it
1047/// addable here automatically.
1048pub async fn handle_map_key(
1049    State(state): State<AppState>,
1050    headers: HeaderMap,
1051    Query(q): Query<MapKeyQuery>,
1052) -> Response {
1053    if let Err(e) = require_auth(&state, &headers) {
1054        return e.into_response();
1055    }
1056
1057    let mut working = state.config.read().clone();
1058    let path = q.path.clone();
1059    let key = q.key.clone();
1060
1061    let created = match working.create_map_key(&path, &key) {
1062        Ok(b) => b,
1063        Err(msg) => {
1064            return error_response(
1065                ConfigApiError::new(ConfigApiCode::PathNotFound, msg).with_path(&path),
1066            );
1067        }
1068    };
1069
1070    if created {
1071        // skill-bundles: materialize the bundle's resolved directory so
1072        // skills have a home immediately. Run before persist so a failed
1073        // mkdir surfaces in logs alongside the config write.
1074        if path == "skill_bundles" {
1075            let install_root = working.install_root_dir();
1076            if let Ok(dir) =
1077                zeroclaw_config::skill_bundles::resolve_directory(&working, &install_root, &key)
1078                && let Err(e) = tokio::fs::create_dir_all(&dir).await
1079            {
1080                ::zeroclaw_log::record!(
1081                    WARN,
1082                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1083                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1084                    &format!(
1085                        "skill-bundle '{key}' directory creation failed at {}: {e}",
1086                        dir.display().to_string()
1087                    )
1088                );
1089            }
1090        }
1091
1092        working.mark_dirty(&format!("{path}.{key}"));
1093        if let Err(e) = persist_and_swap(&state, working).await {
1094            return error_response(e);
1095        }
1096    }
1097
1098    axum::Json(MapKeyResponse { path, key, created }).into_response()
1099}
1100
1101#[derive(Debug, Deserialize)]
1102#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1103pub struct RenameMapKeyBody {
1104    /// Section path, e.g. `channels.discord` or `model_providers.anthropic`.
1105    pub path: String,
1106    /// Current alias name.
1107    pub from: String,
1108    /// New alias name.
1109    pub to: String,
1110}
1111
1112#[derive(Debug, Serialize)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114pub struct RenameMapKeyResponse {
1115    pub path: String,
1116    pub from: String,
1117    pub to: String,
1118    pub renamed: bool,
1119}
1120
1121/// `POST /api/config/rename-map-key` — rename an alias within a map-keyed
1122/// section, preserving the entry's value. Atomic: persists only on success.
1123pub async fn handle_rename_map_key(
1124    State(state): State<AppState>,
1125    headers: HeaderMap,
1126    axum::Json(body): axum::Json<RenameMapKeyBody>,
1127) -> Response {
1128    if let Err(e) = require_auth(&state, &headers) {
1129        return e.into_response();
1130    }
1131
1132    let mut working = state.config.read().clone();
1133
1134    let renamed = match working.rename_map_key(&body.path, &body.from, &body.to) {
1135        Ok(b) => b,
1136        Err(msg) => {
1137            return error_response(
1138                ConfigApiError::new(ConfigApiCode::ValidationFailed, msg).with_path(&body.path),
1139            );
1140        }
1141    };
1142
1143    if renamed {
1144        working.mark_dirty(&format!("{}.{}", body.path, body.from));
1145        working.mark_dirty(&format!("{}.{}", body.path, body.to));
1146        if let Err(e) = persist_and_swap(&state, working).await {
1147            return error_response(e);
1148        }
1149    }
1150
1151    axum::Json(RenameMapKeyResponse {
1152        path: body.path,
1153        from: body.from,
1154        to: body.to,
1155        renamed,
1156    })
1157    .into_response()
1158}
1159
1160/// PATCH /api/config — apply a JSON Patch document atomically.
1161///
1162/// Body is an array of operations executed in order against an in-memory
1163/// copy of the config. After all ops apply, `Config::validate()` runs once;
1164/// if it passes the snapshot is persisted and swapped in. If any op fails or
1165/// validation fails, on-disk + in-memory state are unchanged and the response
1166/// carries the offending op's index.
1167///
1168/// Supported ops: `add`, `remove`, `replace`, `test`.
1169/// `move` and `copy` return `op_not_supported` (no reference-graph in this PR).
1170/// `test` against a `#[secret]` or `#[derived_from_secret]` path is rejected
1171/// with `secret_test_forbidden` (would leak the value via differential outcome).
1172pub async fn handle_patch(
1173    State(state): State<AppState>,
1174    headers: HeaderMap,
1175    axum::Json(body): axum::Json<serde_json::Value>,
1176) -> Response {
1177    if let Err(e) = require_auth(&state, &headers) {
1178        return e.into_response();
1179    }
1180
1181    let ops = match parse_patch_ops(body) {
1182        Ok(ops) => ops,
1183        Err(e) => return error_response(e),
1184    };
1185
1186    let working = state.config.read().clone();
1187
1188    // Drift guard: if the on-disk file diverges from in-memory state on any
1189    // path the PATCH would touch, refuse with 409 ConfigChangedExternally
1190    // unless the client explicitly opts in to overwrite via the
1191    // `X-ZeroClaw-Override-Drift: true` header. The opt-in surface keeps
1192    // the contract loud: the only way to silently overwrite a hand-edit is
1193    // a deliberate header, never an accident.
1194    let override_drift = headers
1195        .get("x-zeroclaw-override-drift")
1196        .and_then(|v| v.to_str().ok())
1197        .map(|v| v.eq_ignore_ascii_case("true"))
1198        .unwrap_or(false);
1199    if !override_drift {
1200        let drifted = compute_drift(&working).await;
1201        if !drifted.is_empty() {
1202            let touched: std::collections::HashSet<String> = ops
1203                .iter()
1204                .map(|op| json_pointer_to_dotted(&op.path))
1205                .collect();
1206            let conflicts: Vec<&DriftEntry> = drifted
1207                .iter()
1208                .filter(|d| touched.contains(&d.path))
1209                .collect();
1210            if !conflicts.is_empty() {
1211                let conflict_paths: Vec<String> =
1212                    conflicts.iter().map(|d| d.path.clone()).collect();
1213                return error_response(ConfigApiError::new(
1214                    ConfigApiCode::ConfigChangedExternally,
1215                    format!(
1216                        "on-disk config has drifted from in-memory state on \
1217                         {} path(s) being patched: {}. Send `X-ZeroClaw-Override-Drift: true` \
1218                         to overwrite, or GET /api/config/drift to inspect first.",
1219                        conflicts.len(),
1220                        conflict_paths.join(", "),
1221                    ),
1222                ));
1223            }
1224        }
1225    }
1226
1227    let mut working = working;
1228    let mut results = Vec::with_capacity(ops.len());
1229
1230    for (idx, op) in ops.iter().enumerate() {
1231        let path = json_pointer_to_dotted(&op.path);
1232        if matches!(op.op.as_str(), "add" | "replace") {
1233            working.ensure_map_key_for_path(&path);
1234        }
1235        let info = lookup_prop_field(&working, &path);
1236        let is_sensitive = info
1237            .as_ref()
1238            .map(|i| i.is_secret || i.derived_from_secret)
1239            .unwrap_or(false);
1240
1241        match op.op.as_str() {
1242            "test" => {
1243                // Secret values can't leave the server, so a differential
1244                // test response would be the only signal — ban the op.
1245                if is_sensitive {
1246                    return error_response(
1247                        ConfigApiError::secret_test_forbidden(&path).with_op_index(idx),
1248                    );
1249                }
1250                let want = match op.value.as_ref() {
1251                    Some(v) => v.clone(),
1252                    None => {
1253                        return error_response(
1254                            ConfigApiError::new(
1255                                ConfigApiCode::ValueTypeMismatch,
1256                                "JSON Patch `test` op requires `value` field",
1257                            )
1258                            .with_path(&path)
1259                            .with_op_index(idx),
1260                        );
1261                    }
1262                };
1263                let actual_str = match working.get_prop(&path) {
1264                    Ok(v) => v,
1265                    Err(e) => return error_response(map_prop_error(e, &path).with_op_index(idx)),
1266                };
1267                let want_str = match json_to_setprop_string(&want, info.as_ref().map(|i| i.kind)) {
1268                    Ok(s) => s,
1269                    Err(e) => return error_response(e.with_path(&path).with_op_index(idx)),
1270                };
1271                if actual_str != want_str {
1272                    return error_response(
1273                        ConfigApiError::new(
1274                            ConfigApiCode::ValidationFailed,
1275                            format!("`test` op failed: expected {want_str:?}, got {actual_str:?}"),
1276                        )
1277                        .with_path(&path)
1278                        .with_op_index(idx),
1279                    );
1280                }
1281                results.push(PatchOpResult {
1282                    op: op.op.clone(),
1283                    path,
1284                    value: Some(serde_json::Value::String(actual_str)),
1285                    populated: None,
1286                    comment: None, // `test` ops don't write
1287                });
1288            }
1289            "add" | "replace" => {
1290                let value = match op.value.as_ref() {
1291                    Some(v) => v.clone(),
1292                    None => {
1293                        return error_response(
1294                            ConfigApiError::new(
1295                                ConfigApiCode::ValueTypeMismatch,
1296                                format!("JSON Patch `{}` op requires `value` field", op.op),
1297                            )
1298                            .with_path(&path)
1299                            .with_op_index(idx),
1300                        );
1301                    }
1302                };
1303                let value_str = match json_to_setprop_string(&value, info.as_ref().map(|i| i.kind))
1304                {
1305                    Ok(s) => s,
1306                    Err(e) => {
1307                        return error_response(e.with_path(&path).with_op_index(idx));
1308                    }
1309                };
1310                if let Err(e) = working.set_prop_persistent(&path, &value_str) {
1311                    return error_response(map_prop_error(e, &path).with_op_index(idx));
1312                }
1313                if is_sensitive {
1314                    results.push(PatchOpResult {
1315                        op: op.op.clone(),
1316                        path,
1317                        value: None,
1318                        populated: Some(!value_str.is_empty()),
1319                        comment: op.comment.clone(),
1320                    });
1321                } else {
1322                    results.push(PatchOpResult {
1323                        op: op.op.clone(),
1324                        path,
1325                        value: Some(serde_json::Value::String(value_str)),
1326                        populated: None,
1327                        comment: op.comment.clone(),
1328                    });
1329                }
1330            }
1331            "remove" => {
1332                if let Err(e) = working.set_prop_persistent(&path, "") {
1333                    return error_response(map_prop_error(e, &path).with_op_index(idx));
1334                }
1335                if is_sensitive {
1336                    results.push(PatchOpResult {
1337                        op: op.op.clone(),
1338                        path,
1339                        value: None,
1340                        populated: Some(false),
1341                        comment: op.comment.clone(),
1342                    });
1343                } else {
1344                    results.push(PatchOpResult {
1345                        op: op.op.clone(),
1346                        path,
1347                        value: Some(serde_json::Value::Null),
1348                        populated: None,
1349                        comment: op.comment.clone(),
1350                    });
1351                }
1352            }
1353            "comment" => {
1354                // Comment-only update: record the (path, comment) pair
1355                // for `apply_comments` after the patch commits, but
1356                // skip `set_prop` entirely. Lets the operator annotate
1357                // a secret without rotating its ciphertext.
1358                if info.is_none() {
1359                    return error_response(
1360                        ConfigApiError::path_not_found(&path).with_op_index(idx),
1361                    );
1362                }
1363                let Some(comment) = op.comment.clone() else {
1364                    return error_response(
1365                        ConfigApiError::new(
1366                            ConfigApiCode::ValueTypeMismatch,
1367                            "JSON Patch `comment` op requires `comment` field",
1368                        )
1369                        .with_path(&path)
1370                        .with_op_index(idx),
1371                    );
1372                };
1373                results.push(PatchOpResult {
1374                    op: op.op.clone(),
1375                    path,
1376                    value: None,
1377                    populated: None,
1378                    comment: Some(comment),
1379                });
1380            }
1381            "move" | "copy" => {
1382                return error_response(
1383                    ConfigApiError::op_not_supported(&op.op)
1384                        .with_path(&path)
1385                        .with_op_index(idx),
1386                );
1387            }
1388            other => {
1389                return error_response(
1390                    ConfigApiError::new(
1391                        ConfigApiCode::OpNotSupported,
1392                        format!("unknown JSON Patch operation `{other}`"),
1393                    )
1394                    .with_path(&path)
1395                    .with_op_index(idx),
1396                );
1397            }
1398        }
1399    }
1400
1401    // Per-PATCH validation is scoped to the dirty paths. See
1402    // `scoped_validate` for the contract.
1403    let scoped_validation_warnings = match scoped_validate(&working) {
1404        Ok(ws) => ws,
1405        Err(err) => return error_response(err),
1406    };
1407
1408    // Collect (path, comment) pairs from any op that supplied a non-None
1409    // comment. Applied after save() so the comment-preserving sync_table
1410    // pass doesn't strip them.
1411    let annotations: Vec<(String, String)> = ops
1412        .iter()
1413        .zip(results.iter())
1414        .filter_map(|(op, res)| op.comment.as_ref().map(|c| (res.path.clone(), c.clone())))
1415        .collect();
1416
1417    let config_path = working.config_path.clone();
1418    // Collect non-fatal validation warnings against the post-save state
1419    // before working is moved into persist_and_swap. Same signal as
1420    // `zeroclaw_log::record!` from `validate()`, surfaced structured so dashboard
1421    // callers see it.
1422    let mut warnings = working.collect_warnings();
1423    warnings.extend(scoped_validation_warnings);
1424    if let Err(e) = persist_and_swap(&state, working).await {
1425        return error_response(e);
1426    }
1427    if !annotations.is_empty()
1428        && let Err(e) =
1429            zeroclaw_config::comment_writer::apply_comments(&config_path, &annotations).await
1430    {
1431        // Comments are best-effort decoration; surface as a non-fatal warn.
1432        // The patch itself succeeded — return success but log the failure.
1433        ::zeroclaw_log::record!(
1434            WARN,
1435            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1436                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1437                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1438            "failed to apply PATCH op comments to config.toml"
1439        );
1440    }
1441
1442    axum::Json(PatchResponse {
1443        saved: true,
1444        results,
1445        warnings,
1446    })
1447    .into_response()
1448}
1449
1450/// Convert a JSON Pointer (`/agents/researcher/model_provider`) to the
1451/// dotted path the `Config::set_prop` machinery expects
1452/// (`agents.researcher.model_provider`). Accepts both forms — passing
1453/// already-dotted paths through unchanged so dashboard clients can use
1454/// whichever is more natural.
1455fn json_pointer_to_dotted(path: &str) -> String {
1456    if path.starts_with('/') {
1457        path.trim_start_matches('/').replace('/', ".")
1458    } else {
1459        path.to_string()
1460    }
1461}
1462
1463#[derive(Debug, Deserialize, Default)]
1464#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1465pub struct InitQuery {
1466    /// Optional section prefix to scope the init pass (e.g. `model_providers`).
1467    /// Without it, every uninitialized nested section gets its defaults.
1468    #[serde(default)]
1469    pub section: Option<String>,
1470}
1471
1472#[derive(Debug, Serialize)]
1473#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1474pub struct InitResponse {
1475    pub initialized: Vec<String>,
1476}
1477
1478/// POST /api/config/init?section=model_providers — instantiate `None` nested
1479/// sections with defaults. Mirrors `zeroclaw config init`. When every
1480/// requested section is already configured, returns `{initialized: []}`.
1481pub async fn handle_init(
1482    State(state): State<AppState>,
1483    headers: HeaderMap,
1484    Query(q): Query<InitQuery>,
1485) -> Response {
1486    if let Err(e) = require_auth(&state, &headers) {
1487        return e.into_response();
1488    }
1489
1490    let mut working = state.config.read().clone();
1491    let initialized: Vec<String> = working
1492        .init_defaults(q.section.as_deref())
1493        .into_iter()
1494        .map(str::to_string)
1495        .collect();
1496
1497    if initialized.is_empty() {
1498        return axum::Json(InitResponse { initialized }).into_response();
1499    }
1500
1501    for section in &initialized {
1502        working.mark_dirty(section);
1503    }
1504
1505    if let Err(err) = scoped_validate(&working) {
1506        return error_response(err);
1507    }
1508    if let Err(e) = persist_and_swap(&state, working).await {
1509        return error_response(e);
1510    }
1511
1512    axum::Json(InitResponse { initialized }).into_response()
1513}
1514
1515#[derive(Debug, Serialize)]
1516#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1517pub struct MigrateResponse {
1518    pub migrated: bool,
1519    /// Backup path written when migration ran; absent when the config was
1520    /// already at the current schema version.
1521    #[serde(skip_serializing_if = "Option::is_none")]
1522    pub backup_path: Option<String>,
1523    pub schema_version: u32,
1524}
1525
1526/// POST /api/config/migrate — apply the schema migration chain to the
1527/// on-disk config file in place. Mirrors `zeroclaw config migrate`. Backs
1528/// up the previous content alongside the original (`config.toml.bak`)
1529/// before writing the migrated form. Returns `{migrated: false}` when the
1530/// config is already at the current schema version.
1531pub async fn handle_migrate(State(state): State<AppState>, headers: HeaderMap) -> Response {
1532    if let Err(e) = require_auth(&state, &headers) {
1533        return e.into_response();
1534    }
1535
1536    let config_path = state.config.read().config_path.clone();
1537
1538    let raw = match tokio::fs::read_to_string(&config_path).await {
1539        Ok(s) => s,
1540        Err(e) => {
1541            return error_response(ConfigApiError::new(
1542                ConfigApiCode::InternalError,
1543                format!("failed to read config file: {e}"),
1544            ));
1545        }
1546    };
1547
1548    let migrated = match zeroclaw_config::migration::migrate_file(&raw) {
1549        Ok(out) => out,
1550        Err(e) => {
1551            return error_response(ConfigApiError::new(
1552                ConfigApiCode::ValidationFailed,
1553                format!("migration failed: {e}"),
1554            ));
1555        }
1556    };
1557
1558    match migrated {
1559        Some(new_content) => {
1560            // Atomic write path mirrors `Config::save()` and `migration::migrate_file_in_place`
1561            //: write temp + fsync → backup → atomic rename → fsync directory.
1562            // Without this sequence the documented durability guarantee on the comment above
1563            // doesn't hold: a copy-then-write window leaves both the original and the new
1564            // content vulnerable to power loss.
1565            let backup_path = config_path.with_extension("toml.bak");
1566            let parent = match config_path.parent() {
1567                Some(p) => p.to_path_buf(),
1568                None => {
1569                    return error_response(ConfigApiError::new(
1570                        ConfigApiCode::InternalError,
1571                        format!(
1572                            "config path has no parent: {}",
1573                            config_path.display().to_string()
1574                        ),
1575                    ));
1576                }
1577            };
1578            let file_name = match config_path.file_name().and_then(|n| n.to_str()) {
1579                Some(n) => n.to_string(),
1580                None => {
1581                    return error_response(ConfigApiError::new(
1582                        ConfigApiCode::InternalError,
1583                        format!(
1584                            "config path has no file name: {}",
1585                            config_path.display().to_string()
1586                        ),
1587                    ));
1588                }
1589            };
1590            let temp_path = parent.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
1591
1592            // 1. Write migrated content to temp + fsync.
1593            match tokio::fs::OpenOptions::new()
1594                .create_new(true)
1595                .write(true)
1596                .open(&temp_path)
1597                .await
1598            {
1599                Ok(mut temp) => {
1600                    use tokio::io::AsyncWriteExt;
1601                    if let Err(e) = temp.write_all(new_content.as_bytes()).await {
1602                        let _ = tokio::fs::remove_file(&temp_path).await;
1603                        return error_response(ConfigApiError::new(
1604                            ConfigApiCode::InternalError,
1605                            format!("failed to write migrated config to temp: {e}"),
1606                        ));
1607                    }
1608                    if let Err(e) = temp.sync_all().await {
1609                        let _ = tokio::fs::remove_file(&temp_path).await;
1610                        return error_response(ConfigApiError::new(
1611                            ConfigApiCode::InternalError,
1612                            format!("failed to fsync migrated config temp: {e}"),
1613                        ));
1614                    }
1615                }
1616                Err(e) => {
1617                    return error_response(ConfigApiError::new(
1618                        ConfigApiCode::InternalError,
1619                        format!("failed to create temp config file: {e}"),
1620                    ));
1621                }
1622            }
1623
1624            // 2. Backup BEFORE replacing the original.
1625            if let Err(e) = tokio::fs::copy(&config_path, &backup_path).await {
1626                let _ = tokio::fs::remove_file(&temp_path).await;
1627                return error_response(ConfigApiError::new(
1628                    ConfigApiCode::InternalError,
1629                    format!("failed to write backup: {e}"),
1630                ));
1631            }
1632
1633            // 3. Atomic rename. On failure, restore from backup.
1634            if let Err(e) = tokio::fs::rename(&temp_path, &config_path).await {
1635                let _ = tokio::fs::remove_file(&temp_path).await;
1636                if backup_path.exists() {
1637                    let _ = tokio::fs::copy(&backup_path, &config_path).await;
1638                }
1639                return error_response(ConfigApiError::new(
1640                    ConfigApiCode::InternalError,
1641                    format!("failed to atomically replace config: {e}"),
1642                ));
1643            }
1644
1645            // 4. Fsync the parent directory so the rename is durable.
1646            #[cfg(unix)]
1647            if let Ok(dir) = tokio::fs::File::open(&parent).await {
1648                let _ = dir.sync_all().await;
1649            }
1650
1651            // Re-read into memory so subsequent requests see the migrated state.
1652            let new_cfg: zeroclaw_config::schema::Config = match toml::from_str(&new_content) {
1653                Ok(c) => c,
1654                Err(e) => {
1655                    return error_response(ConfigApiError::new(
1656                        ConfigApiCode::ReloadFailed,
1657                        format!("re-parse after migration failed: {e}"),
1658                    ));
1659                }
1660            };
1661            *state.config.write() = new_cfg;
1662
1663            axum::Json(MigrateResponse {
1664                migrated: true,
1665                backup_path: Some(backup_path.display().to_string()),
1666                schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
1667            })
1668            .into_response()
1669        }
1670        None => axum::Json(MigrateResponse {
1671            migrated: false,
1672            backup_path: None,
1673            schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
1674        })
1675        .into_response(),
1676    }
1677}
1678
1679/// OPTIONS /api/config — whole-config schema (capabilities, not values)
1680///
1681/// Returns the JSON Schema document for the `Config` type. Distinguishes CORS
1682/// preflight (carries `Access-Control-Request-Method`) from schema-discovery
1683/// requests; preflight gets the standard CORS response only.
1684///
1685/// Static per build — clients should cache via the build-time ETag.
1686pub async fn handle_options_config(headers: HeaderMap) -> Response {
1687    // CORS preflight short-circuit
1688    if headers.contains_key("access-control-request-method") {
1689        let mut response = StatusCode::NO_CONTENT.into_response();
1690        let h = response.headers_mut();
1691        h.insert(
1692            "Access-Control-Allow-Methods",
1693            HeaderValue::from_static("GET, PUT, PATCH, OPTIONS"),
1694        );
1695        h.insert(
1696            "Access-Control-Allow-Headers",
1697            HeaderValue::from_static("Authorization, Content-Type, If-None-Match"),
1698        );
1699        return response;
1700    }
1701
1702    schema_response("zeroclaw_config_schema_full")
1703}
1704
1705/// OPTIONS /api/config/prop?path=agents.researcher.model_provider — per-field schema fragment.
1706///
1707/// Returns 404 with `path_not_found` if the path doesn't resolve against the
1708/// in-memory config — same contract as `GET /api/config/prop`. Previously
1709/// returned the whole-config schema regardless, which silently masked typos.
1710///
1711/// Per-path subtree extraction (walking the JSON Schema tree by JSON Pointer
1712/// to return just the relevant subtree) is a follow-up; today we still return
1713/// the full schema with a `x-zeroclaw-requested-path` + per-field metadata
1714/// (kind, type_hint, is_secret) so the frontend has everything it needs to
1715/// render the input without a separate round-trip.
1716pub async fn handle_options_prop(
1717    State(state): State<AppState>,
1718    headers: HeaderMap,
1719    Query(q): Query<PropQuery>,
1720) -> Response {
1721    if headers.contains_key("access-control-request-method") {
1722        let mut response = StatusCode::NO_CONTENT.into_response();
1723        let h = response.headers_mut();
1724        h.insert(
1725            "Access-Control-Allow-Methods",
1726            HeaderValue::from_static("GET, PUT, DELETE, OPTIONS"),
1727        );
1728        h.insert(
1729            "Access-Control-Allow-Headers",
1730            HeaderValue::from_static("Authorization, Content-Type, If-None-Match"),
1731        );
1732        return response;
1733    }
1734
1735    // Resolve the path against the in-memory config; 404 if it doesn't
1736    // exist. (No auth required for shape discovery — same as OPTIONS /api/config.)
1737    let config = state.config.read().clone();
1738    let info = match lookup_prop_field(&config, &q.path) {
1739        Some(info) => info,
1740        None => return error_response(ConfigApiError::path_not_found(&q.path)),
1741    };
1742
1743    let (whole_body, etag) = cached_schema();
1744    let mut body = whole_body.clone();
1745    if let serde_json::Value::Object(ref mut map) = body {
1746        map.insert(
1747            "x-zeroclaw-requested-path".into(),
1748            serde_json::Value::String(q.path.clone()),
1749        );
1750        map.insert(
1751            "x-zeroclaw-prop".into(),
1752            serde_json::json!({
1753                "path": q.path,
1754                "kind": prop_kind_wire(info.kind),
1755                "type_hint": info.type_hint,
1756                "is_secret": info.is_secret || info.derived_from_secret,
1757                "enum_variants": info.enum_variants.map(|f| f()).unwrap_or_default(),
1758                "category": info.category,
1759            }),
1760        );
1761    }
1762    let mut response = (StatusCode::OK, axum::Json(body)).into_response();
1763    response.headers_mut().insert(
1764        header::ALLOW,
1765        HeaderValue::from_static("GET, PUT, DELETE, OPTIONS"),
1766    );
1767    response
1768        .headers_mut()
1769        .insert(header::ETAG, HeaderValue::from_str(etag).unwrap());
1770    response
1771}
1772
1773fn schema_response(_label: &'static str) -> Response {
1774    let (body, etag) = cached_schema();
1775    let mut response = (StatusCode::OK, axum::Json(body.clone())).into_response();
1776    response.headers_mut().insert(
1777        header::ALLOW,
1778        HeaderValue::from_static("GET, PUT, PATCH, OPTIONS"),
1779    );
1780    response
1781        .headers_mut()
1782        .insert(header::ETAG, HeaderValue::from_str(etag).unwrap());
1783    response
1784}
1785
1786/// Compute the OPTIONS schema body + ETag once and cache them. The schema is
1787/// static per build (schemars output is deterministic for a given Config
1788/// type), so re-rendering on every request is pure waste — we'd send the
1789/// same bytes back every time and re-hash them too. The previous
1790/// implementation re-rendered + re-hashed on every OPTIONS hit; this caches
1791/// both behind a `OnceLock`.
1792fn cached_schema() -> (&'static serde_json::Value, &'static str) {
1793    use std::sync::OnceLock;
1794    static CACHE: OnceLock<(serde_json::Value, String)> = OnceLock::new();
1795    let entry = CACHE.get_or_init(|| {
1796        let body = schema_body_value();
1797        let etag = build_etag_for(&body);
1798        (body, etag)
1799    });
1800    (&entry.0, entry.1.as_str())
1801}
1802
1803#[cfg(feature = "schema-export")]
1804fn schema_body_value() -> serde_json::Value {
1805    let schema = schemars::schema_for!(zeroclaw_config::schema::Config);
1806    serde_json::to_value(schema).unwrap_or(serde_json::Value::Null)
1807}
1808
1809#[cfg(not(feature = "schema-export"))]
1810fn schema_body_value() -> serde_json::Value {
1811    serde_json::json!({
1812        "error": "schema-export feature not enabled in this build",
1813    })
1814}
1815
1816/// Stable ETag derived from the rendered schema bytes. Computed once via
1817/// `cached_schema()`; this helper is kept separate so tests can verify
1818/// determinism.
1819fn build_etag_for(body: &serde_json::Value) -> String {
1820    use std::hash::{Hash, Hasher};
1821    let bytes = body.to_string();
1822    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1823    bytes.hash(&mut hasher);
1824    format!("\"{:016x}\"", hasher.finish())
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829    use super::*;
1830
1831    // typed-value coercion tests live in zeroclaw_config::typed_value
1832    // — shared helper, single source of truth.
1833    //
1834    // build_comment_prefix tests live in zeroclaw_config::comment_writer
1835    // — same reason.
1836
1837    #[test]
1838    fn map_prop_error_classifies_unknown_property() {
1839        let err = anyhow::Error::msg("Unknown property 'foo.bar'");
1840        let api_err = map_prop_error(err, "foo.bar");
1841        assert_eq!(api_err.code, ConfigApiCode::PathNotFound);
1842    }
1843
1844    #[test]
1845    fn map_prop_error_classifies_type_mismatch() {
1846        // The classifier (config::api_error::classify_validation_message) now
1847        // matches "type mismatch" → ValueTypeMismatch; was ValidationFailed.
1848        let err = anyhow::Error::msg("type mismatch: expected u64");
1849        let api_err = map_prop_error(err, "scheduler.max_concurrent");
1850        assert_eq!(api_err.code, ConfigApiCode::ValueTypeMismatch);
1851    }
1852
1853    #[test]
1854    fn map_prop_error_falls_back_to_validation_on_unknown_message() {
1855        let err = anyhow::Error::msg("some completely unrecognized validator message");
1856        let api_err = map_prop_error(err, "scheduler.max_concurrent");
1857        assert_eq!(api_err.code, ConfigApiCode::ValidationFailed);
1858    }
1859
1860    #[test]
1861    fn json_pointer_to_dotted_handles_pointer_form() {
1862        assert_eq!(
1863            json_pointer_to_dotted("/providers/models/openrouter/api-key"),
1864            "providers.models.openrouter.api-key"
1865        );
1866    }
1867
1868    #[test]
1869    fn json_pointer_to_dotted_passes_dotted_through() {
1870        assert_eq!(
1871            json_pointer_to_dotted("providers.models.openrouter.api-key"),
1872            "providers.models.openrouter.api-key"
1873        );
1874        assert_eq!(
1875            json_pointer_to_dotted("scheduler.max_concurrent"),
1876            "scheduler.max_concurrent"
1877        );
1878    }
1879
1880    #[test]
1881    fn json_pointer_to_dotted_handles_empty_root() {
1882        assert_eq!(json_pointer_to_dotted(""), "");
1883        assert_eq!(json_pointer_to_dotted("/"), "");
1884    }
1885
1886    // ── `test` op type-coercion invariants ─────────────────────────────
1887    //
1888    // The `test` JSON Patch op compares the incoming `value` against the
1889    // current property value. `Config::get_prop` always returns a display
1890    // string, regardless of the underlying field's PropKind. Before the
1891    // fix, the handler wrapped that string in `Value::String(...)` and
1892    // compared against the raw incoming `Value::Bool(true)` /
1893    // `Value::Number(42)` / etc. — never equal even when the test should
1894    // pass. The fix normalizes both sides to display strings via
1895    // `json_to_setprop_string` (the same helper `add`/`replace` use).
1896    //
1897    // These tests pin the invariant: for every PropKind that surfaces on
1898    // the API, `json_to_setprop_string(<typed JSON>, Some(kind))` equals
1899    // the string `Config::get_prop` returns.
1900    use zeroclaw_config::traits::PropKind;
1901
1902    #[test]
1903    fn test_op_coercion_bool_typed_value_matches_stored() {
1904        let mut cfg = zeroclaw_config::schema::Config::default();
1905        cfg.risk_profiles.insert(
1906            "default".into(),
1907            zeroclaw_config::schema::RiskProfileConfig::default(),
1908        );
1909        cfg.set_prop("risk_profiles.default.workspace_only", "true")
1910            .expect("set_prop bool");
1911        let actual = cfg
1912            .get_prop("risk_profiles.default.workspace_only")
1913            .expect("get_prop");
1914        let want_typed = json_to_setprop_string(&serde_json::json!(true), Some(PropKind::Bool))
1915            .expect("coerce bool true");
1916        assert_eq!(
1917            actual, want_typed,
1918            "bool field: typed JSON `true` must coerce to the same display string \
1919             as `get_prop` returns; got actual={actual:?} want_typed={want_typed:?}"
1920        );
1921
1922        // Legacy string-form (`Value::String("true")`) for the same bool
1923        // field must also coerce to the same string — back-compat for
1924        // clients that send strings instead of booleans.
1925        let want_string = json_to_setprop_string(&serde_json::json!("true"), Some(PropKind::Bool))
1926            .expect("coerce bool from string");
1927        assert_eq!(actual, want_string);
1928    }
1929
1930    #[test]
1931    fn test_op_coercion_integer_typed_value_matches_stored() {
1932        let mut cfg = zeroclaw_config::schema::Config::default();
1933        cfg.set_prop("gateway.port", "42617")
1934            .expect("set_prop integer");
1935        let actual = cfg.get_prop("gateway.port").expect("get_prop");
1936        let want_typed = json_to_setprop_string(&serde_json::json!(42617), Some(PropKind::Integer))
1937            .expect("coerce integer");
1938        assert_eq!(
1939            actual, want_typed,
1940            "integer field coercion: actual={actual:?} want_typed={want_typed:?}"
1941        );
1942
1943        // Legacy string-form must also coerce equivalently.
1944        let want_string =
1945            json_to_setprop_string(&serde_json::json!("42617"), Some(PropKind::Integer))
1946                .expect("coerce integer from string");
1947        assert_eq!(actual, want_string);
1948    }
1949
1950    #[test]
1951    fn test_op_coercion_float_typed_value_matches_stored() {
1952        // `gateway.host` is a String, but [scheduler] / autonomy carry floats
1953        // for things like temperatures. Pick a path that's a float field on
1954        // the default config. If the schema gains/loses a float field this
1955        // test will need updating; that's fine — we just need one float to
1956        // pin the contract.
1957        let mut cfg = zeroclaw_config::schema::Config::default();
1958        // autonomy doesn't carry floats today; use a model_provider temperature
1959        // by setting a known model provider entry. The model providers map
1960        // is set up via map keys, so use a path that's unambiguously float.
1961        // Fall back to set_prop on a known float location:
1962        match cfg.set_prop("providers.models.openai.temperature", "0.7") {
1963            Ok(()) => {
1964                let actual = cfg
1965                    .get_prop("providers.models.openai.temperature")
1966                    .expect("get_prop float");
1967                let want_typed =
1968                    json_to_setprop_string(&serde_json::json!(0.7), Some(PropKind::Float))
1969                        .expect("coerce float typed");
1970                assert_eq!(
1971                    actual, want_typed,
1972                    "float field coercion: actual={actual:?} want_typed={want_typed:?}"
1973                );
1974            }
1975            Err(_) => {
1976                // Float path not available on default Config — skip without
1977                // failing. The bool and integer tests cover the same
1978                // invariant; float just pins the additional case.
1979            }
1980        }
1981    }
1982
1983    #[test]
1984    fn test_op_coercion_string_field_no_regression() {
1985        let mut cfg = zeroclaw_config::schema::Config::default();
1986        cfg.set_prop("gateway.host", "10.0.0.1")
1987            .expect("set_prop string");
1988        let actual = cfg.get_prop("gateway.host").expect("get_prop string");
1989        let want_typed =
1990            json_to_setprop_string(&serde_json::json!("10.0.0.1"), Some(PropKind::String))
1991                .expect("coerce string");
1992        assert_eq!(actual, want_typed);
1993    }
1994
1995    #[test]
1996    fn test_op_coercion_mismatched_value_correctly_fails() {
1997        let mut cfg = zeroclaw_config::schema::Config::default();
1998        cfg.risk_profiles.insert(
1999            "default".into(),
2000            zeroclaw_config::schema::RiskProfileConfig::default(),
2001        );
2002        cfg.set_prop("risk_profiles.default.workspace_only", "true")
2003            .expect("set_prop");
2004        let actual = cfg
2005            .get_prop("risk_profiles.default.workspace_only")
2006            .expect("get_prop");
2007        let want = json_to_setprop_string(&serde_json::json!(false), Some(PropKind::Bool))
2008            .expect("coerce bool false");
2009        assert_ne!(
2010            actual, want,
2011            "bool true must not match bool false after coercion — \
2012             a mismatched test op should fail with ValidationFailed"
2013        );
2014    }
2015
2016    // ── Integration-flavored tests: drift detection + comment writing ──
2017
2018    use std::path::PathBuf;
2019
2020    fn temp_config_path() -> (tempfile::TempDir, PathBuf) {
2021        let tmp = tempfile::tempdir().expect("tempdir");
2022        let path = tmp.path().join("config.toml");
2023        (tmp, path)
2024    }
2025
2026    #[tokio::test]
2027    async fn compute_drift_returns_empty_when_in_memory_matches_disk() {
2028        let (_tmp, path) = temp_config_path();
2029        let cfg = zeroclaw_config::schema::Config {
2030            config_path: path.clone(),
2031            ..Default::default()
2032        };
2033        // Write the in-memory state to disk first so they agree by definition.
2034        cfg.save().await.expect("save");
2035
2036        let drift = compute_drift(&cfg).await;
2037        assert!(
2038            drift.is_empty(),
2039            "expected no drift right after save, got {drift:?}"
2040        );
2041    }
2042
2043    #[tokio::test]
2044    async fn compute_drift_surfaces_mismatched_non_secret_field() {
2045        let (_tmp, path) = temp_config_path();
2046        let mut cfg = zeroclaw_config::schema::Config {
2047            config_path: path.clone(),
2048            ..Default::default()
2049        };
2050        cfg.save().await.expect("initial save");
2051
2052        // Mutate the in-memory config without saving.
2053        cfg.set_prop("gateway.host", "10.0.0.1").expect("set_prop");
2054
2055        let drift = compute_drift(&cfg).await;
2056        let entry = drift
2057            .iter()
2058            .find(|d| d.path == "gateway.host")
2059            .expect("expected gateway.host in drift summary");
2060        assert!(!entry.secret);
2061        assert!(entry.drifted);
2062        assert!(entry.in_memory_value.is_some());
2063        assert!(entry.on_disk_value.is_some());
2064    }
2065
2066    #[tokio::test]
2067    async fn compute_drift_returns_empty_when_no_disk_file() {
2068        let (_tmp, path) = temp_config_path();
2069        let cfg = zeroclaw_config::schema::Config {
2070            config_path: path.clone(),
2071            ..Default::default()
2072        };
2073        // Don't save — file does not exist.
2074        let drift = compute_drift(&cfg).await;
2075        assert!(drift.is_empty());
2076    }
2077
2078    #[tokio::test]
2079    async fn apply_comments_writes_decoration_to_existing_value() {
2080        let (_tmp, path) = temp_config_path();
2081        let mut cfg = zeroclaw_config::schema::Config {
2082            config_path: path.clone(),
2083            ..Default::default()
2084        };
2085        cfg.set_prop("gateway.host", "10.0.0.5").expect("set_prop");
2086        cfg.save().await.expect("save");
2087
2088        zeroclaw_config::comment_writer::apply_comments(
2089            &path,
2090            &[("gateway.host".into(), "raised after Q3 backlog".into())],
2091        )
2092        .await
2093        .expect("apply_comments");
2094
2095        let raw = tokio::fs::read_to_string(&path).await.expect("read back");
2096        // Existence check: the comment text appears in the file.
2097        assert!(
2098            raw.contains("# raised after Q3 backlog"),
2099            "expected comment in file, got:\n{raw}"
2100        );
2101
2102        // Positional check: the comment appears IMMEDIATELY ABOVE `host = ...`,
2103        // not somewhere else in the file. The previous version of the helper
2104        // wrote the prefix between `=` and the value, producing broken TOML —
2105        // this assertion would have caught that bug.
2106        let lines: Vec<&str> = raw.lines().collect();
2107        let host_line_idx = lines
2108            .iter()
2109            .position(|l| l.trim_start().starts_with("host"))
2110            .expect("host = line in saved config");
2111        assert!(
2112            host_line_idx > 0,
2113            "host line is at top — comment can't precede it"
2114        );
2115        let above = lines[host_line_idx - 1];
2116        assert_eq!(
2117            above.trim(),
2118            "# raised after Q3 backlog",
2119            "expected comment immediately above `host = ...`, got line above:\n  {above:?}\nfull file:\n{raw}"
2120        );
2121
2122        // Round-trip check: re-parsing the file must succeed (broken
2123        // decoration target produces malformed TOML).
2124        let _: toml::Value = toml::from_str(&raw)
2125            .unwrap_or_else(|e| panic!("re-parse failed after apply_comments: {e}\nfile:\n{raw}"));
2126    }
2127
2128    #[test]
2129    fn scrub_credentials_catches_credential_shaped_strings() {
2130        // Defence-in-depth: scrub_credentials (the workspace's existing
2131        // tracing scrubber) catches keyword=value patterns that are the
2132        // most likely shape for accidental log leakage. Pin the contract
2133        // here so a regression in either the regex or the assumed shapes
2134        // gets caught — important for the new HTTP CRUD surface where the
2135        // dashboard sends real bearer tokens, secret PUT bodies, etc.
2136        use zeroclaw_runtime::agent::loop_::scrub_credentials;
2137
2138        // Three realistic shapes a tracing call might emit. All must be
2139        // redacted by the existing scrubber.
2140        // The scrubber matches KEYWORD<:|=>VALUE patterns. These are the
2141        // shapes most likely to appear in a tracing log line (`tracing`'s
2142        // `?body` debug-format renders structs as `field: value` and JSON
2143        // keys are typically written as `"key": "value"`).
2144        let cases = [
2145            // Field=value style log line.
2146            (
2147                "api-key=sk-live-abcdef-1234567890",
2148                "sk-live-abcdef-1234567890",
2149            ),
2150            // JSON-ish quoted key-value pair.
2151            (
2152                r#""token": "sk-test-supersecret-12345""#,
2153                "sk-test-supersecret-12345",
2154            ),
2155            // Explicit secret key.
2156            (
2157                "secret: hunter2-not-a-real-password",
2158                "hunter2-not-a-real-password",
2159            ),
2160            // Bearer credential pair.
2161            (
2162                "credential: bearer-token-abcdef-9876",
2163                "bearer-token-abcdef-9876",
2164            ),
2165        ];
2166        for (input, raw_secret) in cases {
2167            let scrubbed = scrub_credentials(input);
2168            assert!(
2169                !scrubbed.contains(raw_secret),
2170                "scrubber missed `{raw_secret}` in:\n  input    : {input}\n  scrubbed : {scrubbed}"
2171            );
2172            assert!(
2173                scrubbed.contains("REDACTED"),
2174                "expected REDACTED marker in:\n  input    : {input}\n  scrubbed : {scrubbed}"
2175            );
2176        }
2177    }
2178
2179    #[tokio::test]
2180    async fn compute_drift_detects_external_edit_to_field() {
2181        // Persist initial state, externally edit the file, drift surfaces
2182        // the touched path. This is the substrate the PATCH 409 guard fires on.
2183        let (_tmp, path) = temp_config_path();
2184        let mut cfg = zeroclaw_config::schema::Config {
2185            config_path: path.clone(),
2186            ..Default::default()
2187        };
2188        cfg.set_prop("gateway.host", "10.0.0.1").expect("set");
2189        cfg.save().await.expect("save");
2190
2191        // Simulate a hand-edit while the daemon "wasn't looking".
2192        let on_disk = tokio::fs::read_to_string(&path).await.unwrap();
2193        let edited = on_disk.replace("10.0.0.1", "192.168.1.1");
2194        tokio::fs::write(&path, edited).await.unwrap();
2195
2196        // In-memory still believes 10.0.0.1; on-disk now says 192.168.1.1.
2197        let drift = compute_drift(&cfg).await;
2198        let entry = drift
2199            .iter()
2200            .find(|d| d.path == "gateway.host")
2201            .expect("expected gateway.host in drift summary after external edit");
2202        assert!(entry.drifted);
2203        assert_eq!(
2204            entry.in_memory_value,
2205            Some(serde_json::Value::String("10.0.0.1".into()))
2206        );
2207        assert_eq!(
2208            entry.on_disk_value,
2209            Some(serde_json::Value::String("192.168.1.1".into()))
2210        );
2211    }
2212
2213    #[test]
2214    fn secret_response_only_carries_path_and_populated_flag() {
2215        // Belt-and-braces: serialize a SecretResponse and assert the JSON
2216        // shape carries neither a `value` field nor a length-leaking string.
2217        // If anyone ever adds a field to SecretResponse, this test fires.
2218        let r = SecretResponse {
2219            path: "providers.models.ollama.api-key".into(),
2220            populated: true,
2221        };
2222        let json = serde_json::to_value(&r).expect("serialize");
2223        let obj = json.as_object().expect("object");
2224        let keys: Vec<&str> = obj.keys().map(String::as_str).collect();
2225        assert_eq!(
2226            keys,
2227            vec!["path", "populated"],
2228            "SecretResponse must carry only path + populated"
2229        );
2230        assert!(!obj.contains_key("value"));
2231        assert!(!obj.contains_key("length"));
2232        assert!(!obj.contains_key("hash"));
2233        assert!(!obj.contains_key("masked"));
2234    }
2235
2236    #[test]
2237    fn lookup_prop_field_synthesizes_dynamic_http_request_secret_metadata() {
2238        let cfg = zeroclaw_config::schema::Config::default();
2239        let field = lookup_prop_field(&cfg, "http_request.secrets.api_token")
2240            .expect("dynamic http_request secret metadata");
2241
2242        assert_eq!(field.kind, PropKind::String);
2243        assert!(field.is_secret);
2244        assert_eq!(
2245            field.credential_class,
2246            Some(zeroclaw_config::traits::CredentialSurfaceClass::EncryptedSecret)
2247        );
2248    }
2249
2250    #[test]
2251    fn list_entry_for_secret_omits_value_field() {
2252        let entry = ListEntry {
2253            path: "providers.models.ollama.api-key".into(),
2254            category: "providers.models".into(),
2255            kind: "string",
2256            type_hint: "Option<String>",
2257            value: None,
2258            populated: true,
2259            is_secret: true,
2260            is_env_overridden: false,
2261            enum_variants: vec![],
2262            section: Some("providers.models"),
2263            tab: "",
2264        };
2265        let json = serde_json::to_value(&entry).expect("serialize");
2266        let obj = json.as_object().expect("object");
2267        // skip_serializing_if on `value` means it must be absent.
2268        assert!(
2269            !obj.contains_key("value"),
2270            "secret list entry leaks `value` field"
2271        );
2272        // is_secret marker must be present so the dashboard can render it as locked.
2273        assert_eq!(obj.get("is_secret"), Some(&serde_json::Value::Bool(true)));
2274        assert_eq!(obj.get("populated"), Some(&serde_json::Value::Bool(true)));
2275    }
2276
2277    #[test]
2278    fn gateway_paired_tokens_is_gateway_managed() {
2279        // The `Configurable` derive emits prop-field names in the field's
2280        // snake_case form, so the canonical name is `gateway.paired_tokens`
2281        // (underscore). The matcher must use that exact string, otherwise the
2282        // guard never fires and the secret keeps surfacing as drift.
2283        assert!(
2284            is_gateway_managed_field("gateway.paired_tokens"),
2285            "gateway.paired_tokens must be treated as gateway-managed"
2286        );
2287        // The old hyphenated form never matched a real prop-field name.
2288        assert!(!is_gateway_managed_field("gateway.paired-tokens"));
2289
2290        // Guard against the field being renamed or the derive changing its
2291        // naming convention out from under the matcher.
2292        let cfg = zeroclaw_config::schema::Config::default();
2293        assert!(
2294            cfg.prop_fields()
2295                .iter()
2296                .any(|p| p.name == "gateway.paired_tokens"),
2297            "expected a prop-field named gateway.paired_tokens"
2298        );
2299    }
2300
2301    #[tokio::test]
2302    async fn compute_drift_excludes_gateway_paired_tokens() {
2303        let (_tmp, path) = temp_config_path();
2304        let mut cfg = zeroclaw_config::schema::Config {
2305            config_path: path.clone(),
2306            ..Default::default()
2307        };
2308        cfg.save().await.expect("initial save");
2309
2310        // Mutate the gateway-managed secret in memory without saving. Drift
2311        // detection must not surface it because the gateway owns it.
2312        cfg.gateway.paired_tokens = vec!["minted-by-the-gateway".into()];
2313
2314        let drift = compute_drift(&cfg).await;
2315        assert!(
2316            !drift.iter().any(|d| d.path == "gateway.paired_tokens"),
2317            "gateway.paired_tokens must never appear in drift, got {drift:?}"
2318        );
2319    }
2320
2321    /// Guardrail against the original #7156 bug class: a new `#[secret]` field
2322    /// added under `[gateway]` that the gateway also mints/rotates itself will
2323    /// reproduce the permanent-banner symptom unless it is explicitly listed
2324    /// in `is_gateway_managed_field` (or whitelisted below as operator-edited).
2325    /// This test fails when such a field lands without a corresponding matcher
2326    /// entry, forcing the author to make a deliberate decision instead of
2327    /// silently re-introducing the bug.
2328    #[test]
2329    fn every_gateway_secret_is_classified() {
2330        // Secrets under `[gateway]` that are OPERATOR-EDITED (not gateway-
2331        // managed). Add the field's prop-field name here only if the gateway
2332        // does NOT mint/rotate/persist it itself, so legitimate drift between
2333        // disk and memory IS surfaceable. Empty for now — `paired_tokens` is
2334        // the only `[gateway]` secret and it's gateway-managed.
2335        const OPERATOR_EDITED_GATEWAY_SECRETS: &[&str] = &[];
2336
2337        let cfg = zeroclaw_config::schema::Config::default();
2338        let unclassified: Vec<String> = cfg
2339            .prop_fields()
2340            .iter()
2341            .filter(|p| p.is_secret && p.name.starts_with("gateway."))
2342            .map(|p| p.name.clone())
2343            .filter(|name| {
2344                !is_gateway_managed_field(name)
2345                    && !OPERATOR_EDITED_GATEWAY_SECRETS.contains(&name.as_str())
2346            })
2347            .collect();
2348
2349        assert!(
2350            unclassified.is_empty(),
2351            "new [gateway] secret field(s) {unclassified:?} are not classified.\n\
2352             If the gateway mints/rotates/persists this field itself, add it to \
2353             `is_gateway_managed_field`.\n\
2354             If operators edit it directly in config.toml, add it to the \
2355             OPERATOR_EDITED_GATEWAY_SECRETS list in this test."
2356        );
2357    }
2358
2359    #[test]
2360    fn drift_entry_for_secret_omits_both_values() {
2361        let entry = DriftEntry {
2362            path: "providers.models.ollama.api-key".into(),
2363            secret: true,
2364            drifted: true,
2365            in_memory_value: None,
2366            on_disk_value: None,
2367        };
2368        let json = serde_json::to_value(&entry).expect("serialize");
2369        let obj = json.as_object().expect("object");
2370        assert!(
2371            !obj.contains_key("in_memory_value"),
2372            "secret drift entry leaks in_memory_value"
2373        );
2374        assert!(
2375            !obj.contains_key("on_disk_value"),
2376            "secret drift entry leaks on_disk_value"
2377        );
2378        assert_eq!(obj.get("secret"), Some(&serde_json::Value::Bool(true)));
2379        assert_eq!(obj.get("drifted"), Some(&serde_json::Value::Bool(true)));
2380    }
2381
2382    #[tokio::test]
2383    async fn apply_comments_clears_existing_comment_when_passed_empty() {
2384        let (_tmp, path) = temp_config_path();
2385        let mut cfg = zeroclaw_config::schema::Config {
2386            config_path: path.clone(),
2387            ..Default::default()
2388        };
2389        cfg.set_prop("gateway.host", "10.0.0.5").expect("set_prop");
2390        cfg.save().await.expect("save");
2391
2392        zeroclaw_config::comment_writer::apply_comments(
2393            &path,
2394            &[("gateway.host".into(), "first reason".into())],
2395        )
2396        .await
2397        .expect("apply first comment");
2398        zeroclaw_config::comment_writer::apply_comments(
2399            &path,
2400            &[("gateway.host".into(), String::new())],
2401        )
2402        .await
2403        .expect("apply empty");
2404
2405        let raw = tokio::fs::read_to_string(&path).await.expect("read back");
2406        assert!(
2407            !raw.contains("first reason"),
2408            "expected the prior comment to be cleared, got:\n{raw}"
2409        );
2410    }
2411}