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::traits::MaskSecrets;
23use zeroclaw_runtime::onboard::{field_visibility, section_for_path};
24
25use super::AppState;
26use super::api::require_auth;
27
28#[derive(Debug, Deserialize)]
32#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
33pub struct PropQuery {
34 pub path: String,
35}
36
37#[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#[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#[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#[derive(Debug, Serialize)]
79#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
80pub struct PatchOpResult {
81 pub op: String,
82 pub path: String,
83 #[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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
110}
111
112pub 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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub warnings: Vec<zeroclaw_config::validation_warnings::ValidationWarning>,
197}
198
199#[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#[derive(Debug, Serialize)]
218#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
219pub struct ListEntry {
220 pub path: String,
221 pub category: String,
222 pub kind: &'static str,
226 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 #[serde(default, skip_serializing_if = "is_false")]
238 pub is_env_overridden: bool,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub enum_variants: Vec<String>,
243 #[serde(skip_serializing_if = "Option::is_none")]
248 pub onboard_section: Option<&'static str>,
249}
250
251fn 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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub drifted: Vec<DriftEntry>,
276}
277
278#[derive(Debug, Serialize)]
285#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
286pub struct DriftEntry {
287 pub path: String,
288 #[serde(default, skip_serializing_if = "is_false")]
290 pub secret: bool,
291 pub drifted: bool,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub in_memory_value: Option<serde_json::Value>,
297 #[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
306fn 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
315fn 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
327use zeroclaw_config::typed_value::coerce_for_set_prop as json_to_setprop_string;
334
335fn 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
347fn 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 let snapshot = if config_path.exists() {
409 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
434fn is_gateway_managed_field(name: &str) -> bool {
439 matches!(name, "gateway.paired-tokens")
440}
441
442pub 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 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 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 drift.sort_by(|a, b| a.path.cmp(&b.path));
539 drift
540}
541
542pub 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 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
591pub 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
662pub 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
716pub 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 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
782pub 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 pub pending_reload: bool,
802}
803
804pub 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 pub path: String,
821 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 pub kind: &'static str,
845 pub value_type: &'static str,
847 pub description: &'static str,
849}
850
851pub 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; 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
885pub 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
910pub 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 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
982pub 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 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 pub path: String,
1050 pub from: String,
1052 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
1065pub 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
1104pub 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 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 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, });
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 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 let scoped_validation_warnings = match scoped_validate(&working) {
1345 Ok(ws) => ws,
1346 Err(err) => return error_response(err),
1347 };
1348
1349 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 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 ::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
1391fn 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 #[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
1419pub 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 #[serde(skip_serializing_if = "Option::is_none")]
1463 pub backup_path: Option<String>,
1464 pub schema_version: u32,
1465}
1466
1467pub 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 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 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 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 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 #[cfg(unix)]
1588 if let Ok(dir) = tokio::fs::File::open(&parent).await {
1589 let _ = dir.sync_all().await;
1590 }
1591
1592 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
1620pub async fn handle_options_config(headers: HeaderMap) -> Response {
1628 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
1646pub 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 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
1727fn 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
1757fn 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 #[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 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 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 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 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 let mut cfg = zeroclaw_config::schema::Config::default();
1899 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 }
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 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 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 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 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 assert!(
2039 raw.contains("# raised after Q3 backlog"),
2040 "expected comment in file, got:\n{raw}"
2041 );
2042
2043 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 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 use zeroclaw_runtime::agent::loop_::scrub_credentials;
2078
2079 let cases = [
2086 (
2088 "api-key=sk-live-abcdef-1234567890",
2089 "sk-live-abcdef-1234567890",
2090 ),
2091 (
2093 r#""token": "sk-test-supersecret-12345""#,
2094 "sk-test-supersecret-12345",
2095 ),
2096 (
2098 "secret: hunter2-not-a-real-password",
2099 "hunter2-not-a-real-password",
2100 ),
2101 (
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 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 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 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 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 assert!(
2195 !obj.contains_key("value"),
2196 "secret list entry leaks `value` field"
2197 );
2198 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}