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