1use axum::{
15 Json,
16 extract::{Query, State},
17 http::{HeaderMap, HeaderValue, StatusCode, header},
18 response::{IntoResponse, Response},
19};
20use serde::{Deserialize, Serialize};
21use zeroclaw_config::api_error::{ConfigApiCode, ConfigApiError};
22use zeroclaw_config::field_visibility;
23use zeroclaw_config::sections::section_for_path;
24use zeroclaw_config::traits::MaskSecrets;
25
26use super::AppState;
27use super::api::require_auth;
28
29#[derive(Debug, Deserialize)]
33#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
34pub struct PropQuery {
35 pub path: String,
36}
37
38#[derive(Debug, Deserialize, Default)]
40#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
41pub struct ListQuery {
42 #[serde(default)]
43 pub prefix: Option<String>,
44}
45
46#[derive(Debug, Deserialize)]
50#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
51pub struct PropPutBody {
52 pub path: String,
53 pub value: serde_json::Value,
54 #[serde(default)]
55 pub comment: Option<String>,
56}
57
58#[derive(Debug, Deserialize)]
68#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
69pub struct PatchOp {
70 pub op: String,
71 pub path: String,
72 #[serde(default)]
73 pub value: Option<serde_json::Value>,
74 #[serde(default)]
75 pub comment: Option<String>,
76}
77
78#[derive(Debug, Serialize)]
80#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
81pub struct PatchOpResult {
82 pub op: String,
83 pub path: String,
84 #[serde(skip_serializing_if = "Option::is_none")]
88 pub value: Option<serde_json::Value>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub populated: Option<bool>,
91 #[serde(skip_serializing_if = "Option::is_none")]
95 pub comment: Option<String>,
96}
97
98#[derive(Debug, Serialize)]
99#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
100pub struct PatchResponse {
101 pub saved: bool,
102 pub results: Vec<PatchOpResult>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
111}
112
113pub async fn handle_config_get(State(state): State<AppState>, headers: HeaderMap) -> Response {
118 if let Err(e) = require_auth(&state, &headers) {
119 return e.into_response();
120 }
121
122 let mut cfg = state.config.read().clone();
123 cfg.mask_secrets();
124 Json(cfg).into_response()
125}
126
127fn parse_patch_ops(value: serde_json::Value) -> Result<Vec<PatchOp>, ConfigApiError> {
128 let ops = value.as_array().ok_or_else(|| {
129 ConfigApiError::new(
130 ConfigApiCode::ValueTypeMismatch,
131 "JSON Patch body must be a JSON array of operations",
132 )
133 })?;
134
135 let mut parsed = Vec::with_capacity(ops.len());
136 for (idx, op) in ops.iter().enumerate() {
137 let object = op.as_object().ok_or_else(|| {
138 ConfigApiError::new(
139 ConfigApiCode::ValueTypeMismatch,
140 format!("JSON Patch op[{idx}] must be an object"),
141 )
142 .with_op_index(idx)
143 })?;
144 let op_name = object.get("op").and_then(|v| v.as_str()).ok_or_else(|| {
145 ConfigApiError::new(
146 ConfigApiCode::ValueTypeMismatch,
147 format!("JSON Patch op[{idx}] requires string `op` field"),
148 )
149 .with_op_index(idx)
150 })?;
151 let path = object.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
152 ConfigApiError::new(
153 ConfigApiCode::ValueTypeMismatch,
154 format!("JSON Patch op[{idx}] requires string `path` field"),
155 )
156 .with_op_index(idx)
157 })?;
158 let comment = match object.get("comment") {
159 Some(value) => Some(
160 value
161 .as_str()
162 .ok_or_else(|| {
163 ConfigApiError::new(
164 ConfigApiCode::ValueTypeMismatch,
165 format!("JSON Patch op[{idx}] `comment` field must be a string"),
166 )
167 .with_path(json_pointer_to_dotted(path))
168 .with_op_index(idx)
169 })?
170 .to_string(),
171 ),
172 None => None,
173 };
174
175 parsed.push(PatchOp {
176 op: op_name.to_string(),
177 path: path.to_string(),
178 value: object.get("value").cloned(),
179 comment,
180 });
181 }
182
183 Ok(parsed)
184}
185
186#[derive(Debug, Serialize)]
188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
189pub struct PropResponse {
190 pub path: String,
191 pub value: serde_json::Value,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
198}
199
200#[derive(Debug, Serialize)]
204#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
205pub struct SecretResponse {
206 pub path: String,
207 pub populated: bool,
208}
209
210#[derive(Debug, Serialize)]
219#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
220pub struct ListEntry {
221 pub path: String,
222 pub category: String,
223 pub kind: &'static str,
227 pub type_hint: &'static str,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub value: Option<serde_json::Value>,
232 pub populated: bool,
233 pub is_secret: bool,
234 #[serde(default, skip_serializing_if = "is_false")]
239 pub is_env_overridden: bool,
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub enum_variants: Vec<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
249 pub section: Option<&'static str>,
250 #[serde(skip_serializing_if = "str::is_empty")]
255 pub tab: &'static str,
256}
257
258fn prop_kind_wire(kind: zeroclaw_config::traits::PropKind) -> &'static str {
261 use zeroclaw_config::traits::PropKind;
262 match kind {
263 PropKind::String => "string",
264 PropKind::Bool => "bool",
265 PropKind::Integer => "integer",
266 PropKind::Float => "float",
267 PropKind::Enum => "enum",
268 PropKind::StringArray => "string-array",
269 PropKind::ObjectArray => "object-array",
270 PropKind::Object => "object",
271 }
272}
273
274#[derive(Debug, Serialize)]
275#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
276pub struct ListResponse {
277 pub entries: Vec<ListEntry>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub drifted: Vec<DriftEntry>,
283}
284
285#[derive(Debug, Serialize)]
292#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
293pub struct DriftEntry {
294 pub path: String,
295 #[serde(default, skip_serializing_if = "is_false")]
297 pub secret: bool,
298 pub drifted: bool,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub in_memory_value: Option<serde_json::Value>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub on_disk_value: Option<serde_json::Value>,
307}
308
309fn is_false(b: &bool) -> bool {
310 !*b
311}
312
313fn error_response(err: ConfigApiError) -> Response {
317 let status =
318 StatusCode::from_u16(err.code.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
319 (status, axum::Json(err)).into_response()
320}
321
322fn map_prop_error(err: anyhow::Error, path: &str) -> ConfigApiError {
326 let msg = err.to_string();
327 if msg.starts_with("Unknown property") {
328 ConfigApiError::path_not_found(path)
329 } else {
330 ConfigApiError::from_validation(err).with_path(path)
331 }
332}
333
334use zeroclaw_config::typed_value::coerce_for_set_prop as json_to_setprop_string;
341
342fn lookup_prop_field(
345 config: &zeroclaw_config::schema::Config,
346 path: &str,
347) -> Option<zeroclaw_config::traits::PropFieldInfo> {
348 config
349 .prop_fields()
350 .into_iter()
351 .find(|info| info.name == path)
352 .or_else(|| {
353 zeroclaw_config::schema::Config::prop_is_secret(path).then(|| {
354 zeroclaw_config::traits::PropFieldInfo {
355 name: path.to_string(),
356 category: "Secrets",
357 display_value: zeroclaw_config::traits::UNSET_DISPLAY.to_string(),
358 type_hint: "String",
359 kind: zeroclaw_config::traits::PropKind::String,
360 is_secret: true,
361 enum_variants: None,
362 description: "",
363 derived_from_secret: false,
364 credential_class: Some(
365 zeroclaw_config::traits::CredentialSurfaceClass::EncryptedSecret,
366 ),
367 tab: zeroclaw_config::traits::ConfigTab::None,
368 }
369 })
370 })
371}
372
373fn scoped_validate(
389 working: &zeroclaw_config::schema::Config,
390) -> Result<Vec<zeroclaw_config::validation_warnings::ValidationWarning>, ConfigApiError> {
391 if let Err(e) = working.validate() {
392 let api_err = ConfigApiError::from_validation(e);
393 let err_path = api_err.path.as_deref().unwrap_or("");
394 let touches_dirty = !err_path.is_empty()
395 && working.dirty_paths.iter().any(|d| {
396 err_path == d.as_str()
397 || err_path.starts_with(&format!("{d}."))
398 || d.starts_with(&format!("{err_path}."))
399 });
400 if touches_dirty || err_path.is_empty() {
401 return Err(api_err);
402 }
403 ::zeroclaw_log::record!(
404 WARN,
405 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
406 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
407 .with_attrs(::serde_json::json!({"path": err_path})),
408 &format!(
409 "validate() failed on a path outside this PATCH's dirty set; saving anyway and \
410 surfacing as a warning: {}",
411 api_err.message
412 )
413 );
414 return Ok(vec![
415 zeroclaw_config::validation_warnings::ValidationWarning::new(
416 "pre_existing_validation_error",
417 api_err.message,
418 err_path.to_string(),
419 ),
420 ]);
421 }
422 Ok(Vec::new())
423}
424
425async fn persist_and_swap(
426 state: &AppState,
427 mut new_config: zeroclaw_config::schema::Config,
428) -> Result<(), ConfigApiError> {
429 let config_path = new_config.config_path.clone();
430
431 let snapshot = if config_path.exists() {
435 tokio::fs::read(&config_path).await.ok()
437 } else {
438 None
439 };
440
441 if let Err(e) = new_config.save_dirty().await {
442 if let Some(prev) = snapshot {
443 let _ = tokio::fs::write(&config_path, prev).await;
444 } else if config_path.exists() {
445 let _ = tokio::fs::remove_file(&config_path).await;
446 }
447 return Err(ConfigApiError::new(
448 ConfigApiCode::ReloadFailed,
449 format!("save failed: {e}"),
450 ));
451 }
452
453 *state.config.write() = new_config;
454 state
455 .pending_reload
456 .store(true, std::sync::atomic::Ordering::Relaxed);
457 Ok(())
458}
459
460fn is_gateway_managed_field(name: &str) -> bool {
465 matches!(name, "gateway.paired_tokens")
468}
469
470pub async fn compute_drift(in_memory: &zeroclaw_config::schema::Config) -> Vec<DriftEntry> {
483 let path = &in_memory.config_path;
484 if !path.exists() {
485 return Vec::new();
486 }
487
488 let raw = match tokio::fs::read_to_string(path).await {
489 Ok(s) => s,
490 Err(_) => return Vec::new(),
491 };
492
493 let on_disk: zeroclaw_config::schema::Config =
495 match toml::from_str::<zeroclaw_config::schema::Config>(&raw) {
496 Ok(mut cfg) => {
497 cfg.config_path = path.clone();
498 cfg
499 }
500 Err(_) => return Vec::new(),
501 };
502
503 let in_memory_props: std::collections::HashMap<String, zeroclaw_config::traits::PropFieldInfo> =
504 in_memory
505 .prop_fields()
506 .into_iter()
507 .map(|p| (p.name.clone(), p))
508 .collect();
509 let on_disk_props: std::collections::HashMap<String, zeroclaw_config::traits::PropFieldInfo> =
510 on_disk
511 .prop_fields()
512 .into_iter()
513 .map(|p| (p.name.clone(), p))
514 .collect();
515
516 let mut drift: Vec<DriftEntry> = Vec::new();
517 let mut all_names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
518 all_names.extend(in_memory_props.keys().map(String::as_str));
519 all_names.extend(on_disk_props.keys().map(String::as_str));
520 for name in all_names {
521 if is_gateway_managed_field(name) {
527 continue;
528 }
529 let mem = in_memory_props.get(name);
530 let disk = on_disk_props.get(name);
531 let mem_display = mem
532 .map(|p| p.display_value.as_str())
533 .unwrap_or(zeroclaw_config::traits::UNSET_DISPLAY);
534 let disk_display = disk
535 .map(|p| p.display_value.as_str())
536 .unwrap_or(zeroclaw_config::traits::UNSET_DISPLAY);
537 if mem_display == disk_display {
538 continue;
539 }
540 let is_sensitive = mem
541 .or(disk)
542 .map(|p| p.is_secret || p.derived_from_secret)
543 .unwrap_or(false);
544 if is_sensitive {
545 use sha2::{Digest, Sha256};
546 let mem_hash = Sha256::digest(mem_display.as_bytes());
547 let disk_hash = Sha256::digest(disk_display.as_bytes());
548 if mem_hash == disk_hash {
549 continue;
550 }
551 drift.push(DriftEntry {
552 path: name.to_string(),
553 secret: true,
554 drifted: true,
555 in_memory_value: None,
556 on_disk_value: None,
557 });
558 } else {
559 drift.push(DriftEntry {
560 path: name.to_string(),
561 secret: false,
562 drifted: true,
563 in_memory_value: Some(serde_json::Value::String(mem_display.to_string())),
564 on_disk_value: Some(serde_json::Value::String(disk_display.to_string())),
565 });
566 }
567 }
568
569 drift.sort_by(|a, b| a.path.cmp(&b.path));
571 drift
572}
573
574pub async fn handle_prop_get(
582 State(state): State<AppState>,
583 headers: HeaderMap,
584 Query(q): Query<PropQuery>,
585) -> Response {
586 if let Err(e) = require_auth(&state, &headers) {
587 return e.into_response();
588 }
589
590 let config = state.config.read().clone();
591 let info = match lookup_prop_field(&config, &q.path) {
592 Some(info) => info,
593 None => return error_response(ConfigApiError::path_not_found(&q.path)),
594 };
595
596 if info.is_secret || info.derived_from_secret {
597 let populated = info.display_value != zeroclaw_config::traits::UNSET_DISPLAY;
598 return axum::Json(SecretResponse {
599 path: q.path,
600 populated,
601 })
602 .into_response();
603 }
604
605 match config.get_prop(&q.path) {
606 Ok(value_str) => {
607 let warnings = config.collect_warnings();
612 axum::Json(PropResponse {
613 path: q.path,
614 value: serde_json::Value::String(value_str),
615 warnings,
616 })
617 .into_response()
618 }
619 Err(e) => error_response(map_prop_error(e, &q.path)),
620 }
621}
622
623pub async fn handle_prop_put(
629 State(state): State<AppState>,
630 headers: HeaderMap,
631 axum::Json(body): axum::Json<PropPutBody>,
632) -> Response {
633 if let Err(e) = require_auth(&state, &headers) {
634 return e.into_response();
635 }
636
637 let mut new_config = state.config.read().clone();
638 new_config.ensure_map_key_for_path(&body.path);
639 let info = match lookup_prop_field(&new_config, &body.path) {
640 Some(info) => info,
641 None => return error_response(ConfigApiError::path_not_found(&body.path)),
642 };
643
644 let value_str = match json_to_setprop_string(&body.value, Some(info.kind)) {
645 Ok(s) => s,
646 Err(e) => return error_response(e.with_path(&body.path)),
647 };
648
649 let is_sensitive = info.is_secret || info.derived_from_secret;
654 if is_sensitive
655 && (value_str == zeroclaw_config::traits::MASKED_SECRET
656 || value_str == "****"
657 || value_str.is_empty())
658 {
659 return error_response(
660 ConfigApiError::new(
661 ConfigApiCode::ValidationFailed,
662 format!(
663 "Refusing to overwrite secret `{}` with a masked or empty value",
664 body.path
665 ),
666 )
667 .with_path(&body.path),
668 );
669 }
670
671 if let Err(e) = new_config.set_prop_persistent(&body.path, &value_str) {
672 return error_response(map_prop_error(e, &body.path));
673 }
674
675 let scoped_validation_warnings = match scoped_validate(&new_config) {
676 Ok(ws) => ws,
677 Err(err) => return error_response(err),
678 };
679
680 let config_path = new_config.config_path.clone();
681 let mut warnings = new_config.collect_warnings();
682 warnings.extend(scoped_validation_warnings);
683 if let Err(e) = persist_and_swap(&state, new_config).await {
684 return error_response(e);
685 }
686 if let Some(comment) = body.comment.as_ref() {
687 let annotations = [(body.path.clone(), comment.clone())];
688 if let Err(e) =
689 zeroclaw_config::comment_writer::apply_comments(&config_path, &annotations).await
690 {
691 ::zeroclaw_log::record!(
692 WARN,
693 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
694 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
695 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
696 "failed to apply PUT comment to config.toml"
697 );
698 }
699 }
700
701 if info.is_secret || info.derived_from_secret {
702 axum::Json(SecretResponse {
703 path: body.path,
704 populated: !value_str.is_empty(),
705 })
706 .into_response()
707 } else {
708 axum::Json(PropResponse {
709 path: body.path,
710 value: serde_json::Value::String(value_str),
711 warnings,
712 })
713 .into_response()
714 }
715}
716
717pub async fn handle_prop_delete(
726 State(state): State<AppState>,
727 headers: HeaderMap,
728 Query(q): Query<PropQuery>,
729) -> Response {
730 if let Err(e) = require_auth(&state, &headers) {
731 return e.into_response();
732 }
733
734 let mut new_config = state.config.read().clone();
735 let info = match lookup_prop_field(&new_config, &q.path) {
736 Some(info) => info,
737 None => return error_response(ConfigApiError::path_not_found(&q.path)),
738 };
739
740 if let Err(e) = new_config.set_prop_persistent(&q.path, "") {
741 return error_response(map_prop_error(e, &q.path));
742 }
743
744 let scoped_validation_warnings = match scoped_validate(&new_config) {
745 Ok(ws) => ws,
746 Err(err) => return error_response(err),
747 };
748
749 let mut warnings = new_config.collect_warnings();
750 warnings.extend(scoped_validation_warnings);
751 if let Err(e) = persist_and_swap(&state, new_config).await {
752 return error_response(e);
753 }
754
755 if info.is_secret || info.derived_from_secret {
756 axum::Json(SecretResponse {
757 path: q.path,
758 populated: false,
759 })
760 .into_response()
761 } else {
762 axum::Json(PropResponse {
763 path: q.path,
764 value: serde_json::Value::Null,
765 warnings,
766 })
767 .into_response()
768 }
769}
770
771pub async fn handle_list(
777 State(state): State<AppState>,
778 headers: HeaderMap,
779 Query(q): Query<ListQuery>,
780) -> Response {
781 if let Err(e) = require_auth(&state, &headers) {
782 return e.into_response();
783 }
784
785 let config = state.config.read().clone();
786 let prefix = q.prefix.as_deref();
787
788 let excluded = field_visibility::excluded_paths(&config, prefix.unwrap_or(""));
792
793 let entries: Vec<ListEntry> = config
794 .prop_fields()
795 .into_iter()
796 .filter(|info| match prefix {
797 Some(p) => field_visibility::path_matches_prefix(&info.name, p),
798 None => true,
799 })
800 .filter(|info| !field_visibility::is_excluded(&info.name, &excluded))
801 .map(|info| {
802 let populated = info.display_value != zeroclaw_config::traits::UNSET_DISPLAY;
803 let is_sensitive = info.is_secret || info.derived_from_secret;
804 let value = if is_sensitive {
805 None
806 } else {
807 Some(serde_json::Value::String(info.display_value.clone()))
808 };
809 let section = section_for_path(&info.name).map(|s| s.as_str());
810 let enum_variants = info.enum_variants.map(|f| f()).unwrap_or_default();
811 let is_env_overridden = config.prop_is_env_overridden(&info.name);
812 ListEntry {
813 path: info.name,
814 category: info.category.to_string(),
815 kind: prop_kind_wire(info.kind),
816 type_hint: info.type_hint,
817 value,
818 populated,
819 is_secret: is_sensitive,
820 is_env_overridden,
821 enum_variants,
822 section,
823 tab: info.tab.label(),
824 }
825 })
826 .collect();
827
828 let drifted = compute_drift(&config).await;
829 axum::Json(ListResponse { entries, drifted }).into_response()
830}
831
832#[derive(Debug, Serialize)]
833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
834pub struct DriftResponse {
835 pub drifted: Vec<DriftEntry>,
836}
837
838pub async fn handle_drift(State(state): State<AppState>, headers: HeaderMap) -> Response {
841 if let Err(e) = require_auth(&state, &headers) {
842 return e.into_response();
843 }
844 let config = state.config.read().clone();
845 let drifted = compute_drift(&config).await;
846 axum::Json(DriftResponse { drifted }).into_response()
847}
848
849#[derive(Debug, Serialize)]
850#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
851pub struct ReloadStatusResponse {
852 pub pending_reload: bool,
858}
859
860pub async fn handle_reload_status(State(state): State<AppState>, headers: HeaderMap) -> Response {
863 if let Err(e) = require_auth(&state, &headers) {
864 return e.into_response();
865 }
866 let pending_reload = state
867 .pending_reload
868 .load(std::sync::atomic::Ordering::Relaxed);
869 axum::Json(ReloadStatusResponse { pending_reload }).into_response()
870}
871
872#[derive(Debug, Deserialize)]
873#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
874pub struct MapKeyQuery {
875 pub path: String,
877 pub key: String,
879}
880
881#[derive(Debug, Serialize)]
882#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
883pub struct MapKeyResponse {
884 pub path: String,
885 pub key: String,
886 pub created: bool,
887}
888
889#[derive(Debug, Serialize)]
890#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
891pub struct TemplatesResponse {
892 pub templates: Vec<TemplateEntry>,
893}
894
895#[derive(Debug, Serialize)]
896#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
897pub struct TemplateEntry {
898 pub path: &'static str,
899 pub kind: &'static str,
901 pub value_type: &'static str,
903 pub description: &'static str,
905}
906
907pub async fn handle_templates(State(state): State<AppState>, headers: HeaderMap) -> Response {
914 if let Err(e) = require_auth(&state, &headers) {
915 return e.into_response();
916 }
917 let _ = state; let templates: Vec<TemplateEntry> = zeroclaw_config::schema::Config::map_key_sections()
920 .into_iter()
921 .map(|s| TemplateEntry {
922 path: s.path,
923 kind: match s.kind {
924 zeroclaw_config::traits::MapKeyKind::Map => "map",
925 zeroclaw_config::traits::MapKeyKind::List => "list",
926 },
927 value_type: s.value_type,
928 description: s.description,
929 })
930 .collect();
931
932 axum::Json(TemplatesResponse { templates }).into_response()
933}
934
935#[derive(Debug, Deserialize)]
936#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
937pub struct MapPathQuery {
938 pub path: String,
939}
940
941pub async fn handle_get_map_keys(
944 State(state): State<AppState>,
945 headers: HeaderMap,
946 Query(q): Query<MapPathQuery>,
947) -> Response {
948 if let Err(e) = require_auth(&state, &headers) {
949 return e.into_response();
950 }
951 let cfg = state.config.read().clone();
952 match cfg.get_map_keys(&q.path) {
953 Some(keys) => {
954 axum::Json(serde_json::json!({ "path": q.path, "keys": keys })).into_response()
955 }
956 None => error_response(
957 ConfigApiError::new(
958 ConfigApiCode::PathNotFound,
959 format!("no map-keyed section at `{}`", q.path),
960 )
961 .with_path(&q.path),
962 ),
963 }
964}
965
966pub async fn handle_delete_map_key(
969 State(state): State<AppState>,
970 headers: HeaderMap,
971 Query(q): Query<MapKeyQuery>,
972) -> Response {
973 if let Err(e) = require_auth(&state, &headers) {
974 return e.into_response();
975 }
976 let mut working = state.config.read().clone();
977 let removed = match working.delete_map_key(&q.path, &q.key) {
978 Ok(b) => b,
979 Err(msg) => {
980 return error_response(
981 ConfigApiError::new(ConfigApiCode::PathNotFound, msg).with_path(&q.path),
982 );
983 }
984 };
985 if removed {
986 if q.path == "agents" {
993 let workspace = working.agent_workspace_dir(&q.key);
994 if workspace.exists()
995 && let Some(parent) = workspace.parent()
996 {
997 let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
998 let archive_root = parent.join("_deleted");
999 let archive_dir = archive_root.join(format!("{}-{ts}", q.key));
1000 if let Err(err) = tokio::fs::create_dir_all(&archive_root).await {
1001 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": q.key, "archive": archive_root.display().to_string(), "err": err.to_string()})), "agent alias removed from config but archive dir creation failed");
1002 } else if let Err(err) = tokio::fs::rename(&workspace, &archive_dir).await {
1003 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": q.key, "from": workspace.display().to_string(), "to": archive_dir.display().to_string(), "err": err.to_string()})), "agent alias removed from config but workspace archive failed");
1004 } else {
1005 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"agent": q.key, "archive": archive_dir.display().to_string()})), "agent workspace archived after alias removal");
1006 }
1007 }
1008 match state.mem.purge_agent(&q.key).await {
1009 Ok(rows) if rows > 0 => ::zeroclaw_log::record!(
1010 INFO,
1011 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1012 .with_attrs(::serde_json::json!({"agent": q.key, "rows": rows})),
1013 "agent memory rows purged after alias removal"
1014 ),
1015 Ok(_) => {}
1016 Err(err) => ::zeroclaw_log::record!(
1017 WARN,
1018 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1019 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1020 .with_attrs(::serde_json::json!({"agent": q.key, "err": err.to_string()})),
1021 "purge_agent failed (backend may not support it)"
1022 ),
1023 }
1024 }
1025 working.mark_dirty(&format!("{}.{}", q.path, q.key));
1026 if let Err(e) = persist_and_swap(&state, working).await {
1027 return error_response(e);
1028 }
1029 }
1030 axum::Json(MapKeyResponse {
1031 path: q.path,
1032 key: q.key,
1033 created: false,
1034 })
1035 .into_response()
1036}
1037
1038pub async fn handle_map_key(
1049 State(state): State<AppState>,
1050 headers: HeaderMap,
1051 Query(q): Query<MapKeyQuery>,
1052) -> Response {
1053 if let Err(e) = require_auth(&state, &headers) {
1054 return e.into_response();
1055 }
1056
1057 let mut working = state.config.read().clone();
1058 let path = q.path.clone();
1059 let key = q.key.clone();
1060
1061 let created = match working.create_map_key(&path, &key) {
1062 Ok(b) => b,
1063 Err(msg) => {
1064 return error_response(
1065 ConfigApiError::new(ConfigApiCode::PathNotFound, msg).with_path(&path),
1066 );
1067 }
1068 };
1069
1070 if created {
1071 if path == "skill_bundles" {
1075 let install_root = working.install_root_dir();
1076 if let Ok(dir) =
1077 zeroclaw_config::skill_bundles::resolve_directory(&working, &install_root, &key)
1078 && let Err(e) = tokio::fs::create_dir_all(&dir).await
1079 {
1080 ::zeroclaw_log::record!(
1081 WARN,
1082 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1083 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1084 &format!(
1085 "skill-bundle '{key}' directory creation failed at {}: {e}",
1086 dir.display().to_string()
1087 )
1088 );
1089 }
1090 }
1091
1092 working.mark_dirty(&format!("{path}.{key}"));
1093 if let Err(e) = persist_and_swap(&state, working).await {
1094 return error_response(e);
1095 }
1096 }
1097
1098 axum::Json(MapKeyResponse { path, key, created }).into_response()
1099}
1100
1101#[derive(Debug, Deserialize)]
1102#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1103pub struct RenameMapKeyBody {
1104 pub path: String,
1106 pub from: String,
1108 pub to: String,
1110}
1111
1112#[derive(Debug, Serialize)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114pub struct RenameMapKeyResponse {
1115 pub path: String,
1116 pub from: String,
1117 pub to: String,
1118 pub renamed: bool,
1119}
1120
1121pub async fn handle_rename_map_key(
1124 State(state): State<AppState>,
1125 headers: HeaderMap,
1126 axum::Json(body): axum::Json<RenameMapKeyBody>,
1127) -> Response {
1128 if let Err(e) = require_auth(&state, &headers) {
1129 return e.into_response();
1130 }
1131
1132 let mut working = state.config.read().clone();
1133
1134 let renamed = match working.rename_map_key(&body.path, &body.from, &body.to) {
1135 Ok(b) => b,
1136 Err(msg) => {
1137 return error_response(
1138 ConfigApiError::new(ConfigApiCode::ValidationFailed, msg).with_path(&body.path),
1139 );
1140 }
1141 };
1142
1143 if renamed {
1144 working.mark_dirty(&format!("{}.{}", body.path, body.from));
1145 working.mark_dirty(&format!("{}.{}", body.path, body.to));
1146 if let Err(e) = persist_and_swap(&state, working).await {
1147 return error_response(e);
1148 }
1149 }
1150
1151 axum::Json(RenameMapKeyResponse {
1152 path: body.path,
1153 from: body.from,
1154 to: body.to,
1155 renamed,
1156 })
1157 .into_response()
1158}
1159
1160pub async fn handle_patch(
1173 State(state): State<AppState>,
1174 headers: HeaderMap,
1175 axum::Json(body): axum::Json<serde_json::Value>,
1176) -> Response {
1177 if let Err(e) = require_auth(&state, &headers) {
1178 return e.into_response();
1179 }
1180
1181 let ops = match parse_patch_ops(body) {
1182 Ok(ops) => ops,
1183 Err(e) => return error_response(e),
1184 };
1185
1186 let working = state.config.read().clone();
1187
1188 let override_drift = headers
1195 .get("x-zeroclaw-override-drift")
1196 .and_then(|v| v.to_str().ok())
1197 .map(|v| v.eq_ignore_ascii_case("true"))
1198 .unwrap_or(false);
1199 if !override_drift {
1200 let drifted = compute_drift(&working).await;
1201 if !drifted.is_empty() {
1202 let touched: std::collections::HashSet<String> = ops
1203 .iter()
1204 .map(|op| json_pointer_to_dotted(&op.path))
1205 .collect();
1206 let conflicts: Vec<&DriftEntry> = drifted
1207 .iter()
1208 .filter(|d| touched.contains(&d.path))
1209 .collect();
1210 if !conflicts.is_empty() {
1211 let conflict_paths: Vec<String> =
1212 conflicts.iter().map(|d| d.path.clone()).collect();
1213 return error_response(ConfigApiError::new(
1214 ConfigApiCode::ConfigChangedExternally,
1215 format!(
1216 "on-disk config has drifted from in-memory state on \
1217 {} path(s) being patched: {}. Send `X-ZeroClaw-Override-Drift: true` \
1218 to overwrite, or GET /api/config/drift to inspect first.",
1219 conflicts.len(),
1220 conflict_paths.join(", "),
1221 ),
1222 ));
1223 }
1224 }
1225 }
1226
1227 let mut working = working;
1228 let mut results = Vec::with_capacity(ops.len());
1229
1230 for (idx, op) in ops.iter().enumerate() {
1231 let path = json_pointer_to_dotted(&op.path);
1232 if matches!(op.op.as_str(), "add" | "replace") {
1233 working.ensure_map_key_for_path(&path);
1234 }
1235 let info = lookup_prop_field(&working, &path);
1236 let is_sensitive = info
1237 .as_ref()
1238 .map(|i| i.is_secret || i.derived_from_secret)
1239 .unwrap_or(false);
1240
1241 match op.op.as_str() {
1242 "test" => {
1243 if is_sensitive {
1246 return error_response(
1247 ConfigApiError::secret_test_forbidden(&path).with_op_index(idx),
1248 );
1249 }
1250 let want = match op.value.as_ref() {
1251 Some(v) => v.clone(),
1252 None => {
1253 return error_response(
1254 ConfigApiError::new(
1255 ConfigApiCode::ValueTypeMismatch,
1256 "JSON Patch `test` op requires `value` field",
1257 )
1258 .with_path(&path)
1259 .with_op_index(idx),
1260 );
1261 }
1262 };
1263 let actual_str = match working.get_prop(&path) {
1264 Ok(v) => v,
1265 Err(e) => return error_response(map_prop_error(e, &path).with_op_index(idx)),
1266 };
1267 let want_str = match json_to_setprop_string(&want, info.as_ref().map(|i| i.kind)) {
1268 Ok(s) => s,
1269 Err(e) => return error_response(e.with_path(&path).with_op_index(idx)),
1270 };
1271 if actual_str != want_str {
1272 return error_response(
1273 ConfigApiError::new(
1274 ConfigApiCode::ValidationFailed,
1275 format!("`test` op failed: expected {want_str:?}, got {actual_str:?}"),
1276 )
1277 .with_path(&path)
1278 .with_op_index(idx),
1279 );
1280 }
1281 results.push(PatchOpResult {
1282 op: op.op.clone(),
1283 path,
1284 value: Some(serde_json::Value::String(actual_str)),
1285 populated: None,
1286 comment: None, });
1288 }
1289 "add" | "replace" => {
1290 let value = match op.value.as_ref() {
1291 Some(v) => v.clone(),
1292 None => {
1293 return error_response(
1294 ConfigApiError::new(
1295 ConfigApiCode::ValueTypeMismatch,
1296 format!("JSON Patch `{}` op requires `value` field", op.op),
1297 )
1298 .with_path(&path)
1299 .with_op_index(idx),
1300 );
1301 }
1302 };
1303 let value_str = match json_to_setprop_string(&value, info.as_ref().map(|i| i.kind))
1304 {
1305 Ok(s) => s,
1306 Err(e) => {
1307 return error_response(e.with_path(&path).with_op_index(idx));
1308 }
1309 };
1310 if let Err(e) = working.set_prop_persistent(&path, &value_str) {
1311 return error_response(map_prop_error(e, &path).with_op_index(idx));
1312 }
1313 if is_sensitive {
1314 results.push(PatchOpResult {
1315 op: op.op.clone(),
1316 path,
1317 value: None,
1318 populated: Some(!value_str.is_empty()),
1319 comment: op.comment.clone(),
1320 });
1321 } else {
1322 results.push(PatchOpResult {
1323 op: op.op.clone(),
1324 path,
1325 value: Some(serde_json::Value::String(value_str)),
1326 populated: None,
1327 comment: op.comment.clone(),
1328 });
1329 }
1330 }
1331 "remove" => {
1332 if let Err(e) = working.set_prop_persistent(&path, "") {
1333 return error_response(map_prop_error(e, &path).with_op_index(idx));
1334 }
1335 if is_sensitive {
1336 results.push(PatchOpResult {
1337 op: op.op.clone(),
1338 path,
1339 value: None,
1340 populated: Some(false),
1341 comment: op.comment.clone(),
1342 });
1343 } else {
1344 results.push(PatchOpResult {
1345 op: op.op.clone(),
1346 path,
1347 value: Some(serde_json::Value::Null),
1348 populated: None,
1349 comment: op.comment.clone(),
1350 });
1351 }
1352 }
1353 "comment" => {
1354 if info.is_none() {
1359 return error_response(
1360 ConfigApiError::path_not_found(&path).with_op_index(idx),
1361 );
1362 }
1363 let Some(comment) = op.comment.clone() else {
1364 return error_response(
1365 ConfigApiError::new(
1366 ConfigApiCode::ValueTypeMismatch,
1367 "JSON Patch `comment` op requires `comment` field",
1368 )
1369 .with_path(&path)
1370 .with_op_index(idx),
1371 );
1372 };
1373 results.push(PatchOpResult {
1374 op: op.op.clone(),
1375 path,
1376 value: None,
1377 populated: None,
1378 comment: Some(comment),
1379 });
1380 }
1381 "move" | "copy" => {
1382 return error_response(
1383 ConfigApiError::op_not_supported(&op.op)
1384 .with_path(&path)
1385 .with_op_index(idx),
1386 );
1387 }
1388 other => {
1389 return error_response(
1390 ConfigApiError::new(
1391 ConfigApiCode::OpNotSupported,
1392 format!("unknown JSON Patch operation `{other}`"),
1393 )
1394 .with_path(&path)
1395 .with_op_index(idx),
1396 );
1397 }
1398 }
1399 }
1400
1401 let scoped_validation_warnings = match scoped_validate(&working) {
1404 Ok(ws) => ws,
1405 Err(err) => return error_response(err),
1406 };
1407
1408 let annotations: Vec<(String, String)> = ops
1412 .iter()
1413 .zip(results.iter())
1414 .filter_map(|(op, res)| op.comment.as_ref().map(|c| (res.path.clone(), c.clone())))
1415 .collect();
1416
1417 let config_path = working.config_path.clone();
1418 let mut warnings = working.collect_warnings();
1423 warnings.extend(scoped_validation_warnings);
1424 if let Err(e) = persist_and_swap(&state, working).await {
1425 return error_response(e);
1426 }
1427 if !annotations.is_empty()
1428 && let Err(e) =
1429 zeroclaw_config::comment_writer::apply_comments(&config_path, &annotations).await
1430 {
1431 ::zeroclaw_log::record!(
1434 WARN,
1435 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1436 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1437 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1438 "failed to apply PATCH op comments to config.toml"
1439 );
1440 }
1441
1442 axum::Json(PatchResponse {
1443 saved: true,
1444 results,
1445 warnings,
1446 })
1447 .into_response()
1448}
1449
1450fn json_pointer_to_dotted(path: &str) -> String {
1456 if path.starts_with('/') {
1457 path.trim_start_matches('/').replace('/', ".")
1458 } else {
1459 path.to_string()
1460 }
1461}
1462
1463#[derive(Debug, Deserialize, Default)]
1464#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1465pub struct InitQuery {
1466 #[serde(default)]
1469 pub section: Option<String>,
1470}
1471
1472#[derive(Debug, Serialize)]
1473#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1474pub struct InitResponse {
1475 pub initialized: Vec<String>,
1476}
1477
1478pub async fn handle_init(
1482 State(state): State<AppState>,
1483 headers: HeaderMap,
1484 Query(q): Query<InitQuery>,
1485) -> Response {
1486 if let Err(e) = require_auth(&state, &headers) {
1487 return e.into_response();
1488 }
1489
1490 let mut working = state.config.read().clone();
1491 let initialized: Vec<String> = working
1492 .init_defaults(q.section.as_deref())
1493 .into_iter()
1494 .map(str::to_string)
1495 .collect();
1496
1497 if initialized.is_empty() {
1498 return axum::Json(InitResponse { initialized }).into_response();
1499 }
1500
1501 for section in &initialized {
1502 working.mark_dirty(section);
1503 }
1504
1505 if let Err(err) = scoped_validate(&working) {
1506 return error_response(err);
1507 }
1508 if let Err(e) = persist_and_swap(&state, working).await {
1509 return error_response(e);
1510 }
1511
1512 axum::Json(InitResponse { initialized }).into_response()
1513}
1514
1515#[derive(Debug, Serialize)]
1516#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1517pub struct MigrateResponse {
1518 pub migrated: bool,
1519 #[serde(skip_serializing_if = "Option::is_none")]
1522 pub backup_path: Option<String>,
1523 pub schema_version: u32,
1524}
1525
1526pub async fn handle_migrate(State(state): State<AppState>, headers: HeaderMap) -> Response {
1532 if let Err(e) = require_auth(&state, &headers) {
1533 return e.into_response();
1534 }
1535
1536 let config_path = state.config.read().config_path.clone();
1537
1538 let raw = match tokio::fs::read_to_string(&config_path).await {
1539 Ok(s) => s,
1540 Err(e) => {
1541 return error_response(ConfigApiError::new(
1542 ConfigApiCode::InternalError,
1543 format!("failed to read config file: {e}"),
1544 ));
1545 }
1546 };
1547
1548 let migrated = match zeroclaw_config::migration::migrate_file(&raw) {
1549 Ok(out) => out,
1550 Err(e) => {
1551 return error_response(ConfigApiError::new(
1552 ConfigApiCode::ValidationFailed,
1553 format!("migration failed: {e}"),
1554 ));
1555 }
1556 };
1557
1558 match migrated {
1559 Some(new_content) => {
1560 let backup_path = config_path.with_extension("toml.bak");
1566 let parent = match config_path.parent() {
1567 Some(p) => p.to_path_buf(),
1568 None => {
1569 return error_response(ConfigApiError::new(
1570 ConfigApiCode::InternalError,
1571 format!(
1572 "config path has no parent: {}",
1573 config_path.display().to_string()
1574 ),
1575 ));
1576 }
1577 };
1578 let file_name = match config_path.file_name().and_then(|n| n.to_str()) {
1579 Some(n) => n.to_string(),
1580 None => {
1581 return error_response(ConfigApiError::new(
1582 ConfigApiCode::InternalError,
1583 format!(
1584 "config path has no file name: {}",
1585 config_path.display().to_string()
1586 ),
1587 ));
1588 }
1589 };
1590 let temp_path = parent.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
1591
1592 match tokio::fs::OpenOptions::new()
1594 .create_new(true)
1595 .write(true)
1596 .open(&temp_path)
1597 .await
1598 {
1599 Ok(mut temp) => {
1600 use tokio::io::AsyncWriteExt;
1601 if let Err(e) = temp.write_all(new_content.as_bytes()).await {
1602 let _ = tokio::fs::remove_file(&temp_path).await;
1603 return error_response(ConfigApiError::new(
1604 ConfigApiCode::InternalError,
1605 format!("failed to write migrated config to temp: {e}"),
1606 ));
1607 }
1608 if let Err(e) = temp.sync_all().await {
1609 let _ = tokio::fs::remove_file(&temp_path).await;
1610 return error_response(ConfigApiError::new(
1611 ConfigApiCode::InternalError,
1612 format!("failed to fsync migrated config temp: {e}"),
1613 ));
1614 }
1615 }
1616 Err(e) => {
1617 return error_response(ConfigApiError::new(
1618 ConfigApiCode::InternalError,
1619 format!("failed to create temp config file: {e}"),
1620 ));
1621 }
1622 }
1623
1624 if let Err(e) = tokio::fs::copy(&config_path, &backup_path).await {
1626 let _ = tokio::fs::remove_file(&temp_path).await;
1627 return error_response(ConfigApiError::new(
1628 ConfigApiCode::InternalError,
1629 format!("failed to write backup: {e}"),
1630 ));
1631 }
1632
1633 if let Err(e) = tokio::fs::rename(&temp_path, &config_path).await {
1635 let _ = tokio::fs::remove_file(&temp_path).await;
1636 if backup_path.exists() {
1637 let _ = tokio::fs::copy(&backup_path, &config_path).await;
1638 }
1639 return error_response(ConfigApiError::new(
1640 ConfigApiCode::InternalError,
1641 format!("failed to atomically replace config: {e}"),
1642 ));
1643 }
1644
1645 #[cfg(unix)]
1647 if let Ok(dir) = tokio::fs::File::open(&parent).await {
1648 let _ = dir.sync_all().await;
1649 }
1650
1651 let new_cfg: zeroclaw_config::schema::Config = match toml::from_str(&new_content) {
1653 Ok(c) => c,
1654 Err(e) => {
1655 return error_response(ConfigApiError::new(
1656 ConfigApiCode::ReloadFailed,
1657 format!("re-parse after migration failed: {e}"),
1658 ));
1659 }
1660 };
1661 *state.config.write() = new_cfg;
1662
1663 axum::Json(MigrateResponse {
1664 migrated: true,
1665 backup_path: Some(backup_path.display().to_string()),
1666 schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
1667 })
1668 .into_response()
1669 }
1670 None => axum::Json(MigrateResponse {
1671 migrated: false,
1672 backup_path: None,
1673 schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
1674 })
1675 .into_response(),
1676 }
1677}
1678
1679pub async fn handle_options_config(headers: HeaderMap) -> Response {
1687 if headers.contains_key("access-control-request-method") {
1689 let mut response = StatusCode::NO_CONTENT.into_response();
1690 let h = response.headers_mut();
1691 h.insert(
1692 "Access-Control-Allow-Methods",
1693 HeaderValue::from_static("GET, PUT, PATCH, OPTIONS"),
1694 );
1695 h.insert(
1696 "Access-Control-Allow-Headers",
1697 HeaderValue::from_static("Authorization, Content-Type, If-None-Match"),
1698 );
1699 return response;
1700 }
1701
1702 schema_response("zeroclaw_config_schema_full")
1703}
1704
1705pub async fn handle_options_prop(
1717 State(state): State<AppState>,
1718 headers: HeaderMap,
1719 Query(q): Query<PropQuery>,
1720) -> Response {
1721 if headers.contains_key("access-control-request-method") {
1722 let mut response = StatusCode::NO_CONTENT.into_response();
1723 let h = response.headers_mut();
1724 h.insert(
1725 "Access-Control-Allow-Methods",
1726 HeaderValue::from_static("GET, PUT, DELETE, OPTIONS"),
1727 );
1728 h.insert(
1729 "Access-Control-Allow-Headers",
1730 HeaderValue::from_static("Authorization, Content-Type, If-None-Match"),
1731 );
1732 return response;
1733 }
1734
1735 let config = state.config.read().clone();
1738 let info = match lookup_prop_field(&config, &q.path) {
1739 Some(info) => info,
1740 None => return error_response(ConfigApiError::path_not_found(&q.path)),
1741 };
1742
1743 let (whole_body, etag) = cached_schema();
1744 let mut body = whole_body.clone();
1745 if let serde_json::Value::Object(ref mut map) = body {
1746 map.insert(
1747 "x-zeroclaw-requested-path".into(),
1748 serde_json::Value::String(q.path.clone()),
1749 );
1750 map.insert(
1751 "x-zeroclaw-prop".into(),
1752 serde_json::json!({
1753 "path": q.path,
1754 "kind": prop_kind_wire(info.kind),
1755 "type_hint": info.type_hint,
1756 "is_secret": info.is_secret || info.derived_from_secret,
1757 "enum_variants": info.enum_variants.map(|f| f()).unwrap_or_default(),
1758 "category": info.category,
1759 }),
1760 );
1761 }
1762 let mut response = (StatusCode::OK, axum::Json(body)).into_response();
1763 response.headers_mut().insert(
1764 header::ALLOW,
1765 HeaderValue::from_static("GET, PUT, DELETE, OPTIONS"),
1766 );
1767 response
1768 .headers_mut()
1769 .insert(header::ETAG, HeaderValue::from_str(etag).unwrap());
1770 response
1771}
1772
1773fn schema_response(_label: &'static str) -> Response {
1774 let (body, etag) = cached_schema();
1775 let mut response = (StatusCode::OK, axum::Json(body.clone())).into_response();
1776 response.headers_mut().insert(
1777 header::ALLOW,
1778 HeaderValue::from_static("GET, PUT, PATCH, OPTIONS"),
1779 );
1780 response
1781 .headers_mut()
1782 .insert(header::ETAG, HeaderValue::from_str(etag).unwrap());
1783 response
1784}
1785
1786fn cached_schema() -> (&'static serde_json::Value, &'static str) {
1793 use std::sync::OnceLock;
1794 static CACHE: OnceLock<(serde_json::Value, String)> = OnceLock::new();
1795 let entry = CACHE.get_or_init(|| {
1796 let body = schema_body_value();
1797 let etag = build_etag_for(&body);
1798 (body, etag)
1799 });
1800 (&entry.0, entry.1.as_str())
1801}
1802
1803#[cfg(feature = "schema-export")]
1804fn schema_body_value() -> serde_json::Value {
1805 let schema = schemars::schema_for!(zeroclaw_config::schema::Config);
1806 serde_json::to_value(schema).unwrap_or(serde_json::Value::Null)
1807}
1808
1809#[cfg(not(feature = "schema-export"))]
1810fn schema_body_value() -> serde_json::Value {
1811 serde_json::json!({
1812 "error": "schema-export feature not enabled in this build",
1813 })
1814}
1815
1816fn build_etag_for(body: &serde_json::Value) -> String {
1820 use std::hash::{Hash, Hasher};
1821 let bytes = body.to_string();
1822 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1823 bytes.hash(&mut hasher);
1824 format!("\"{:016x}\"", hasher.finish())
1825}
1826
1827#[cfg(test)]
1828mod tests {
1829 use super::*;
1830
1831 #[test]
1838 fn map_prop_error_classifies_unknown_property() {
1839 let err = anyhow::Error::msg("Unknown property 'foo.bar'");
1840 let api_err = map_prop_error(err, "foo.bar");
1841 assert_eq!(api_err.code, ConfigApiCode::PathNotFound);
1842 }
1843
1844 #[test]
1845 fn map_prop_error_classifies_type_mismatch() {
1846 let err = anyhow::Error::msg("type mismatch: expected u64");
1849 let api_err = map_prop_error(err, "scheduler.max_concurrent");
1850 assert_eq!(api_err.code, ConfigApiCode::ValueTypeMismatch);
1851 }
1852
1853 #[test]
1854 fn map_prop_error_falls_back_to_validation_on_unknown_message() {
1855 let err = anyhow::Error::msg("some completely unrecognized validator message");
1856 let api_err = map_prop_error(err, "scheduler.max_concurrent");
1857 assert_eq!(api_err.code, ConfigApiCode::ValidationFailed);
1858 }
1859
1860 #[test]
1861 fn json_pointer_to_dotted_handles_pointer_form() {
1862 assert_eq!(
1863 json_pointer_to_dotted("/providers/models/openrouter/api-key"),
1864 "providers.models.openrouter.api-key"
1865 );
1866 }
1867
1868 #[test]
1869 fn json_pointer_to_dotted_passes_dotted_through() {
1870 assert_eq!(
1871 json_pointer_to_dotted("providers.models.openrouter.api-key"),
1872 "providers.models.openrouter.api-key"
1873 );
1874 assert_eq!(
1875 json_pointer_to_dotted("scheduler.max_concurrent"),
1876 "scheduler.max_concurrent"
1877 );
1878 }
1879
1880 #[test]
1881 fn json_pointer_to_dotted_handles_empty_root() {
1882 assert_eq!(json_pointer_to_dotted(""), "");
1883 assert_eq!(json_pointer_to_dotted("/"), "");
1884 }
1885
1886 use zeroclaw_config::traits::PropKind;
1901
1902 #[test]
1903 fn test_op_coercion_bool_typed_value_matches_stored() {
1904 let mut cfg = zeroclaw_config::schema::Config::default();
1905 cfg.risk_profiles.insert(
1906 "default".into(),
1907 zeroclaw_config::schema::RiskProfileConfig::default(),
1908 );
1909 cfg.set_prop("risk_profiles.default.workspace_only", "true")
1910 .expect("set_prop bool");
1911 let actual = cfg
1912 .get_prop("risk_profiles.default.workspace_only")
1913 .expect("get_prop");
1914 let want_typed = json_to_setprop_string(&serde_json::json!(true), Some(PropKind::Bool))
1915 .expect("coerce bool true");
1916 assert_eq!(
1917 actual, want_typed,
1918 "bool field: typed JSON `true` must coerce to the same display string \
1919 as `get_prop` returns; got actual={actual:?} want_typed={want_typed:?}"
1920 );
1921
1922 let want_string = json_to_setprop_string(&serde_json::json!("true"), Some(PropKind::Bool))
1926 .expect("coerce bool from string");
1927 assert_eq!(actual, want_string);
1928 }
1929
1930 #[test]
1931 fn test_op_coercion_integer_typed_value_matches_stored() {
1932 let mut cfg = zeroclaw_config::schema::Config::default();
1933 cfg.set_prop("gateway.port", "42617")
1934 .expect("set_prop integer");
1935 let actual = cfg.get_prop("gateway.port").expect("get_prop");
1936 let want_typed = json_to_setprop_string(&serde_json::json!(42617), Some(PropKind::Integer))
1937 .expect("coerce integer");
1938 assert_eq!(
1939 actual, want_typed,
1940 "integer field coercion: actual={actual:?} want_typed={want_typed:?}"
1941 );
1942
1943 let want_string =
1945 json_to_setprop_string(&serde_json::json!("42617"), Some(PropKind::Integer))
1946 .expect("coerce integer from string");
1947 assert_eq!(actual, want_string);
1948 }
1949
1950 #[test]
1951 fn test_op_coercion_float_typed_value_matches_stored() {
1952 let mut cfg = zeroclaw_config::schema::Config::default();
1958 match cfg.set_prop("providers.models.openai.temperature", "0.7") {
1963 Ok(()) => {
1964 let actual = cfg
1965 .get_prop("providers.models.openai.temperature")
1966 .expect("get_prop float");
1967 let want_typed =
1968 json_to_setprop_string(&serde_json::json!(0.7), Some(PropKind::Float))
1969 .expect("coerce float typed");
1970 assert_eq!(
1971 actual, want_typed,
1972 "float field coercion: actual={actual:?} want_typed={want_typed:?}"
1973 );
1974 }
1975 Err(_) => {
1976 }
1980 }
1981 }
1982
1983 #[test]
1984 fn test_op_coercion_string_field_no_regression() {
1985 let mut cfg = zeroclaw_config::schema::Config::default();
1986 cfg.set_prop("gateway.host", "10.0.0.1")
1987 .expect("set_prop string");
1988 let actual = cfg.get_prop("gateway.host").expect("get_prop string");
1989 let want_typed =
1990 json_to_setprop_string(&serde_json::json!("10.0.0.1"), Some(PropKind::String))
1991 .expect("coerce string");
1992 assert_eq!(actual, want_typed);
1993 }
1994
1995 #[test]
1996 fn test_op_coercion_mismatched_value_correctly_fails() {
1997 let mut cfg = zeroclaw_config::schema::Config::default();
1998 cfg.risk_profiles.insert(
1999 "default".into(),
2000 zeroclaw_config::schema::RiskProfileConfig::default(),
2001 );
2002 cfg.set_prop("risk_profiles.default.workspace_only", "true")
2003 .expect("set_prop");
2004 let actual = cfg
2005 .get_prop("risk_profiles.default.workspace_only")
2006 .expect("get_prop");
2007 let want = json_to_setprop_string(&serde_json::json!(false), Some(PropKind::Bool))
2008 .expect("coerce bool false");
2009 assert_ne!(
2010 actual, want,
2011 "bool true must not match bool false after coercion — \
2012 a mismatched test op should fail with ValidationFailed"
2013 );
2014 }
2015
2016 use std::path::PathBuf;
2019
2020 fn temp_config_path() -> (tempfile::TempDir, PathBuf) {
2021 let tmp = tempfile::tempdir().expect("tempdir");
2022 let path = tmp.path().join("config.toml");
2023 (tmp, path)
2024 }
2025
2026 #[tokio::test]
2027 async fn compute_drift_returns_empty_when_in_memory_matches_disk() {
2028 let (_tmp, path) = temp_config_path();
2029 let cfg = zeroclaw_config::schema::Config {
2030 config_path: path.clone(),
2031 ..Default::default()
2032 };
2033 cfg.save().await.expect("save");
2035
2036 let drift = compute_drift(&cfg).await;
2037 assert!(
2038 drift.is_empty(),
2039 "expected no drift right after save, got {drift:?}"
2040 );
2041 }
2042
2043 #[tokio::test]
2044 async fn compute_drift_surfaces_mismatched_non_secret_field() {
2045 let (_tmp, path) = temp_config_path();
2046 let mut cfg = zeroclaw_config::schema::Config {
2047 config_path: path.clone(),
2048 ..Default::default()
2049 };
2050 cfg.save().await.expect("initial save");
2051
2052 cfg.set_prop("gateway.host", "10.0.0.1").expect("set_prop");
2054
2055 let drift = compute_drift(&cfg).await;
2056 let entry = drift
2057 .iter()
2058 .find(|d| d.path == "gateway.host")
2059 .expect("expected gateway.host in drift summary");
2060 assert!(!entry.secret);
2061 assert!(entry.drifted);
2062 assert!(entry.in_memory_value.is_some());
2063 assert!(entry.on_disk_value.is_some());
2064 }
2065
2066 #[tokio::test]
2067 async fn compute_drift_returns_empty_when_no_disk_file() {
2068 let (_tmp, path) = temp_config_path();
2069 let cfg = zeroclaw_config::schema::Config {
2070 config_path: path.clone(),
2071 ..Default::default()
2072 };
2073 let drift = compute_drift(&cfg).await;
2075 assert!(drift.is_empty());
2076 }
2077
2078 #[tokio::test]
2079 async fn apply_comments_writes_decoration_to_existing_value() {
2080 let (_tmp, path) = temp_config_path();
2081 let mut cfg = zeroclaw_config::schema::Config {
2082 config_path: path.clone(),
2083 ..Default::default()
2084 };
2085 cfg.set_prop("gateway.host", "10.0.0.5").expect("set_prop");
2086 cfg.save().await.expect("save");
2087
2088 zeroclaw_config::comment_writer::apply_comments(
2089 &path,
2090 &[("gateway.host".into(), "raised after Q3 backlog".into())],
2091 )
2092 .await
2093 .expect("apply_comments");
2094
2095 let raw = tokio::fs::read_to_string(&path).await.expect("read back");
2096 assert!(
2098 raw.contains("# raised after Q3 backlog"),
2099 "expected comment in file, got:\n{raw}"
2100 );
2101
2102 let lines: Vec<&str> = raw.lines().collect();
2107 let host_line_idx = lines
2108 .iter()
2109 .position(|l| l.trim_start().starts_with("host"))
2110 .expect("host = line in saved config");
2111 assert!(
2112 host_line_idx > 0,
2113 "host line is at top — comment can't precede it"
2114 );
2115 let above = lines[host_line_idx - 1];
2116 assert_eq!(
2117 above.trim(),
2118 "# raised after Q3 backlog",
2119 "expected comment immediately above `host = ...`, got line above:\n {above:?}\nfull file:\n{raw}"
2120 );
2121
2122 let _: toml::Value = toml::from_str(&raw)
2125 .unwrap_or_else(|e| panic!("re-parse failed after apply_comments: {e}\nfile:\n{raw}"));
2126 }
2127
2128 #[test]
2129 fn scrub_credentials_catches_credential_shaped_strings() {
2130 use zeroclaw_runtime::agent::loop_::scrub_credentials;
2137
2138 let cases = [
2145 (
2147 "api-key=sk-live-abcdef-1234567890",
2148 "sk-live-abcdef-1234567890",
2149 ),
2150 (
2152 r#""token": "sk-test-supersecret-12345""#,
2153 "sk-test-supersecret-12345",
2154 ),
2155 (
2157 "secret: hunter2-not-a-real-password",
2158 "hunter2-not-a-real-password",
2159 ),
2160 (
2162 "credential: bearer-token-abcdef-9876",
2163 "bearer-token-abcdef-9876",
2164 ),
2165 ];
2166 for (input, raw_secret) in cases {
2167 let scrubbed = scrub_credentials(input);
2168 assert!(
2169 !scrubbed.contains(raw_secret),
2170 "scrubber missed `{raw_secret}` in:\n input : {input}\n scrubbed : {scrubbed}"
2171 );
2172 assert!(
2173 scrubbed.contains("REDACTED"),
2174 "expected REDACTED marker in:\n input : {input}\n scrubbed : {scrubbed}"
2175 );
2176 }
2177 }
2178
2179 #[tokio::test]
2180 async fn compute_drift_detects_external_edit_to_field() {
2181 let (_tmp, path) = temp_config_path();
2184 let mut cfg = zeroclaw_config::schema::Config {
2185 config_path: path.clone(),
2186 ..Default::default()
2187 };
2188 cfg.set_prop("gateway.host", "10.0.0.1").expect("set");
2189 cfg.save().await.expect("save");
2190
2191 let on_disk = tokio::fs::read_to_string(&path).await.unwrap();
2193 let edited = on_disk.replace("10.0.0.1", "192.168.1.1");
2194 tokio::fs::write(&path, edited).await.unwrap();
2195
2196 let drift = compute_drift(&cfg).await;
2198 let entry = drift
2199 .iter()
2200 .find(|d| d.path == "gateway.host")
2201 .expect("expected gateway.host in drift summary after external edit");
2202 assert!(entry.drifted);
2203 assert_eq!(
2204 entry.in_memory_value,
2205 Some(serde_json::Value::String("10.0.0.1".into()))
2206 );
2207 assert_eq!(
2208 entry.on_disk_value,
2209 Some(serde_json::Value::String("192.168.1.1".into()))
2210 );
2211 }
2212
2213 #[test]
2214 fn secret_response_only_carries_path_and_populated_flag() {
2215 let r = SecretResponse {
2219 path: "providers.models.ollama.api-key".into(),
2220 populated: true,
2221 };
2222 let json = serde_json::to_value(&r).expect("serialize");
2223 let obj = json.as_object().expect("object");
2224 let keys: Vec<&str> = obj.keys().map(String::as_str).collect();
2225 assert_eq!(
2226 keys,
2227 vec!["path", "populated"],
2228 "SecretResponse must carry only path + populated"
2229 );
2230 assert!(!obj.contains_key("value"));
2231 assert!(!obj.contains_key("length"));
2232 assert!(!obj.contains_key("hash"));
2233 assert!(!obj.contains_key("masked"));
2234 }
2235
2236 #[test]
2237 fn lookup_prop_field_synthesizes_dynamic_http_request_secret_metadata() {
2238 let cfg = zeroclaw_config::schema::Config::default();
2239 let field = lookup_prop_field(&cfg, "http_request.secrets.api_token")
2240 .expect("dynamic http_request secret metadata");
2241
2242 assert_eq!(field.kind, PropKind::String);
2243 assert!(field.is_secret);
2244 assert_eq!(
2245 field.credential_class,
2246 Some(zeroclaw_config::traits::CredentialSurfaceClass::EncryptedSecret)
2247 );
2248 }
2249
2250 #[test]
2251 fn list_entry_for_secret_omits_value_field() {
2252 let entry = ListEntry {
2253 path: "providers.models.ollama.api-key".into(),
2254 category: "providers.models".into(),
2255 kind: "string",
2256 type_hint: "Option<String>",
2257 value: None,
2258 populated: true,
2259 is_secret: true,
2260 is_env_overridden: false,
2261 enum_variants: vec![],
2262 section: Some("providers.models"),
2263 tab: "",
2264 };
2265 let json = serde_json::to_value(&entry).expect("serialize");
2266 let obj = json.as_object().expect("object");
2267 assert!(
2269 !obj.contains_key("value"),
2270 "secret list entry leaks `value` field"
2271 );
2272 assert_eq!(obj.get("is_secret"), Some(&serde_json::Value::Bool(true)));
2274 assert_eq!(obj.get("populated"), Some(&serde_json::Value::Bool(true)));
2275 }
2276
2277 #[test]
2278 fn gateway_paired_tokens_is_gateway_managed() {
2279 assert!(
2284 is_gateway_managed_field("gateway.paired_tokens"),
2285 "gateway.paired_tokens must be treated as gateway-managed"
2286 );
2287 assert!(!is_gateway_managed_field("gateway.paired-tokens"));
2289
2290 let cfg = zeroclaw_config::schema::Config::default();
2293 assert!(
2294 cfg.prop_fields()
2295 .iter()
2296 .any(|p| p.name == "gateway.paired_tokens"),
2297 "expected a prop-field named gateway.paired_tokens"
2298 );
2299 }
2300
2301 #[tokio::test]
2302 async fn compute_drift_excludes_gateway_paired_tokens() {
2303 let (_tmp, path) = temp_config_path();
2304 let mut cfg = zeroclaw_config::schema::Config {
2305 config_path: path.clone(),
2306 ..Default::default()
2307 };
2308 cfg.save().await.expect("initial save");
2309
2310 cfg.gateway.paired_tokens = vec!["minted-by-the-gateway".into()];
2313
2314 let drift = compute_drift(&cfg).await;
2315 assert!(
2316 !drift.iter().any(|d| d.path == "gateway.paired_tokens"),
2317 "gateway.paired_tokens must never appear in drift, got {drift:?}"
2318 );
2319 }
2320
2321 #[test]
2329 fn every_gateway_secret_is_classified() {
2330 const OPERATOR_EDITED_GATEWAY_SECRETS: &[&str] = &[];
2336
2337 let cfg = zeroclaw_config::schema::Config::default();
2338 let unclassified: Vec<String> = cfg
2339 .prop_fields()
2340 .iter()
2341 .filter(|p| p.is_secret && p.name.starts_with("gateway."))
2342 .map(|p| p.name.clone())
2343 .filter(|name| {
2344 !is_gateway_managed_field(name)
2345 && !OPERATOR_EDITED_GATEWAY_SECRETS.contains(&name.as_str())
2346 })
2347 .collect();
2348
2349 assert!(
2350 unclassified.is_empty(),
2351 "new [gateway] secret field(s) {unclassified:?} are not classified.\n\
2352 If the gateway mints/rotates/persists this field itself, add it to \
2353 `is_gateway_managed_field`.\n\
2354 If operators edit it directly in config.toml, add it to the \
2355 OPERATOR_EDITED_GATEWAY_SECRETS list in this test."
2356 );
2357 }
2358
2359 #[test]
2360 fn drift_entry_for_secret_omits_both_values() {
2361 let entry = DriftEntry {
2362 path: "providers.models.ollama.api-key".into(),
2363 secret: true,
2364 drifted: true,
2365 in_memory_value: None,
2366 on_disk_value: None,
2367 };
2368 let json = serde_json::to_value(&entry).expect("serialize");
2369 let obj = json.as_object().expect("object");
2370 assert!(
2371 !obj.contains_key("in_memory_value"),
2372 "secret drift entry leaks in_memory_value"
2373 );
2374 assert!(
2375 !obj.contains_key("on_disk_value"),
2376 "secret drift entry leaks on_disk_value"
2377 );
2378 assert_eq!(obj.get("secret"), Some(&serde_json::Value::Bool(true)));
2379 assert_eq!(obj.get("drifted"), Some(&serde_json::Value::Bool(true)));
2380 }
2381
2382 #[tokio::test]
2383 async fn apply_comments_clears_existing_comment_when_passed_empty() {
2384 let (_tmp, path) = temp_config_path();
2385 let mut cfg = zeroclaw_config::schema::Config {
2386 config_path: path.clone(),
2387 ..Default::default()
2388 };
2389 cfg.set_prop("gateway.host", "10.0.0.5").expect("set_prop");
2390 cfg.save().await.expect("save");
2391
2392 zeroclaw_config::comment_writer::apply_comments(
2393 &path,
2394 &[("gateway.host".into(), "first reason".into())],
2395 )
2396 .await
2397 .expect("apply first comment");
2398 zeroclaw_config::comment_writer::apply_comments(
2399 &path,
2400 &[("gateway.host".into(), String::new())],
2401 )
2402 .await
2403 .expect("apply empty");
2404
2405 let raw = tokio::fs::read_to_string(&path).await.expect("read back");
2406 assert!(
2407 !raw.contains("first reason"),
2408 "expected the prior comment to be cleared, got:\n{raw}"
2409 );
2410 }
2411}