1use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9
10use khive_types::{namespace::Namespace, SubstrateKind};
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::{
15 config::{parse_embedding_model_alias, BackendId},
16 presentation::OutputFormat,
17};
18
19#[derive(Debug, Error)]
23pub enum ConfigError {
24 #[error("mount configuration: {reason}")]
25 InvalidMountConfig { reason: String },
26
27 #[error("config file I/O: {0}")]
28 Io(#[from] std::io::Error),
29
30 #[error("config TOML parse error in {path}: {source}")]
31 Parse {
32 path: PathBuf,
33 #[source]
34 source: toml::de::Error,
35 },
36
37 #[error("exactly one engine must be marked `default = true`; found {found}")]
38 DefaultCount { found: usize },
39
40 #[error("duplicate engine name: {name:?}")]
41 DuplicateName { name: String },
42
43 #[error(
44 "engine {name:?}: model {model:?} is not a recognized lattice_embed::EmbeddingModel name"
45 )]
46 UnknownModel { name: String, model: String },
47
48 #[error("engine {name:?}: fusion_weight must be > 0, got {value}")]
49 InvalidFusionWeight { name: String, value: f64 },
50
51 #[error("actor.id {id:?} is not a valid namespace: {reason}")]
52 InvalidActorId { id: String, reason: String },
53
54 #[error("[actor].mailbox_readers: {reason}")]
55 InvalidMailboxReaders { reason: String },
56
57 #[error("[gate].granted_actors entry {id:?} is not a valid actor id: {reason}")]
58 InvalidGrantedActorId { id: String, reason: String },
59 #[error("[gate].deny_writes_for is invalid: {reason}")]
60 InvalidWriteDenyPatterns { reason: String },
61
62 #[error("duplicate backend name: {name:?}")]
63 DuplicateBackendName { name: String },
64
65 #[error("invalid backend name {name:?}: {reason}")]
66 InvalidBackendName { name: String, reason: String },
67
68 #[error("backend {name:?}: `served_kinds` must not be empty when declared")]
69 EmptyBackendServedKinds { name: String },
70
71 #[error(
72 "backend configuration leaves searchable substrate kinds {kinds:?} unserved; \
73 defined backends: {defined}"
74 )]
75 MissingBackendSearchKinds {
76 kinds: Vec<SubstrateKind>,
77 defined: String,
78 },
79
80 #[error(
81 "[packs.{pack}].backend = {backend:?} references an unknown backend; \
82 defined backends: {defined}"
83 )]
84 UnknownPackBackend {
85 pack: String,
86 backend: String,
87 defined: String,
88 },
89
90 #[error(
91 "[[backends]] entry {name:?}: field `{field}` is not yet supported; \
92 remove it from the config or wait for a future release that implements it"
93 )]
94 UnsupportedBackendField { name: String, field: &'static str },
95
96 #[error(
97 "top-level `db = {value:?}` is not a supported config-file key; \
98 use `--db` / `KHIVE_DB` to select a single-file database, or \
99 `[[backends]].path` to declare storage backend topology"
100 )]
101 UnsupportedTopLevelDb { value: String },
102
103 #[error("[[git_write.allowed]] entry {repo:?}: {reason}")]
104 InvalidGitWriteEntry { repo: String, reason: String },
105
106 #[error("[git_write] {key}: {reason}")]
107 InvalidGitWriteConfig { key: String, reason: String },
108
109 #[error("[exec] {key}: {reason}")]
110 InvalidExecConfig { key: String, reason: String },
111
112 #[error("{entry}: {reason}")]
113 InvalidTelemetryConfig { entry: String, reason: String },
114
115 #[error("[web] {key}: {reason}")]
116 InvalidWebConfig { key: String, reason: String },
117
118 #[error(
119 "[runtime] blob_hydration_bytes must be between {min} and {max} bytes inclusive; got {value}"
120 )]
121 InvalidBlobHydrationBytes { value: u64, min: u64, max: u64 },
122
123 #[error("the explicitly selected config file does not exist: {path}")]
124 ExplicitConfigMissing { path: PathBuf },
125
126 #[error("[gate] configuration is not supported by this build")]
130 UnsupportedGateSection,
131
132 #[error(
133 "[display] timezone {timezone:?} is not a recognized IANA zone name (e.g. \"America/New_York\", \"UTC\")"
134 )]
135 InvalidDisplayTimezone { timezone: String },
136
137 #[error("{source} (config file: {})", path.display())]
147 InFile {
148 path: PathBuf,
149 #[source]
150 source: Box<ConfigError>,
151 },
152}
153
154impl ConfigError {
155 fn in_file(self, path: &Path) -> Self {
158 match self {
159 already @ (ConfigError::Parse { .. }
160 | ConfigError::ExplicitConfigMissing { .. }
161 | ConfigError::InFile { .. }) => already,
162 other => ConfigError::InFile {
163 path: path.to_path_buf(),
164 source: Box::new(other),
165 },
166 }
167 }
168}
169
170#[derive(Debug, Clone, Deserialize)]
174pub struct EngineConfig {
175 pub name: String,
177
178 pub model: String,
183
184 #[serde(default)]
187 pub default: bool,
188
189 pub fusion_weight: Option<f64>,
199
200 pub dims: Option<u32>,
206}
207
208#[derive(Debug, Clone, Deserialize, Default)]
233#[serde(deny_unknown_fields)]
234pub struct ActorConfig {
235 #[serde(default)]
241 pub id: Option<String>,
242
243 #[serde(default)]
246 pub display_name: Option<String>,
247
248 #[serde(default)]
256 pub mailbox_readers: Vec<String>,
257
258 #[serde(default)]
264 pub visible_namespaces: Option<Vec<String>>,
265
266 #[serde(default)]
278 pub allowed_outbound_namespaces: Vec<String>,
279}
280
281#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
288#[serde(deny_unknown_fields)]
289pub struct GateSectionConfig {
290 #[serde(default)]
292 pub granted_actors: Vec<String>,
293
294 #[serde(default)]
296 pub grant_unattributed: bool,
297
298 #[serde(default)]
301 pub deny_writes_for: Vec<String>,
302}
303
304#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
308#[serde(rename_all = "lowercase")]
309pub enum BackendKind {
310 #[default]
312 Sqlite,
313 Memory,
315}
316
317#[derive(Debug, Clone, Deserialize)]
334pub struct BackendConfig {
335 pub name: String,
337 #[serde(default)]
339 pub kind: BackendKind,
340 pub path: Option<std::path::PathBuf>,
343 pub cache_mb: Option<u32>,
345 pub journal_mode: Option<String>,
347 #[serde(default)]
353 pub served_kinds: Option<BTreeSet<SubstrateKind>>,
354 #[serde(default)]
356 pub read_only: bool,
357}
358
359#[derive(Debug, Clone, Deserialize)]
373pub struct PackConfig {
374 pub backend: String,
377 #[serde(default)]
387 pub no_embed: bool,
388}
389
390#[derive(Debug, Clone, Deserialize)]
417#[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)]
418pub enum BlobConfig {
419 Fs {
423 #[serde(default)]
424 root: Option<String>,
425 #[serde(default)]
426 floor_bytes: Option<u64>,
427 },
428 S3 {
433 bucket: String,
434 region: String,
435 #[serde(default)]
436 endpoint: Option<String>,
437 #[serde(default)]
438 prefix: Option<String>,
439 #[serde(default)]
440 allow_http: Option<bool>,
441 },
442}
443
444#[derive(Debug, Clone, Deserialize, Default)]
447pub struct StorageSectionConfig {
448 #[serde(default)]
453 pub blob: Option<BlobConfig>,
454}
455
456#[derive(Debug, Clone, Deserialize, Serialize, Default)]
458#[serde(deny_unknown_fields)]
459pub struct BrainSectionConfig {
460 #[serde(default)]
462 pub fleet_readers: Vec<String>,
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize)]
477pub struct GitWriteEntryConfig {
478 pub repo: String,
480 pub branches: Vec<String>,
483}
484
485#[derive(Debug, Clone, Deserialize, Serialize)]
497pub struct GitWriteSectionConfig {
498 #[serde(default)]
500 pub program: Option<PathBuf>,
501 #[serde(default)]
502 pub allowed: Vec<GitWriteEntryConfig>,
503 #[serde(default)]
504 pub actors: BTreeMap<String, GitWriteActorConfig>,
505 #[serde(default)]
506 pub repositories: BTreeMap<String, GitWriteRepositoryConfig>,
507 #[serde(default = "default_git_credential_resolver")]
508 pub credential_resolver: Vec<String>,
509 #[serde(default)]
510 pub contract_faults: bool,
511 #[serde(default)]
512 pub fault: Option<String>,
513}
514
515#[derive(Debug, Clone, Deserialize, Serialize)]
516#[serde(deny_unknown_fields)]
517pub struct GitWriteRepositoryConfig {
518 pub remote: String,
520 pub slug: String,
522 pub visibility: String,
523 #[serde(default)]
529 pub merge_refusals: Vec<String>,
530}
531
532impl GitWriteRepositoryConfig {
533 pub const MERGE_REFUSALS: [&'static str; 2] = ["opener", "last_pusher"];
534
535 pub fn refuses_merge_by(&self, entry: &str) -> bool {
537 self.merge_refusals.iter().any(|listed| listed == entry)
538 }
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
542#[serde(deny_unknown_fields)]
543pub struct GitWriteActorConfig {
544 pub name: String,
545 pub email: String,
546 pub credential_ref: String,
547 pub platform_identity: String,
548}
549
550fn default_git_credential_resolver() -> Vec<String> {
551 [
552 "/usr/bin/security",
553 "find-generic-password",
554 "-w",
555 "-s",
556 "{ref}",
557 ]
558 .into_iter()
559 .map(str::to_string)
560 .collect()
561}
562
563impl Default for GitWriteSectionConfig {
564 fn default() -> Self {
565 Self {
566 program: None,
567 allowed: Vec::new(),
568 actors: BTreeMap::new(),
569 repositories: BTreeMap::new(),
570 credential_resolver: default_git_credential_resolver(),
571 contract_faults: false,
572 fault: None,
573 }
574 }
575}
576
577impl GitWriteSectionConfig {
578 pub fn git_program(&self) -> &Path {
579 self.program.as_deref().unwrap_or_else(|| Path::new("git"))
580 }
581
582 pub fn validate_dev_loop(&self) -> Result<(), ConfigError> {
583 let invalid = |key: &str, reason: &str| ConfigError::InvalidGitWriteConfig {
584 key: key.to_string(),
585 reason: reason.to_string(),
586 };
587 if let Some(program) = &self.program {
588 if !program.is_absolute() {
589 return Err(invalid("git_write.program", "must be absolute"));
590 }
591 let metadata = std::fs::metadata(program).map_err(|error| {
592 if error.kind() == std::io::ErrorKind::NotFound {
593 invalid("git_write.program", "does not exist")
594 } else {
595 invalid("git_write.program", &format!("is not executable: {error}"))
596 }
597 })?;
598 #[cfg(unix)]
599 let executable = {
600 use std::os::unix::fs::PermissionsExt;
601 metadata.permissions().mode() & 0o111 != 0
602 };
603 #[cfg(windows)]
604 let executable = program
605 .extension()
606 .and_then(|extension| extension.to_str())
607 .is_some_and(|extension| {
608 extension.eq_ignore_ascii_case("exe") || extension.eq_ignore_ascii_case("com")
609 });
610 #[cfg(not(any(unix, windows)))]
611 let executable = false;
612 if !metadata.is_file() || !executable {
613 return Err(invalid("git_write.program", "is not executable"));
614 }
615 }
616 if self.contract_faults && !cfg!(feature = "contract-faults") {
617 tracing::error!(
618 target: "khive.boot",
619 "[git_write] contract_faults requires the test-only contract-faults build feature"
620 );
621 return Err(invalid(
622 "contract_faults",
623 "requires the test-only contract-faults build feature",
624 ));
625 }
626 if let Some(fault) = &self.fault {
627 if !self.contract_faults {
628 return Err(invalid("fault", "requires contract_faults = true"));
629 }
630 let valid = fault.split_once(':').is_some_and(|(verb, point)| {
631 matches!(verb, "git.push" | "git.pr_merge")
632 && matches!(
633 point,
634 "reply-lost-after-effect" | "audit-fails-after-effect"
635 )
636 });
637 if !valid {
638 return Err(invalid("fault", "unsupported contract fault selector"));
639 }
640 }
641 for (path, repository) in &self.repositories {
642 let key = format!("repositories.{path}.merge_refusals");
643 let mut seen: Vec<&str> = Vec::new();
644 for entry in &repository.merge_refusals {
645 if !GitWriteRepositoryConfig::MERGE_REFUSALS.contains(&entry.as_str()) {
646 return Err(invalid(&key, "entries must be opener or last_pusher"));
647 }
648 if seen.contains(&entry.as_str()) {
649 return Err(invalid(&key, "entries must not repeat"));
650 }
651 seen.push(entry);
652 }
653 }
654 if !cfg!(unix)
657 && self.actors.is_empty()
658 && self.credential_resolver == default_git_credential_resolver()
659 {
660 return Ok(());
661 }
662 let argv = &self.credential_resolver;
663 let Some(program) = argv.first() else {
664 return Err(invalid("credential_resolver", "argv must not be empty"));
665 };
666 let program_path = Path::new(program);
667 if !program_path.is_absolute() {
668 return Err(invalid(
669 "credential_resolver",
670 "argv[0] must be an absolute path",
671 ));
672 }
673 let program_name = program_path
674 .file_name()
675 .and_then(|name| name.to_str())
676 .unwrap_or_default()
677 .to_ascii_lowercase();
678 if matches!(
679 program_name.trim_end_matches(".exe"),
680 "sh" | "bash"
681 | "dash"
682 | "zsh"
683 | "ksh"
684 | "fish"
685 | "csh"
686 | "tcsh"
687 | "cmd"
688 | "powershell"
689 | "pwsh"
690 | "env"
691 ) {
692 return Err(invalid(
693 "credential_resolver",
694 "shell or env launcher is not allowed",
695 ));
696 }
697 if argv.iter().any(|arg| arg.chars().any(char::is_control)) {
698 return Err(invalid(
699 "credential_resolver",
700 "argv must not contain control characters",
701 ));
702 }
703 if program.contains(['{', '}'])
704 || argv[1..]
705 .iter()
706 .any(|arg| arg != "{ref}" && arg.contains(['{', '}']))
707 {
708 return Err(invalid(
709 "credential_resolver",
710 "{ref} must be a complete argument and is the only allowed template",
711 ));
712 }
713 if !argv[1..].iter().any(|arg| arg == "{ref}") {
714 return Err(invalid(
715 "credential_resolver",
716 "argv must contain a {ref} argument",
717 ));
718 }
719 for (actor, identity) in &self.actors {
720 if actor.trim().is_empty() || actor.chars().any(char::is_control) {
721 return Err(invalid(
722 "actors",
723 "actor labels must be nonempty and contain no control characters",
724 ));
725 }
726 for (field, value) in [
727 ("name", &identity.name),
728 ("email", &identity.email),
729 ("credential_ref", &identity.credential_ref),
730 ("platform_identity", &identity.platform_identity),
731 ] {
732 if value.trim().is_empty() || value.chars().any(char::is_control) {
733 return Err(invalid(
734 &format!("actors.{actor}.{field}"),
735 "must be nonempty and contain no control characters",
736 ));
737 }
738 }
739 if identity.name.contains(['<', '>']) || identity.email.contains(['<', '>']) {
740 return Err(invalid(
741 &format!("actors.{actor}"),
742 "name and email must not contain Git identity delimiters",
743 ));
744 }
745 }
746 Ok(())
747 }
748}
749
750#[derive(Debug, Clone, Deserialize, Default)]
755pub struct ExecLimitsConfig {
756 #[serde(default)]
757 pub cpu_seconds: Option<u64>,
758 #[serde(default)]
759 pub address_space: Option<u64>,
760 #[serde(default)]
761 pub file_size: Option<u64>,
762 #[serde(default)]
763 pub nproc: Option<u64>,
764}
765
766#[derive(Debug, Clone, Deserialize, Default)]
788pub struct ExecSectionConfig {
789 #[serde(default)]
790 pub root: Option<String>,
791 #[serde(default)]
792 pub read_roots: Vec<String>,
793 #[serde(default)]
794 pub env: Vec<String>,
795 #[serde(default)]
796 pub never: Vec<String>,
797 #[serde(default)]
798 pub max_output_bytes: Option<u64>,
799 #[serde(default)]
800 pub timeout_default_s: Option<f64>,
801 #[serde(default)]
802 pub timeout_max_s: Option<f64>,
803 #[serde(default)]
804 pub keep: bool,
805 #[serde(default)]
806 pub limits: ExecLimitsConfig,
807}
808
809#[derive(Debug, Clone, Deserialize, Serialize)]
818#[serde(deny_unknown_fields)]
819pub struct WebAllowlistEntry {
820 pub host: String,
821}
822
823#[derive(Debug, Clone, Deserialize, Serialize)]
830#[serde(deny_unknown_fields)]
831pub struct WebCredentialConfig {
832 pub name: String,
834 pub env_var: String,
836 pub hosts: Vec<String>,
839}
840
841#[derive(Debug, Clone, Deserialize, Serialize)]
845#[serde(deny_unknown_fields)]
846pub struct WebFixtureResult {
847 pub title: String,
848 pub url: String,
849 pub snippet: String,
850}
851
852#[derive(Debug, Clone, Deserialize, Serialize)]
856#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
857pub enum WebSearchProviderConfig {
858 Fixture {
860 name: String,
861 #[serde(default)]
862 default: bool,
863 results: Vec<WebFixtureResult>,
864 },
865 Http {
872 name: String,
873 #[serde(default)]
874 default: bool,
875 url_template: String,
876 #[serde(default)]
877 api_key_env: Option<String>,
878 #[serde(default)]
885 hosts: Vec<String>,
886 },
887}
888
889impl WebSearchProviderConfig {
890 pub fn name(&self) -> &str {
891 match self {
892 WebSearchProviderConfig::Fixture { name, .. } => name,
893 WebSearchProviderConfig::Http { name, .. } => name,
894 }
895 }
896
897 pub fn is_default(&self) -> bool {
898 match self {
899 WebSearchProviderConfig::Fixture { default, .. } => *default,
900 WebSearchProviderConfig::Http { default, .. } => *default,
901 }
902 }
903}
904
905#[derive(Debug, Clone, Deserialize, Serialize, Default)]
939#[serde(deny_unknown_fields)]
940pub struct WebSectionConfig {
941 #[serde(default)]
942 pub timeout_default_s: Option<u64>,
943 #[serde(default)]
944 pub timeout_max_s: Option<u64>,
945 #[serde(default)]
946 pub max_bytes_default: Option<u64>,
947 #[serde(default)]
948 pub max_bytes_max: Option<u64>,
949 #[serde(default)]
950 pub search_limit_default: Option<u32>,
951 #[serde(default)]
952 pub search_limit_max: Option<u32>,
953 #[serde(default)]
954 pub allowlist: Vec<WebAllowlistEntry>,
955 #[serde(default)]
956 pub credentials: Vec<WebCredentialConfig>,
957 #[serde(default)]
958 pub search_providers: Vec<WebSearchProviderConfig>,
959 #[serde(default)]
963 pub read_roots: Vec<String>,
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub struct WebCeilings {
969 pub timeout_default_s: u64,
970 pub timeout_max_s: u64,
971 pub max_bytes_default: u64,
972 pub max_bytes_max: u64,
973 pub search_limit_default: u32,
974 pub search_limit_max: u32,
975}
976
977impl Default for WebCeilings {
978 fn default() -> Self {
979 Self {
980 timeout_default_s: 30,
981 timeout_max_s: 120,
982 max_bytes_default: 5 * 1024 * 1024,
983 max_bytes_max: 50 * 1024 * 1024,
984 search_limit_default: 10,
985 search_limit_max: 50,
986 }
987 }
988}
989
990impl WebSectionConfig {
991 pub fn resolved_ceilings(&self) -> Result<WebCeilings, ConfigError> {
992 let defaults = WebCeilings::default();
993 let bounds = WebCeilings {
994 timeout_default_s: self.timeout_default_s.unwrap_or(defaults.timeout_default_s),
995 timeout_max_s: self.timeout_max_s.unwrap_or(defaults.timeout_max_s),
996 max_bytes_default: self.max_bytes_default.unwrap_or(defaults.max_bytes_default),
997 max_bytes_max: self.max_bytes_max.unwrap_or(defaults.max_bytes_max),
998 search_limit_default: self
999 .search_limit_default
1000 .unwrap_or(defaults.search_limit_default),
1001 search_limit_max: self.search_limit_max.unwrap_or(defaults.search_limit_max),
1002 };
1003 for (key, default, maximum, maximum_key) in [
1004 (
1005 "timeout_default_s",
1006 bounds.timeout_default_s,
1007 bounds.timeout_max_s,
1008 "timeout_max_s",
1009 ),
1010 (
1011 "max_bytes_default",
1012 bounds.max_bytes_default,
1013 bounds.max_bytes_max,
1014 "max_bytes_max",
1015 ),
1016 (
1017 "search_limit_default",
1018 u64::from(bounds.search_limit_default),
1019 u64::from(bounds.search_limit_max),
1020 "search_limit_max",
1021 ),
1022 ] {
1023 if default == 0 || default > maximum {
1024 return Err(ConfigError::InvalidWebConfig {
1025 key: key.into(),
1026 reason: format!(
1027 "resolved default must be positive and not exceed {maximum_key}={maximum}"
1028 ),
1029 });
1030 }
1031 }
1032 if std::time::Instant::now()
1033 .checked_add(std::time::Duration::from_secs(bounds.timeout_max_s))
1034 .is_none()
1035 {
1036 return Err(ConfigError::InvalidWebConfig {
1037 key: "timeout_max_s".into(),
1038 reason: "cannot be represented as a request deadline".into(),
1039 });
1040 }
1041 Ok(bounds)
1042 }
1043
1044 pub fn validate(&self) -> Result<(), ConfigError> {
1045 self.resolved_ceilings()?;
1046 let invalid = |key: &str, reason: &str| ConfigError::InvalidWebConfig {
1047 key: key.to_string(),
1048 reason: reason.to_string(),
1049 };
1050 let mut seen_hosts = std::collections::HashSet::new();
1051 for entry in &self.allowlist {
1052 let normalized = entry.host.trim().trim_end_matches('.').to_ascii_lowercase();
1053 if normalized.is_empty() {
1054 return Err(invalid("allowlist.host", "must not be empty"));
1055 }
1056 if !seen_hosts.insert(normalized) {
1057 return Err(invalid("allowlist.host", "duplicate host entry"));
1058 }
1059 }
1060 let mut seen_credentials = std::collections::HashSet::new();
1061 for credential in &self.credentials {
1062 if credential.name.trim().is_empty() {
1063 return Err(invalid("credentials.name", "must not be empty"));
1064 }
1065 if !seen_credentials.insert(credential.name.clone()) {
1066 return Err(invalid("credentials.name", "duplicate credential name"));
1067 }
1068 if credential.env_var.trim().is_empty() {
1069 return Err(invalid("credentials.env_var", "must not be empty"));
1070 }
1071 if credential.hosts.is_empty() {
1072 return Err(invalid(
1073 "credentials.hosts",
1074 "must name at least one host or suffix",
1075 ));
1076 }
1077 }
1078 let mut seen_providers = std::collections::HashSet::new();
1079 let mut default_count = 0;
1080 for provider in &self.search_providers {
1081 let name = provider.name();
1082 if name.trim().is_empty() {
1083 return Err(invalid("search_providers.name", "must not be empty"));
1084 }
1085 if !seen_providers.insert(name.to_string()) {
1086 return Err(invalid("search_providers.name", "duplicate provider name"));
1087 }
1088 if provider.is_default() {
1089 default_count += 1;
1090 }
1091 if let WebSearchProviderConfig::Http {
1092 url_template,
1093 api_key_env,
1094 hosts,
1095 ..
1096 } = provider
1097 {
1098 if !url_template.contains("{query}") {
1099 return Err(invalid(
1100 "search_providers.url_template",
1101 "must contain the literal substring {query}",
1102 ));
1103 }
1104 if api_key_env.is_some() && hosts.is_empty() {
1105 return Err(invalid(
1106 "search_providers.hosts",
1107 "an api_key_env-bearing provider must name at least one host or suffix",
1108 ));
1109 }
1110 }
1111 }
1112 if default_count > 1 {
1113 return Err(invalid(
1114 "search_providers",
1115 "at most one provider may set default = true",
1116 ));
1117 }
1118 Ok(())
1119 }
1120}
1121
1122#[derive(Debug, Clone, Deserialize, Default)]
1139pub struct KhiveConfig {
1140 #[serde(default)]
1141 pub mounts: Vec<crate::mount_config::MountConfig>,
1142
1143 #[serde(default)]
1149 pub db: Option<String>,
1150
1151 #[serde(default)]
1153 pub engines: Vec<EngineConfig>,
1154
1155 #[serde(default)]
1163 pub actor: ActorConfig,
1164
1165 #[serde(default)]
1169 pub gate: Option<GateSectionConfig>,
1170
1171 #[serde(default)]
1173 pub runtime: RuntimeSectionConfig,
1174
1175 #[serde(default)]
1180 pub backends: Vec<BackendConfig>,
1181
1182 #[serde(default)]
1188 pub packs: std::collections::HashMap<String, PackConfig>,
1189
1190 #[serde(default)]
1192 pub brain: BrainSectionConfig,
1193
1194 #[serde(default)]
1198 pub git_write: GitWriteSectionConfig,
1199
1200 #[serde(default)]
1203 pub storage: StorageSectionConfig,
1204
1205 #[serde(default)]
1208 pub exec: ExecSectionConfig,
1209
1210 #[serde(default)]
1212 pub telemetry: crate::telemetry_config::TelemetryConfig,
1213
1214 #[serde(default)]
1218 pub display: DisplaySectionConfig,
1219
1220 #[serde(default)]
1225 pub web: WebSectionConfig,
1226}
1227
1228#[derive(Debug, Clone, Deserialize, Default)]
1235pub struct RuntimeSectionConfig {
1236 #[serde(default)]
1239 pub packs: Option<Vec<String>>,
1240
1241 #[serde(default)]
1248 pub brain_profile: Option<String>,
1249
1250 #[serde(default)]
1257 pub default_output_format: Option<OutputFormat>,
1258
1259 #[serde(default)]
1266 pub blob_hydration_bytes: Option<u64>,
1267}
1268
1269#[derive(Debug, Clone, Deserialize, Default)]
1277pub struct DisplaySectionConfig {
1278 #[serde(default)]
1284 pub timezone: Option<String>,
1285}
1286
1287impl KhiveConfig {
1288 pub fn load(path: Option<&Path>) -> Result<Option<Self>, ConfigError> {
1304 let resolved = match path {
1305 Some(p) => p.to_path_buf(),
1306 None => PathBuf::from(".khive/config.toml"),
1307 };
1308
1309 if !resolved.exists() {
1310 return Ok(None);
1311 }
1312
1313 let diagnostic_path = std::fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
1316 let raw = std::fs::read_to_string(&resolved)
1317 .map_err(|source| ConfigError::from(source).in_file(&diagnostic_path))?;
1318 let cfg: KhiveConfig = toml::from_str(&raw).map_err(|source| ConfigError::Parse {
1319 path: diagnostic_path.clone(),
1320 source,
1321 })?;
1322 cfg.validate()
1323 .map_err(|error| error.in_file(&diagnostic_path))?;
1324 Ok(Some(cfg))
1325 }
1326
1327 pub fn load_with_home_fallback(
1355 path: Option<&Path>,
1356 db_path: Option<&Path>,
1357 ) -> Result<Option<Self>, ConfigError> {
1358 Ok(Self::load_with_home_fallback_and_source(path, db_path)?.map(|(config, _)| config))
1359 }
1360
1361 pub fn load_with_home_fallback_and_source(
1369 path: Option<&Path>,
1370 db_path: Option<&Path>,
1371 ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1372 if let Some(p) = path {
1380 if !p.exists() {
1381 return Err(ConfigError::ExplicitConfigMissing {
1382 path: p.to_path_buf(),
1383 });
1384 }
1385 return Ok(Self::load(Some(p))?.map(|config| (config, Self::diagnostic_config_path(p))));
1386 }
1387
1388 let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1390 let home_root = std::env::var_os("HOME").map(PathBuf::from);
1391 Self::load_with_roots_and_source(&project_root, home_root.as_deref(), db_path)
1392 }
1393
1394 #[cfg(test)]
1403 pub(crate) fn load_with_roots(
1404 project_root: &Path,
1405 home_root: Option<&Path>,
1406 db_path: Option<&Path>,
1407 ) -> Result<Option<Self>, ConfigError> {
1408 Ok(
1409 Self::load_with_roots_and_source(project_root, home_root, db_path)?
1410 .map(|(config, _)| config),
1411 )
1412 }
1413
1414 fn load_with_roots_and_source(
1415 project_root: &Path,
1416 home_root: Option<&Path>,
1417 db_path: Option<&Path>,
1418 ) -> Result<Option<(Self, PathBuf)>, ConfigError> {
1419 let tier2 = project_root.join("khive.toml");
1421 if tier2.exists() {
1422 return Ok(Self::load(Some(&tier2))?
1423 .map(|config| (config, Self::diagnostic_config_path(&tier2))));
1424 }
1425
1426 let tier3 = Self::project_config_anchor_dir(db_path, project_root).join("config.toml");
1429 if tier3.exists() {
1430 return Ok(Self::load(Some(&tier3))?
1431 .map(|config| (config, Self::diagnostic_config_path(&tier3))));
1432 }
1433
1434 if let Some(home) = home_root {
1436 let tier4 = home.join(".khive/config.toml");
1437 if tier4.exists() {
1438 return Ok(Self::load(Some(&tier4))?
1439 .map(|config| (config, Self::diagnostic_config_path(&tier4))));
1440 }
1441 }
1442
1443 Ok(None)
1444 }
1445
1446 fn diagnostic_config_path(path: &Path) -> PathBuf {
1447 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1448 }
1449
1450 fn project_config_anchor_dir(db_path: Option<&Path>, project_root: &Path) -> PathBuf {
1476 let Some(db_path) = db_path else {
1477 return project_root.join(".khive");
1478 };
1479
1480 let absolute = std::fs::canonicalize(db_path).unwrap_or_else(|_| {
1481 if db_path.is_absolute() {
1482 db_path.to_path_buf()
1483 } else {
1484 project_root.join(db_path)
1485 }
1486 });
1487
1488 let db_dir = absolute.parent().map(Path::to_path_buf).unwrap_or(absolute);
1489
1490 if db_dir.file_name().is_some_and(|name| name == ".khive") {
1491 db_dir
1492 } else {
1493 db_dir.join(".khive")
1494 }
1495 }
1496
1497 pub fn validate(&self) -> Result<(), ConfigError> {
1505 crate::mount_config::validate_mounts(&self.mounts)?;
1506 self.git_write.validate_dev_loop()?;
1507 self.telemetry.validate()?;
1508 self.web.validate()?;
1509
1510 if let Some(value) = self.db.as_deref() {
1515 if !value.is_empty() {
1516 return Err(ConfigError::UnsupportedTopLevelDb {
1517 value: value.to_string(),
1518 });
1519 }
1520 }
1521
1522 if cfg!(target_os = "macos") {
1526 if self.exec.limits.address_space.is_some() {
1527 return Err(ConfigError::InvalidExecConfig {
1528 key: "limits.address_space".to_string(),
1529 reason: "unsupported_on_platform: macOS does not enforce an address-space rlimit per process".to_string(),
1530 });
1531 }
1532 if self.exec.limits.nproc.is_some() {
1533 return Err(ConfigError::InvalidExecConfig {
1534 key: "limits.nproc".to_string(),
1535 reason: "unsupported_on_platform: RLIMIT_NPROC counts every process of the uid, not one run".to_string(),
1536 });
1537 }
1538 }
1539 if let (Some(d), Some(m)) = (self.exec.timeout_default_s, self.exec.timeout_max_s) {
1540 if d > m {
1541 return Err(ConfigError::InvalidExecConfig {
1542 key: "timeout_default_s".to_string(),
1543 reason: format!("default {d} exceeds timeout_max_s {m}"),
1544 });
1545 }
1546 }
1547
1548 if let Some(value) = self.runtime.blob_hydration_bytes {
1549 let min = khive_storage::MAX_BLOB_WHOLE_BYTES;
1550 let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
1551 if value < min || value > max {
1552 return Err(ConfigError::InvalidBlobHydrationBytes { value, min, max });
1553 }
1554 }
1555
1556 if let Some(id) = self.actor.id.as_deref() {
1559 if id.is_empty() {
1560 return Err(ConfigError::InvalidActorId {
1561 id: id.to_string(),
1562 reason: "actor.id must not be empty; remove the key or provide a value"
1563 .to_string(),
1564 });
1565 }
1566 Namespace::parse(id).map_err(|e| ConfigError::InvalidActorId {
1567 id: id.to_string(),
1568 reason: e.to_string(),
1569 })?;
1570 }
1571
1572 self.actor
1573 .mailbox_gate(std::sync::Arc::new(khive_gate::AllowAllGate))
1574 .map_err(|error| ConfigError::InvalidMailboxReaders {
1575 reason: error.to_string(),
1576 })?;
1577
1578 if let Some(ref vis) = self.actor.visible_namespaces {
1579 for ns_str in vis {
1580 if ns_str.is_empty() {
1581 return Err(ConfigError::InvalidActorId {
1582 id: ns_str.clone(),
1583 reason: "visible_namespaces entries must not be empty".to_string(),
1584 });
1585 }
1586 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1587 id: ns_str.clone(),
1588 reason: format!("invalid visible namespace: {e}"),
1589 })?;
1590 }
1591 }
1592
1593 if let Some(gate) = &self.gate {
1594 khive_gate::CallerEnrollmentGate::validate_write_denials(&gate.deny_writes_for)
1595 .map_err(|error| ConfigError::InvalidWriteDenyPatterns {
1596 reason: error.to_string(),
1597 })?;
1598 for id in &gate.granted_actors {
1599 if id.is_empty() {
1600 return Err(ConfigError::InvalidGrantedActorId {
1601 id: id.clone(),
1602 reason: "actor ids must not be empty".to_string(),
1603 });
1604 }
1605 Namespace::parse(id).map_err(|error| ConfigError::InvalidGrantedActorId {
1606 id: id.clone(),
1607 reason: error.to_string(),
1608 })?;
1609 }
1610 }
1611
1612 for ns_str in &self.actor.allowed_outbound_namespaces {
1614 if ns_str.is_empty() {
1615 return Err(ConfigError::InvalidActorId {
1616 id: ns_str.clone(),
1617 reason: "allowed_outbound_namespaces entries must not be empty".to_string(),
1618 });
1619 }
1620 Namespace::parse(ns_str).map_err(|e| ConfigError::InvalidActorId {
1621 id: ns_str.clone(),
1622 reason: format!("invalid allowed_outbound_namespaces entry: {e}"),
1623 })?;
1624 }
1625
1626 if !self.backends.is_empty() {
1628 let mut seen_backends = std::collections::HashSet::new();
1629 for backend in &self.backends {
1630 BackendId::parse(&backend.name).map_err(|error| {
1631 ConfigError::InvalidBackendName {
1632 name: backend.name.clone(),
1633 reason: error.to_string(),
1634 }
1635 })?;
1636 if backend
1637 .served_kinds
1638 .as_ref()
1639 .is_some_and(BTreeSet::is_empty)
1640 {
1641 return Err(ConfigError::EmptyBackendServedKinds {
1642 name: backend.name.clone(),
1643 });
1644 }
1645 if !seen_backends.insert(backend.name.clone()) {
1646 return Err(ConfigError::DuplicateBackendName {
1647 name: backend.name.clone(),
1648 });
1649 }
1650
1651 if backend.cache_mb.is_some() {
1654 return Err(ConfigError::UnsupportedBackendField {
1655 name: backend.name.clone(),
1656 field: "cache_mb",
1657 });
1658 }
1659 if backend.journal_mode.is_some() {
1660 return Err(ConfigError::UnsupportedBackendField {
1661 name: backend.name.clone(),
1662 field: "journal_mode",
1663 });
1664 }
1665 }
1666 }
1667
1668 let defined: Vec<&str> = if self.backends.is_empty() {
1669 vec![BackendId::MAIN]
1670 } else {
1671 self.backends.iter().map(|b| b.name.as_str()).collect()
1672 };
1673 for (pack_name, pack_cfg) in &self.packs {
1674 if !defined.contains(&pack_cfg.backend.as_str()) {
1675 return Err(ConfigError::UnknownPackBackend {
1676 pack: pack_name.clone(),
1677 backend: pack_cfg.backend.clone(),
1678 defined: defined.join(", "),
1679 });
1680 }
1681 }
1682
1683 if !self.backends.is_empty() {
1684 let missing: Vec<_> = [SubstrateKind::Note, SubstrateKind::Entity]
1685 .into_iter()
1686 .filter(|kind| {
1687 !self.backends.iter().any(|backend| {
1688 backend
1689 .served_kinds
1690 .as_ref()
1691 .is_none_or(|served| served.contains(kind))
1692 })
1693 })
1694 .collect();
1695 if !missing.is_empty() {
1696 return Err(ConfigError::MissingBackendSearchKinds {
1697 kinds: missing,
1698 defined: defined.join(", "),
1699 });
1700 }
1701 }
1702
1703 if let Some(tz) = self.display.timezone.as_deref() {
1706 if tz.trim().is_empty() || tz.parse::<chrono_tz::Tz>().is_err() {
1707 return Err(ConfigError::InvalidDisplayTimezone {
1708 timezone: tz.to_string(),
1709 });
1710 }
1711 }
1712
1713 for entry in &self.git_write.allowed {
1720 if entry.repo.trim().is_empty() {
1721 return Err(ConfigError::InvalidGitWriteEntry {
1722 repo: entry.repo.clone(),
1723 reason: "repo must not be empty".to_string(),
1724 });
1725 }
1726 if !Path::new(&entry.repo).is_absolute() {
1727 return Err(ConfigError::InvalidGitWriteEntry {
1728 repo: entry.repo.clone(),
1729 reason: "repo must be an absolute path".to_string(),
1730 });
1731 }
1732 if entry.branches.is_empty() {
1733 return Err(ConfigError::InvalidGitWriteEntry {
1734 repo: entry.repo.clone(),
1735 reason: "branches must not be empty".to_string(),
1736 });
1737 }
1738 if entry.branches.iter().any(|b| b.trim().is_empty()) {
1739 return Err(ConfigError::InvalidGitWriteEntry {
1740 repo: entry.repo.clone(),
1741 reason: "branches entries must not be empty".to_string(),
1742 });
1743 }
1744 if let Some(bad) = entry.branches.iter().find(|b| b.matches('*').count() > 1) {
1749 return Err(ConfigError::InvalidGitWriteEntry {
1750 repo: entry.repo.clone(),
1751 reason: format!(
1752 "branch pattern {bad:?} must contain at most one '*' wildcard (ADR-108)"
1753 ),
1754 });
1755 }
1756 }
1757
1758 if self.engines.is_empty() {
1759 return Ok(());
1760 }
1761
1762 let mut seen_names = std::collections::HashSet::new();
1763 for engine in &self.engines {
1764 if !seen_names.insert(engine.name.clone()) {
1765 return Err(ConfigError::DuplicateName {
1766 name: engine.name.clone(),
1767 });
1768 }
1769 if parse_embedding_model_alias(&engine.model).is_none() {
1770 return Err(ConfigError::UnknownModel {
1771 name: engine.name.clone(),
1772 model: engine.model.clone(),
1773 });
1774 }
1775 }
1776
1777 let default_count = self.engines.iter().filter(|e| e.default).count();
1778 if default_count != 1 {
1779 return Err(ConfigError::DefaultCount {
1780 found: default_count,
1781 });
1782 }
1783
1784 for engine in &self.engines {
1787 if let Some(w) = engine.fusion_weight {
1788 if !w.is_finite() || w <= 0.0 {
1789 return Err(ConfigError::InvalidFusionWeight {
1790 name: engine.name.clone(),
1791 value: w,
1792 });
1793 }
1794 }
1795 }
1796
1797 Ok(())
1798 }
1799
1800 pub fn default_engine(&self) -> Option<&EngineConfig> {
1802 self.engines.iter().find(|e| e.default)
1803 }
1804}
1805
1806pub fn config_from_env() -> KhiveConfig {
1820 let primary_model = std::env::var("KHIVE_EMBEDDING_MODEL")
1821 .ok()
1822 .filter(|s| !s.trim().is_empty());
1823 let additional_raw = std::env::var("KHIVE_ADDITIONAL_EMBEDDING_MODELS")
1824 .ok()
1825 .unwrap_or_default();
1826 let additional: Vec<String> = crate::runtime::parse_pack_list(&additional_raw)
1827 .into_iter()
1828 .filter(|s| !s.is_empty())
1829 .collect();
1830
1831 if primary_model.is_none() && additional.is_empty() {
1832 return KhiveConfig::default();
1833 }
1834
1835 tracing::info!(
1836 "using env-var embedding config; consider migrating to .khive/config.toml in your project root"
1837 );
1838
1839 config_from_env_parts(primary_model, additional)
1840}
1841
1842fn config_from_env_parts(primary_model: Option<String>, additional: Vec<String>) -> KhiveConfig {
1845 let mut engines = Vec::new();
1846
1847 let primary =
1848 primary_model.unwrap_or_else(|| lattice_embed::EmbeddingModel::AllMiniLmL6V2.to_string());
1849 engines.push(EngineConfig {
1850 name: "default".to_string(),
1851 model: primary.clone(),
1852 default: true,
1853 fusion_weight: None,
1854 dims: None,
1855 });
1856
1857 for (i, model) in additional.into_iter().enumerate() {
1858 if model.eq_ignore_ascii_case(&primary) {
1860 continue;
1861 }
1862 engines.push(EngineConfig {
1863 name: format!("engine-{}", i + 1),
1864 model,
1865 default: false,
1866 fusion_weight: None,
1867 dims: None,
1868 });
1869 }
1870
1871 KhiveConfig {
1872 engines,
1873 ..KhiveConfig::default()
1874 }
1875}
1876
1877#[cfg(test)]
1882mod tests {
1883 use super::*;
1884
1885 #[test]
1886 fn web_partial_ceiling_config_rejects_incoherent_effective_bounds_at_load() {
1887 let dir = tempfile::tempdir().unwrap();
1888 for (default_key, max_key, default, maximum) in [
1889 ("timeout_default_s", "timeout_max_s", 30_u64, 120_u64),
1890 (
1891 "max_bytes_default",
1892 "max_bytes_max",
1893 5 * 1024 * 1024,
1894 50 * 1024 * 1024,
1895 ),
1896 ("search_limit_default", "search_limit_max", 10, 50),
1897 ] {
1898 for invalid in [
1899 format!("{max_key}=1"),
1900 format!("{max_key}=0"),
1901 format!("{default_key}=0"),
1902 format!("{default_key}={}", maximum + 1),
1903 format!("{default_key}=2\n{max_key}=1"),
1904 ] {
1905 let path = write_toml(&dir, &format!("[web]\n{invalid}\n"));
1906 let error = KhiveConfig::load(Some(&path)).unwrap_err();
1907 assert!(
1908 error.to_string().contains(default_key),
1909 "{invalid}: {error}"
1910 );
1911 }
1912 for valid in [
1913 String::new(),
1914 format!("{max_key}={default}"),
1915 format!("{default_key}={maximum}"),
1916 format!("{default_key}=1\n{max_key}=1"),
1917 ] {
1918 let path = write_toml(&dir, &format!("[web]\n{valid}\n"));
1919 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
1920 config.web.validate().unwrap();
1921 }
1922 }
1923 }
1924
1925 #[test]
1926 fn web_ceiling_programmatic_validation_rejects_unrepresentable_deadlines() {
1927 let config = WebSectionConfig {
1928 timeout_max_s: Some(u64::MAX),
1929 ..Default::default()
1930 };
1931 assert!(
1932 matches!(config.resolved_ceilings(), Err(ConfigError::InvalidWebConfig { key, .. }) if key == "timeout_max_s")
1933 );
1934 assert!(config.validate().is_err());
1935 let config = WebSectionConfig {
1936 max_bytes_max: Some(1),
1937 ..Default::default()
1938 };
1939 assert!(config.resolved_ceilings().is_err());
1940 }
1941
1942 fn write_toml(dir: &tempfile::TempDir, content: &str) -> PathBuf {
1943 let path = dir.path().join("config.toml");
1944 std::fs::write(&path, content).unwrap();
1945 path
1946 }
1947
1948 fn in_memory_runtime_config() -> crate::RuntimeConfig {
1949 crate::RuntimeConfig {
1950 db_path: None,
1951 ..crate::RuntimeConfig::no_embeddings()
1952 }
1953 }
1954
1955 #[test]
1960 fn load_errors_name_the_config_file() {
1961 let dir = tempfile::tempdir().unwrap();
1962
1963 let gate = write_toml(&dir, "[gate]\nmode = \"x\"\n");
1964 let err = KhiveConfig::load(Some(&gate)).expect_err("unknown gate key must fail");
1965 assert!(
1966 err.to_string().contains(&gate.display().to_string()),
1967 "gate error must name the file, got: {err}"
1968 );
1969
1970 let invalid = write_toml(
1971 &dir,
1972 "[[engines]]\nname = \"a\"\nmodel = \"all-minilm-l6-v2\"\n",
1973 );
1974 let err = KhiveConfig::load(Some(&invalid)).expect_err("validation must fail");
1975 assert!(
1976 err.to_string().contains("(config file: "),
1977 "validation error must name the file, got: {err}"
1978 );
1979
1980 let parse = write_toml(&dir, "not = = toml");
1981 let err = KhiveConfig::load(Some(&parse)).expect_err("parse must fail");
1982 assert!(
1983 err.to_string().contains("config.toml"),
1984 "parse error must name the file, got: {err}"
1985 );
1986 assert!(
1987 matches!(err, ConfigError::Parse { .. }),
1988 "parse errors keep their own variant unwrapped, got: {err:?}"
1989 );
1990 }
1991
1992 fn config_error_root(err: &ConfigError) -> &ConfigError {
1995 match err {
1996 ConfigError::InFile { path, source } => {
1997 assert!(
1998 !path.as_os_str().is_empty(),
1999 "InFile must carry the config path"
2000 );
2001 source
2002 }
2003 other => other,
2004 }
2005 }
2006
2007 #[test]
2010 fn env_additional_only_keeps_builtin_primary() {
2011 let cfg = super::config_from_env_parts(
2012 None,
2013 vec!["paraphrase-multilingual-minilm-l12-v2".to_string()],
2014 );
2015 assert_eq!(cfg.engines.len(), 2);
2016 let default_engine = cfg.default_engine().expect("a default engine");
2017 assert_eq!(default_engine.model, "all-minilm-l6-v2");
2018 assert!(
2019 cfg.engines
2020 .iter()
2021 .any(|e| !e.default && e.model == "paraphrase-multilingual-minilm-l12-v2"),
2022 "additional model must be a non-default secondary engine"
2023 );
2024 }
2025
2026 #[test]
2027 fn env_explicit_primary_stays_primary() {
2028 let cfg = super::config_from_env_parts(
2029 Some("paraphrase-multilingual-minilm-l12-v2".to_string()),
2030 vec![],
2031 );
2032 assert_eq!(cfg.engines.len(), 1);
2033 assert_eq!(
2034 cfg.default_engine().expect("default").model,
2035 "paraphrase-multilingual-minilm-l12-v2"
2036 );
2037 }
2038
2039 #[test]
2040 fn env_additional_restating_primary_is_deduped() {
2041 let cfg = super::config_from_env_parts(None, vec!["all-minilm-l6-v2".to_string()]);
2042 assert_eq!(cfg.engines.len(), 1);
2043 assert!(cfg.engines[0].default);
2044 }
2045
2046 #[test]
2047 fn test_load_minimal_config() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let path = write_toml(
2050 &dir,
2051 r#"
2052[[engines]]
2053name = "x"
2054model = "all-minilm-l6-v2"
2055default = true
2056"#,
2057 );
2058 let cfg = KhiveConfig::load(Some(&path))
2059 .expect("load should succeed")
2060 .expect("file should be found");
2061 assert_eq!(cfg.engines.len(), 1);
2062 assert_eq!(cfg.engines[0].name, "x");
2063 assert_eq!(cfg.engines[0].model, "all-minilm-l6-v2");
2064 assert!(cfg.engines[0].default);
2065 }
2066
2067 #[test]
2068 fn test_unknown_engine_model_rejected_before_conversion() {
2069 let dir = tempfile::tempdir().unwrap();
2070 let path = write_toml(
2071 &dir,
2072 "[[engines]]\nname = \"primary\"\nmodel = \"not-a-model\"\ndefault = true\n",
2073 );
2074 let err = KhiveConfig::load(Some(&path)).expect_err("unknown primary model must fail");
2075 assert!(
2076 matches!(
2077 config_error_root(&err),
2078 ConfigError::UnknownModel { name, model }
2079 if name == "primary" && model == "not-a-model"
2080 ),
2081 "expected UnknownModel for the primary engine, got {err:?}"
2082 );
2083
2084 let config: KhiveConfig = toml::from_str(
2085 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n\n[[engines]]\nname = \"secondary\"\nmodel = \"not-a-model\"\n",
2086 )
2087 .unwrap();
2088 assert!(matches!(
2089 config.validate(),
2090 Err(ConfigError::UnknownModel { name, model })
2091 if name == "secondary" && model == "not-a-model"
2092 ));
2093 }
2094
2095 #[test]
2096 fn test_recognized_engine_model_validates_and_converts() {
2097 let dir = tempfile::tempdir().unwrap();
2098 let path = write_toml(
2099 &dir,
2100 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
2101 );
2102 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
2103 config.validate().unwrap();
2104 let runtime = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
2105 assert_eq!(
2106 runtime.embedding_model,
2107 Some(lattice_embed::EmbeddingModel::AllMiniLmL6V2)
2108 );
2109 }
2110
2111 #[test]
2112 fn test_default_engine_required_when_engines_present() {
2113 let dir = tempfile::tempdir().unwrap();
2114 let path = write_toml(
2115 &dir,
2116 r#"
2117[[engines]]
2118name = "a"
2119model = "all-minilm-l6-v2"
2120"#,
2121 );
2122 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with no default flagged");
2123 assert!(
2124 matches!(
2125 config_error_root(&err),
2126 ConfigError::DefaultCount { found: 0 }
2127 ),
2128 "expected DefaultCount {{ found: 0 }}, got {err:?}"
2129 );
2130 }
2131
2132 #[test]
2133 fn test_multiple_default_rejected() {
2134 let dir = tempfile::tempdir().unwrap();
2135 let path = write_toml(
2136 &dir,
2137 r#"
2138[[engines]]
2139name = "a"
2140model = "all-minilm-l6-v2"
2141default = true
2142
2143[[engines]]
2144name = "b"
2145model = "paraphrase-multilingual-minilm-l12-v2"
2146default = true
2147"#,
2148 );
2149 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with two defaults");
2150 assert!(
2151 matches!(
2152 config_error_root(&err),
2153 ConfigError::DefaultCount { found: 2 }
2154 ),
2155 "expected DefaultCount {{ found: 2 }}, got {err:?}"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_fusion_weight_validation() {
2161 let dir = tempfile::tempdir().unwrap();
2162 let path = write_toml(
2163 &dir,
2164 r#"
2165[[engines]]
2166name = "a"
2167model = "all-minilm-l6-v2"
2168default = true
2169fusion_weight = -0.5
2170"#,
2171 );
2172 let err =
2173 KhiveConfig::load(Some(&path)).expect_err("should fail with negative fusion_weight");
2174 assert!(
2175 matches!(
2176 config_error_root(&err),
2177 ConfigError::InvalidFusionWeight { .. }
2178 ),
2179 "expected InvalidFusionWeight, got {err:?}"
2180 );
2181
2182 let path2 = write_toml(
2183 &dir,
2184 r#"
2185[[engines]]
2186name = "a"
2187model = "all-minilm-l6-v2"
2188default = true
2189fusion_weight = 0.0
2190"#,
2191 );
2192 let err2 =
2193 KhiveConfig::load(Some(&path2)).expect_err("should fail with zero fusion_weight");
2194 assert!(
2195 matches!(
2196 config_error_root(&err2),
2197 ConfigError::InvalidFusionWeight { .. }
2198 ),
2199 "expected InvalidFusionWeight, got {err2:?}"
2200 );
2201 }
2202
2203 #[test]
2204 fn test_env_var_fallback() {
2205 let dir = tempfile::tempdir().unwrap();
2206 let absent = dir.path().join("missing.toml");
2207
2208 let loaded = KhiveConfig::load(Some(&absent)).unwrap();
2209 assert!(loaded.is_none());
2210
2211 let primary = "all-minilm-l6-v2".to_string();
2214 let additional = vec!["paraphrase-multilingual-minilm-l12-v2".to_string()];
2215
2216 let mut engines = vec![EngineConfig {
2217 name: "default".to_string(),
2218 model: primary,
2219 default: true,
2220 fusion_weight: None,
2221 dims: None,
2222 }];
2223 for (i, model) in additional.into_iter().enumerate() {
2224 engines.push(EngineConfig {
2225 name: format!("engine-{}", i + 1),
2226 model,
2227 default: false,
2228 fusion_weight: None,
2229 dims: None,
2230 });
2231 }
2232 let cfg = KhiveConfig {
2233 engines,
2234 ..KhiveConfig::default()
2235 };
2236 cfg.validate().expect("env-derived config should be valid");
2237 assert_eq!(cfg.engines.len(), 2);
2238 assert!(cfg.default_engine().is_some());
2239 assert_eq!(cfg.default_engine().unwrap().name, "default");
2240 }
2241
2242 #[test]
2243 fn test_file_overrides_env() {
2244 let dir = tempfile::tempdir().unwrap();
2245 let path = write_toml(
2246 &dir,
2247 r#"
2248[[engines]]
2249name = "file-engine"
2250model = "all-minilm-l6-v2"
2251default = true
2252"#,
2253 );
2254
2255 let cfg = KhiveConfig::load(Some(&path))
2258 .expect("load should succeed")
2259 .expect("file should be present");
2260 assert_eq!(cfg.engines[0].name, "file-engine");
2261 }
2262
2263 #[test]
2264 fn test_duplicate_engine_names_rejected() {
2265 let dir = tempfile::tempdir().unwrap();
2266 let path = write_toml(
2267 &dir,
2268 r#"
2269[[engines]]
2270name = "shared"
2271model = "all-minilm-l6-v2"
2272default = true
2273
2274[[engines]]
2275name = "shared"
2276model = "paraphrase-multilingual-minilm-l12-v2"
2277"#,
2278 );
2279 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
2280 assert!(
2281 matches!(config_error_root(&err), ConfigError::DuplicateName { .. }),
2282 "expected DuplicateName, got {err:?}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_empty_config_is_valid() {
2288 let dir = tempfile::tempdir().unwrap();
2289 let path = write_toml(&dir, "# no engines\n");
2290 let cfg = KhiveConfig::load(Some(&path))
2291 .expect("load should succeed")
2292 .expect("file should be found");
2293 assert!(cfg.engines.is_empty());
2294 cfg.validate().expect("empty config should be valid");
2295 }
2296
2297 #[test]
2298 fn runtime_blob_hydration_budget_parses_and_resolves_before_engine_early_return() {
2299 use crate::runtime::runtime_config_from_khive_config;
2300 use crate::RuntimeConfig;
2301
2302 let dir = tempfile::tempdir().unwrap();
2303 let path = write_toml(
2304 &dir,
2305 r#"
2306[runtime]
2307blob_hydration_bytes = 134217728
2308"#,
2309 );
2310 let cfg = KhiveConfig::load(Some(&path))
2311 .expect("load should succeed")
2312 .expect("file should be found");
2313
2314 assert_eq!(cfg.runtime.blob_hydration_bytes, Some(134_217_728));
2315 let resolved = runtime_config_from_khive_config(&cfg, RuntimeConfig::default());
2316 assert_eq!(resolved.blob_hydration_bytes, 134_217_728);
2317 }
2318
2319 #[test]
2320 fn runtime_blob_hydration_budget_below_one_whole_blob_is_rejected() {
2321 let dir = tempfile::tempdir().unwrap();
2322 let value = khive_storage::MAX_BLOB_WHOLE_BYTES - 1;
2323 let path = write_toml(
2324 &dir,
2325 &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2326 );
2327
2328 let err = KhiveConfig::load(Some(&path)).expect_err("undersized budget must fail closed");
2329 assert!(
2330 matches!(
2331 config_error_root(&err),
2332 ConfigError::InvalidBlobHydrationBytes {
2333 value: actual,
2334 min,
2335 ..
2336 } if *actual == value && *min == khive_storage::MAX_BLOB_WHOLE_BYTES
2337 ),
2338 "got {err:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn runtime_blob_hydration_budget_accepts_the_inclusive_portable_minimum() {
2344 let dir = tempfile::tempdir().unwrap();
2345 let value = khive_storage::MAX_BLOB_WHOLE_BYTES;
2346 let path = write_toml(
2347 &dir,
2348 &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2349 );
2350
2351 let cfg = KhiveConfig::load(Some(&path))
2352 .expect("the inclusive minimum must be valid")
2353 .expect("config should exist");
2354 assert_eq!(cfg.runtime.blob_hydration_bytes, Some(value));
2355 }
2356
2357 #[test]
2358 fn runtime_blob_hydration_budget_above_semaphore_capacity_is_rejected() {
2359 let dir = tempfile::tempdir().unwrap();
2360 let max = tokio::sync::Semaphore::MAX_PERMITS as u64;
2361 let value = max
2362 .checked_add(1)
2363 .expect("tokio maximum fits below u64::MAX");
2364 let path = write_toml(
2365 &dir,
2366 &format!("[runtime]\nblob_hydration_bytes = {value}\n"),
2367 );
2368
2369 let err = KhiveConfig::load(Some(&path)).expect_err("oversized budget must fail closed");
2370 assert!(
2371 matches!(
2372 config_error_root(&err),
2373 ConfigError::InvalidBlobHydrationBytes {
2374 value: actual,
2375 max: actual_max,
2376 ..
2377 } if *actual == value && *actual_max == max
2378 ),
2379 "got {err:?}"
2380 );
2381 }
2382
2383 #[test]
2384 fn test_multi_engine_positive_fusion_weight() {
2385 let dir = tempfile::tempdir().unwrap();
2386 let path = write_toml(
2387 &dir,
2388 r#"
2389[[engines]]
2390name = "primary"
2391model = "all-minilm-l6-v2"
2392default = true
2393fusion_weight = 0.7
2394
2395[[engines]]
2396name = "secondary"
2397model = "paraphrase-multilingual-minilm-l12-v2"
2398fusion_weight = 0.3
2399"#,
2400 );
2401 let cfg = KhiveConfig::load(Some(&path))
2402 .expect("load should succeed")
2403 .expect("file should be found");
2404 assert_eq!(cfg.engines.len(), 2);
2405 assert_eq!(cfg.engines[0].fusion_weight, Some(0.7));
2406 assert_eq!(cfg.engines[1].fusion_weight, Some(0.3));
2407 }
2408
2409 #[test]
2410 fn test_actor_id_parsed() {
2411 let dir = tempfile::tempdir().unwrap();
2412 let path = write_toml(
2413 &dir,
2414 r#"
2415[actor]
2416id = "lambda:khive"
2417display_name = "example actor"
2418"#,
2419 );
2420 let cfg = KhiveConfig::load(Some(&path))
2421 .expect("load should succeed")
2422 .expect("file should be found");
2423 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:khive"));
2424 assert_eq!(cfg.actor.display_name.as_deref(), Some("example actor"));
2425 assert!(cfg.engines.is_empty());
2426 }
2427
2428 #[test]
2429 fn gate_mailbox_reader_config_loads_exact_labels_and_rejects_bad_policy() {
2430 let dir = tempfile::tempdir().unwrap();
2431 let path = write_toml(
2432 &dir,
2433 "[actor]\nid = \"lambda:owner\"\nmailbox_readers = [\"lambda:helper\", \"助手/审阅者\", \"lambda:helper\"]\n",
2434 );
2435 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
2436 assert_eq!(
2437 config.actor.mailbox_readers,
2438 ["lambda:helper", "助手/审阅者", "lambda:helper"]
2439 );
2440
2441 for (owner, readers) in [
2442 (None, vec!["reader".to_string()]),
2443 (Some("local"), vec!["reader".to_string()]),
2444 (Some("lambda:owner"), vec!["local".to_string()]),
2445 (Some("lambda:owner"), vec![String::new()]),
2446 (Some("lambda:owner"), vec![" \t".to_string()]),
2447 (Some("lambda:owner"), vec!["bad\nactor".to_string()]),
2448 (Some("lambda:owner"), vec!["x".repeat(256)]),
2449 (Some("lambda:owner"), vec!["reader".to_string(); 257]),
2450 ] {
2451 let config = KhiveConfig {
2452 actor: ActorConfig {
2453 id: owner.map(str::to_string),
2454 mailbox_readers: readers,
2455 ..Default::default()
2456 },
2457 ..Default::default()
2458 };
2459 assert!(matches!(
2460 config.validate(),
2461 Err(ConfigError::InvalidMailboxReaders { .. })
2462 ));
2463 }
2464 for source in [
2465 "[actor]\nmailbox_readers = [\"reader\"]\n",
2466 "[actor]\nid = \"local\"\nmailbox_readers = [\"reader\"]\n",
2467 "[actor]\nid = \"lambda:owner\"\nmailbox_readers = [\"\"]\n",
2468 "[actor]\nid = \"lambda:owner\"\nmailbox_readers = \"reader\"\n",
2469 ] {
2470 let path = write_toml(&dir, source);
2471 assert!(KhiveConfig::load(Some(&path)).is_err());
2472 }
2473 let boundary = KhiveConfig {
2474 actor: ActorConfig {
2475 id: Some("lambda:owner".into()),
2476 mailbox_readers: vec!["x".repeat(255); 256],
2477 ..Default::default()
2478 },
2479 ..Default::default()
2480 };
2481 boundary.validate().unwrap();
2482 KhiveConfig::default().validate().unwrap();
2483 }
2484
2485 #[test]
2486 fn test_actor_and_engines_together() {
2487 let dir = tempfile::tempdir().unwrap();
2488 let path = write_toml(
2489 &dir,
2490 r#"
2491[actor]
2492id = "lambda:test"
2493
2494[[engines]]
2495name = "default"
2496model = "all-minilm-l6-v2"
2497default = true
2498"#,
2499 );
2500 let cfg = KhiveConfig::load(Some(&path))
2501 .expect("load should succeed")
2502 .expect("file should be found");
2503 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:test"));
2504 assert_eq!(cfg.engines.len(), 1);
2505 }
2506
2507 #[test]
2508 fn test_actor_absent_defaults_to_none() {
2509 let dir = tempfile::tempdir().unwrap();
2510 let path = write_toml(
2511 &dir,
2512 r#"
2513[[engines]]
2514name = "x"
2515model = "all-minilm-l6-v2"
2516default = true
2517"#,
2518 );
2519 let cfg = KhiveConfig::load(Some(&path))
2520 .expect("load should succeed")
2521 .expect("file should be found");
2522 assert!(
2523 cfg.actor.id.is_none(),
2524 "actor.id must be None when [actor] section is absent"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_load_with_home_fallback_no_files() {
2530 let project_dir = tempfile::tempdir().unwrap();
2531 let home_dir = tempfile::tempdir().unwrap();
2532 let result = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None);
2533 assert!(
2534 result.expect("no error expected").is_none(),
2535 "should return None when no config files exist in the given roots"
2536 );
2537 }
2538
2539 #[test]
2540 fn home_gate_config_loads_while_explicit_empty_config_is_hermetic() {
2541 let project_dir = tempfile::tempdir().unwrap();
2542 let home_dir = tempfile::tempdir().unwrap();
2543 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2544 std::fs::write(
2545 home_dir.path().join(".khive/config.toml"),
2546 "[gate]\ngranted_actors = [\"lambda:enrolled\"]\ndeny_writes_for = [\"*:duty\"]\n",
2547 )
2548 .unwrap();
2549
2550 let loaded = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2551 .expect("supported home gate policy loads")
2552 .expect("home config exists");
2553 let gate = loaded.gate.expect("gate table");
2554 assert_eq!(gate.granted_actors, vec!["lambda:enrolled"]);
2555 assert_eq!(gate.deny_writes_for, vec!["*:duty"]);
2556
2557 let empty = project_dir.path().join("empty-khive-config.toml");
2558 std::fs::write(&empty, "").unwrap();
2559 let isolated = KhiveConfig::load_with_home_fallback(Some(&empty), None)
2560 .expect("an explicit empty fixture must isolate config discovery")
2561 .expect("the explicit config exists");
2562 assert!(isolated.engines.is_empty());
2563 assert!(isolated.actor.id.is_none());
2564 assert!(isolated.gate.is_none());
2565 }
2566
2567 #[test]
2568 fn test_load_with_home_fallback_explicit_path() {
2569 let dir = tempfile::tempdir().unwrap();
2570 let path = write_toml(
2571 &dir,
2572 r#"
2573[actor]
2574id = "lambda:explicit"
2575"#,
2576 );
2577 let cfg = KhiveConfig::load_with_home_fallback(Some(&path), None)
2578 .expect("no error expected")
2579 .expect("file found");
2580 assert_eq!(cfg.actor.id.as_deref(), Some("lambda:explicit"));
2581 }
2582
2583 #[test]
2584 fn load_with_home_fallback_and_source_names_selected_file() {
2585 let project_dir = tempfile::tempdir().unwrap();
2586 let home_dir = tempfile::tempdir().unwrap();
2587 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2588 let selected = home_dir.path().join(".khive/config.toml");
2589 std::fs::write(&selected, "[actor]\nid = \"lambda:home\"\n").unwrap();
2590
2591 let (config, source) = KhiveConfig::load_with_roots_and_source(
2592 project_dir.path(),
2593 Some(home_dir.path()),
2594 None,
2595 )
2596 .expect("load should succeed")
2597 .expect("home fallback should be selected");
2598
2599 assert_eq!(config.actor.id.as_deref(), Some("lambda:home"));
2600 assert_eq!(
2601 source,
2602 std::fs::canonicalize(selected).expect("canonical selected config path")
2603 );
2604 }
2605
2606 #[test]
2607 fn test_invalid_actor_id_rejected_at_load() {
2608 let dir = tempfile::tempdir().unwrap();
2609 let path = write_toml(
2610 &dir,
2611 r#"
2612[actor]
2613id = "bad namespace"
2614"#,
2615 );
2616 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with invalid actor.id");
2617 assert!(
2618 matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2619 "expected InvalidActorId, got {err:?}"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_empty_actor_id_rejected() {
2625 let dir = tempfile::tempdir().unwrap();
2626 let path = write_toml(
2627 &dir,
2628 r#"
2629[actor]
2630id = ""
2631"#,
2632 );
2633 let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id should be rejected");
2634 assert!(
2635 matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2636 "expected InvalidActorId for empty string, got {err:?}"
2637 );
2638 }
2639
2640 #[test]
2641 fn test_malformed_actor_id_lambda_colon_only() {
2642 let dir = tempfile::tempdir().unwrap();
2643 let path = write_toml(
2644 &dir,
2645 r#"
2646[actor]
2647id = "lambda:"
2648"#,
2649 );
2650 let err =
2651 KhiveConfig::load(Some(&path)).expect_err("lambda: with no slug should be rejected");
2652 assert!(
2653 matches!(config_error_root(&err), ConfigError::InvalidActorId { .. }),
2654 "expected InvalidActorId for 'lambda:', got {err:?}"
2655 );
2656 }
2657
2658 #[test]
2661 fn test_runtime_config_actor_id_does_not_override_namespace() {
2662 use crate::runtime::runtime_config_from_khive_config;
2663 use crate::RuntimeConfig;
2664 use khive_types::namespace::Namespace;
2665
2666 let cfg = KhiveConfig {
2667 engines: vec![],
2668 actor: ActorConfig {
2669 id: Some("lambda:test-actor".to_string()),
2670 display_name: None,
2671 ..Default::default()
2672 },
2673 ..KhiveConfig::default()
2674 };
2675 cfg.validate().expect("valid config");
2676
2677 let base = RuntimeConfig::default();
2678 let result = runtime_config_from_khive_config(&cfg, base);
2679 assert_eq!(
2680 result.default_namespace,
2681 Namespace::local(),
2682 "actor.id must NOT become default_namespace (ADR-007 Rev 4 Rule 0); \
2683 writes stay pinned to local"
2684 );
2685 assert!(
2688 result
2689 .visible_namespaces
2690 .contains(&Namespace::parse("lambda:test-actor").unwrap()),
2691 "actor.id must be folded into visible_namespaces (ADR-007 Rev 4 Rule 3b fold-in); \
2692 got: {:?}",
2693 result.visible_namespaces
2694 );
2695 }
2696
2697 #[test]
2698 fn test_runtime_config_no_actor_preserves_base() {
2699 use crate::runtime::runtime_config_from_khive_config;
2700 use crate::RuntimeConfig;
2701 use khive_types::namespace::Namespace;
2702
2703 let cfg = KhiveConfig {
2704 engines: vec![],
2705 actor: ActorConfig {
2706 id: None,
2707 display_name: None,
2708 ..Default::default()
2709 },
2710 ..KhiveConfig::default()
2711 };
2712 cfg.validate().expect("valid config");
2713
2714 let base_ns = Namespace::parse("lambda:base").unwrap();
2715 let base = RuntimeConfig {
2716 default_namespace: base_ns.clone(),
2717 ..RuntimeConfig::default()
2718 };
2719 let result = runtime_config_from_khive_config(&cfg, base);
2720 assert_eq!(
2721 result.default_namespace, base_ns,
2722 "no actor.id must leave base namespace unchanged"
2723 );
2724 }
2725
2726 #[test]
2727 fn test_load_with_home_fallback_project_root_over_hidden() {
2728 let dir = tempfile::tempdir().unwrap();
2729
2730 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
2732 std::fs::write(
2733 dir.path().join(".khive/config.toml"),
2734 "[actor]\nid = \"lambda:hidden\"\n",
2735 )
2736 .unwrap();
2737
2738 std::fs::write(
2740 dir.path().join("khive.toml"),
2741 "[actor]\nid = \"lambda:project-root\"\n",
2742 )
2743 .unwrap();
2744
2745 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
2746 .expect("no error expected")
2747 .expect("file should be found");
2748 assert_eq!(
2749 cfg.actor.id.as_deref(),
2750 Some("lambda:project-root"),
2751 "khive.toml (tier 2) must win over .khive/config.toml (tier 3)"
2752 );
2753 }
2754
2755 #[test]
2756 fn test_load_with_home_fallback_hidden_over_absent_root() {
2757 let dir = tempfile::tempdir().unwrap();
2758
2759 std::fs::create_dir_all(dir.path().join(".khive")).unwrap();
2760 std::fs::write(
2761 dir.path().join(".khive/config.toml"),
2762 "[actor]\nid = \"lambda:hidden-config\"\n",
2763 )
2764 .unwrap();
2765 let cfg = KhiveConfig::load_with_roots(dir.path(), None, None)
2768 .expect("no error expected")
2769 .expect("file should be found");
2770 assert_eq!(
2771 cfg.actor.id.as_deref(),
2772 Some("lambda:hidden-config"),
2773 ".khive/config.toml (tier 3) must be found when khive.toml is absent"
2774 );
2775 }
2776
2777 #[test]
2778 fn test_load_with_roots_home_tier_found() {
2779 let project_dir = tempfile::tempdir().unwrap();
2780 let home_dir = tempfile::tempdir().unwrap();
2781
2782 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2783 std::fs::write(
2784 home_dir.path().join(".khive/config.toml"),
2785 "[actor]\nid = \"lambda:user-global\"\n",
2786 )
2787 .unwrap();
2788 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2791 .expect("no error expected")
2792 .expect("file should be found");
2793 assert_eq!(
2794 cfg.actor.id.as_deref(),
2795 Some("lambda:user-global"),
2796 "~/.khive/config.toml (tier 4) must be found when project files absent"
2797 );
2798 }
2799
2800 #[test]
2801 fn test_load_with_roots_project_wins_over_home() {
2802 let project_dir = tempfile::tempdir().unwrap();
2803 let home_dir = tempfile::tempdir().unwrap();
2804
2805 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2807 std::fs::write(
2808 home_dir.path().join(".khive/config.toml"),
2809 "[actor]\nid = \"lambda:user-global\"\n",
2810 )
2811 .unwrap();
2812
2813 std::fs::create_dir_all(project_dir.path().join(".khive")).unwrap();
2815 std::fs::write(
2816 project_dir.path().join(".khive/config.toml"),
2817 "[actor]\nid = \"lambda:project-wins\"\n",
2818 )
2819 .unwrap();
2820
2821 let cfg = KhiveConfig::load_with_roots(project_dir.path(), Some(home_dir.path()), None)
2822 .expect("no error expected")
2823 .expect("file should be found");
2824 assert_eq!(
2825 cfg.actor.id.as_deref(),
2826 Some("lambda:project-wins"),
2827 "project .khive/config.toml (tier 3) must win over ~/.khive/config.toml (tier 4)"
2828 );
2829 }
2830
2831 #[test]
2839 fn test_load_with_roots_same_db_different_cwd_resolves_identical_config() {
2840 let cwd_a = tempfile::tempdir().unwrap();
2841 let cwd_b = tempfile::tempdir().unwrap();
2842
2843 std::fs::create_dir_all(cwd_a.path().join(".khive")).unwrap();
2846 std::fs::write(
2847 cwd_a.path().join(".khive/config.toml"),
2848 "[actor]\nid = \"lambda:wrong-cwd-a\"\n",
2849 )
2850 .unwrap();
2851 std::fs::create_dir_all(cwd_b.path().join(".khive")).unwrap();
2852 std::fs::write(
2853 cwd_b.path().join(".khive/config.toml"),
2854 "[actor]\nid = \"lambda:wrong-cwd-b\"\n",
2855 )
2856 .unwrap();
2857
2858 let db_root = tempfile::tempdir().unwrap();
2861 let khive_dir = db_root.path().join(".khive");
2862 std::fs::create_dir_all(&khive_dir).unwrap();
2863 let db_path = khive_dir.join("khive.db");
2864 std::fs::write(&db_path, b"").unwrap(); std::fs::write(
2866 khive_dir.join("config.toml"),
2867 "[actor]\nid = \"lambda:db-anchored\"\n",
2868 )
2869 .unwrap();
2870
2871 let cfg_a = KhiveConfig::load_with_roots(cwd_a.path(), None, Some(&db_path))
2872 .expect("no error expected")
2873 .expect("db-anchored config must be found from cwd A");
2874 let cfg_b = KhiveConfig::load_with_roots(cwd_b.path(), None, Some(&db_path))
2875 .expect("no error expected")
2876 .expect("db-anchored config must be found from cwd B");
2877
2878 assert_eq!(
2879 cfg_a.actor.id.as_deref(),
2880 Some("lambda:db-anchored"),
2881 "cwd A must resolve the db-anchored config, not its own decoy"
2882 );
2883 assert_eq!(
2884 cfg_b.actor.id.as_deref(),
2885 Some("lambda:db-anchored"),
2886 "cwd B must resolve the db-anchored config, not its own decoy"
2887 );
2888 assert_eq!(
2889 cfg_a.actor.id, cfg_b.actor.id,
2890 "two processes at different cwds targeting the same db must resolve \
2891 identical config, killing config_id drift between client and daemon"
2892 );
2893 }
2894
2895 #[test]
2899 fn test_load_with_home_fallback_explicit_config_wins_over_db_anchor() {
2900 let explicit_dir = tempfile::tempdir().unwrap();
2901 let explicit_path = write_toml(&explicit_dir, "[actor]\nid = \"lambda:explicit-wins\"\n");
2902
2903 let db_root = tempfile::tempdir().unwrap();
2904 let khive_dir = db_root.path().join(".khive");
2905 std::fs::create_dir_all(&khive_dir).unwrap();
2906 let db_path = khive_dir.join("khive.db");
2907 std::fs::write(&db_path, b"").unwrap();
2908 std::fs::write(
2909 khive_dir.join("config.toml"),
2910 "[actor]\nid = \"lambda:db-anchor-loses\"\n",
2911 )
2912 .unwrap();
2913
2914 let cfg = KhiveConfig::load_with_home_fallback(Some(&explicit_path), Some(&db_path))
2915 .expect("no error expected")
2916 .expect("explicit path must be found");
2917 assert_eq!(
2918 cfg.actor.id.as_deref(),
2919 Some("lambda:explicit-wins"),
2920 "explicit --config/KHIVE_CONFIG must win over the db-dir anchor"
2921 );
2922 }
2923
2924 #[test]
2927 fn test_load_with_roots_home_fallback_reached_when_db_anchor_has_no_config() {
2928 let cwd = tempfile::tempdir().unwrap();
2929 let home_dir = tempfile::tempdir().unwrap();
2930 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2931 std::fs::write(
2932 home_dir.path().join(".khive/config.toml"),
2933 "[actor]\nid = \"lambda:home-fallback\"\n",
2934 )
2935 .unwrap();
2936
2937 let db_root = tempfile::tempdir().unwrap();
2939 let khive_dir = db_root.path().join(".khive");
2940 std::fs::create_dir_all(&khive_dir).unwrap();
2941 let db_path = khive_dir.join("khive.db");
2942 std::fs::write(&db_path, b"").unwrap();
2943
2944 let cfg = KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&db_path))
2945 .expect("no error expected")
2946 .expect("home-tier config must be found");
2947 assert_eq!(
2948 cfg.actor.id.as_deref(),
2949 Some("lambda:home-fallback"),
2950 "tier 4 (~/.khive/config.toml) must still be reached when the db-anchored \
2951 tier-3 directory has no config.toml"
2952 );
2953 }
2954
2955 #[test]
2958 fn test_load_with_roots_nonexistent_db_path_does_not_panic_and_falls_through() {
2959 let cwd = tempfile::tempdir().unwrap();
2960 let home_dir = tempfile::tempdir().unwrap();
2961 std::fs::create_dir_all(home_dir.path().join(".khive")).unwrap();
2962 std::fs::write(
2963 home_dir.path().join(".khive/config.toml"),
2964 "[actor]\nid = \"lambda:home-cold-start\"\n",
2965 )
2966 .unwrap();
2967
2968 let nonexistent_db = cwd.path().join("never-created/.khive/khive.db");
2970
2971 let cfg =
2972 KhiveConfig::load_with_roots(cwd.path(), Some(home_dir.path()), Some(&nonexistent_db))
2973 .expect("cold-start db path must not error or panic")
2974 .expect("home-tier config must still be found");
2975 assert_eq!(
2976 cfg.actor.id.as_deref(),
2977 Some("lambda:home-cold-start"),
2978 "a nonexistent db path (cold start) must fall through to tier 4, not panic"
2979 );
2980 }
2981
2982 #[test]
2987 fn test_load_with_roots_relative_nonexistent_db_path_does_not_panic() {
2988 let cwd = tempfile::tempdir().unwrap();
2989 let relative_db = PathBuf::from("never-created/.khive/khive.db");
2990
2991 let result = KhiveConfig::load_with_roots(cwd.path(), None, Some(&relative_db));
2992 assert!(
2993 result.is_ok(),
2994 "relative cold-start db path must not error or panic: {result:?}"
2995 );
2996 assert!(
2997 result.unwrap().is_none(),
2998 "no config exists anywhere in this test; result must be None"
2999 );
3000 }
3001
3002 #[test]
3005 fn test_no_backends_section_is_valid() {
3006 let dir = tempfile::tempdir().unwrap();
3007 let path = write_toml(
3008 &dir,
3009 r#"
3010[[engines]]
3011name = "default"
3012model = "all-minilm-l6-v2"
3013default = true
3014"#,
3015 );
3016 let cfg = KhiveConfig::load(Some(&path))
3017 .expect("no error")
3018 .expect("file found");
3019 assert!(cfg.backends.is_empty());
3020 assert!(cfg.packs.is_empty());
3021 }
3022
3023 #[test]
3024 fn test_single_sqlite_backend_parses() {
3025 let dir = tempfile::tempdir().unwrap();
3026 let path = write_toml(
3027 &dir,
3028 r#"
3029[[backends]]
3030name = "knowledge"
3031kind = "sqlite"
3032path = "/tmp/knowledge.db"
3033"#,
3034 );
3035 let cfg = KhiveConfig::load(Some(&path))
3036 .expect("no error")
3037 .expect("file found");
3038 assert_eq!(cfg.backends.len(), 1);
3039 let b = &cfg.backends[0];
3040 assert_eq!(b.name, "knowledge");
3041 assert!(matches!(b.kind, BackendKind::Sqlite));
3042 assert_eq!(
3043 b.path.as_ref().and_then(|p| p.to_str()),
3044 Some("/tmp/knowledge.db")
3045 );
3046 }
3047
3048 #[test]
3049 fn test_memory_backend_parses() {
3050 let dir = tempfile::tempdir().unwrap();
3051 let path = write_toml(
3052 &dir,
3053 r#"
3054[[backends]]
3055name = "ephemeral"
3056kind = "memory"
3057"#,
3058 );
3059 let cfg = KhiveConfig::load(Some(&path))
3060 .expect("no error")
3061 .expect("file found");
3062 assert_eq!(cfg.backends.len(), 1);
3063 assert!(matches!(cfg.backends[0].kind, BackendKind::Memory));
3064 }
3065
3066 #[test]
3067 fn test_pack_backend_assignment_parses() {
3068 let dir = tempfile::tempdir().unwrap();
3069 let path = write_toml(
3070 &dir,
3071 r#"
3072[[backends]]
3073name = "knowledge"
3074kind = "memory"
3075
3076[packs.knowledge]
3077backend = "knowledge"
3078"#,
3079 );
3080 let cfg = KhiveConfig::load(Some(&path))
3081 .expect("no error")
3082 .expect("file found");
3083 assert_eq!(cfg.packs.len(), 1);
3084 let pc = cfg.packs.get("knowledge").expect("knowledge pack present");
3085 assert_eq!(pc.backend, "knowledge");
3086 }
3087
3088 #[test]
3089 fn test_duplicate_backend_name_rejected() {
3090 let dir = tempfile::tempdir().unwrap();
3091 let path = write_toml(
3092 &dir,
3093 r#"
3094[[backends]]
3095name = "dup"
3096kind = "memory"
3097
3098[[backends]]
3099name = "dup"
3100kind = "memory"
3101"#,
3102 );
3103 let err = KhiveConfig::load(Some(&path)).expect_err("should fail with duplicate name");
3104 assert!(
3105 matches!(config_error_root(&err), ConfigError::DuplicateBackendName { ref name } if name == "dup"),
3106 "expected DuplicateBackendName {{ name: \"dup\" }}, got {err:?}"
3107 );
3108 }
3109
3110 #[test]
3111 fn test_empty_backend_name_rejected() {
3112 let dir = tempfile::tempdir().unwrap();
3113 let path = write_toml(
3114 &dir,
3115 r#"
3116[[backends]]
3117name = ""
3118kind = "memory"
3119"#,
3120 );
3121 let err = KhiveConfig::load(Some(&path)).expect_err("empty backend name must fail");
3122 assert!(
3123 matches!(config_error_root(&err), ConfigError::InvalidBackendName { ref name, .. } if name.is_empty()),
3124 "expected InvalidBackendName for the empty name, got {err:?}"
3125 );
3126 }
3127
3128 #[test]
3129 fn test_backend_served_kinds_absent_and_declared() {
3130 let dir = tempfile::tempdir().unwrap();
3131 let path = write_toml(
3132 &dir,
3133 r#"
3134[[backends]]
3135name = "legacy"
3136kind = "memory"
3137
3138[[backends]]
3139name = "notes"
3140kind = "memory"
3141served_kinds = ["note", "event"]
3142"#,
3143 );
3144 let config = KhiveConfig::load(Some(&path))
3145 .expect("valid served-kind declarations")
3146 .expect("config file found");
3147
3148 assert!(config.backends[0].served_kinds.is_none());
3149 assert_eq!(
3150 config.backends[1].served_kinds,
3151 Some(BTreeSet::from([SubstrateKind::Note, SubstrateKind::Event]))
3152 );
3153 }
3154
3155 #[test]
3156 fn test_empty_backend_served_kinds_rejected() {
3157 let dir = tempfile::tempdir().unwrap();
3158 let path = write_toml(
3159 &dir,
3160 r#"
3161[[backends]]
3162name = "main"
3163kind = "memory"
3164served_kinds = []
3165"#,
3166 );
3167 let error = KhiveConfig::load(Some(&path))
3168 .expect_err("an explicit empty served-kind declaration must fail closed");
3169
3170 assert!(matches!(
3171 config_error_root(&error),
3172 ConfigError::EmptyBackendServedKinds { name } if name == "main"
3173 ));
3174 }
3175
3176 #[test]
3177 fn test_unknown_backend_served_kind_rejected() {
3178 let dir = tempfile::tempdir().unwrap();
3179 let path = write_toml(
3180 &dir,
3181 r#"
3182[[backends]]
3183name = "main"
3184kind = "memory"
3185served_kinds = ["asset"]
3186"#,
3187 );
3188 let error = KhiveConfig::load(Some(&path))
3189 .expect_err("served-kind declarations use a closed vocabulary");
3190
3191 assert!(matches!(
3192 config_error_root(&error),
3193 ConfigError::Parse { .. }
3194 ));
3195 assert!(error.to_string().contains("unknown variant `asset`"));
3196 }
3197
3198 #[test]
3199 fn test_pack_referencing_undefined_backend_rejected() {
3200 let dir = tempfile::tempdir().unwrap();
3201 let path = write_toml(
3202 &dir,
3203 r#"
3204[[backends]]
3205name = "knowledge"
3206kind = "memory"
3207
3208[packs.kg]
3209backend = "nonexistent"
3210"#,
3211 );
3212 let err =
3213 KhiveConfig::load(Some(&path)).expect_err("should fail with unknown backend reference");
3214 assert!(
3215 matches!(config_error_root(&err), ConfigError::UnknownPackBackend { ref pack, ref backend, .. }
3216 if pack == "kg" && backend == "nonexistent"),
3217 "expected UnknownPackBackend for kg→nonexistent, got {err:?}"
3218 );
3219 }
3220
3221 #[test]
3222 fn test_pack_config_without_backends_section_is_allowed() {
3223 let dir = tempfile::tempdir().unwrap();
3224 let path = write_toml(
3226 &dir,
3227 r#"
3228[packs.kg]
3229backend = "main"
3230"#,
3231 );
3232 let cfg = KhiveConfig::load(Some(&path))
3233 .expect("no error expected")
3234 .expect("file found");
3235 assert_eq!(cfg.backends.len(), 0);
3236 assert_eq!(cfg.packs.len(), 1);
3237 }
3238
3239 #[test]
3240 fn test_implicit_main_rejects_unknown_pack_backend() {
3241 let dir = tempfile::tempdir().unwrap();
3242 let path = write_toml(&dir, "[packs.comm]\nbackend = 'does-not-exist'\n");
3243 let error = KhiveConfig::load(Some(&path)).expect_err("unknown route must fail");
3244 assert!(matches!(
3245 config_error_root(&error),
3246 ConfigError::UnknownPackBackend { pack, backend, defined }
3247 if pack == "comm" && backend == "does-not-exist" && defined == "main"
3248 ));
3249 }
3250
3251 #[test]
3252 fn test_backend_search_coverage_rejects_missing_substrates() {
3253 for (served, missing) in [
3254 ("'note'", vec![SubstrateKind::Entity]),
3255 ("'entity'", vec![SubstrateKind::Note]),
3256 ("'event'", vec![SubstrateKind::Note, SubstrateKind::Entity]),
3257 ] {
3258 let dir = tempfile::tempdir().unwrap();
3259 let path = write_toml(
3260 &dir,
3261 &format!(
3262 "[[backends]]\nname = 'main'\nkind = 'memory'\nserved_kinds = [{served}]\n"
3263 ),
3264 );
3265 let error = KhiveConfig::load(Some(&path)).expect_err("incomplete coverage");
3266 assert!(
3267 matches!(
3268 config_error_root(&error),
3269 ConfigError::MissingBackendSearchKinds { kinds, defined }
3270 if kinds == &missing && defined == "main"
3271 ),
3272 "unexpected coverage error: {error}"
3273 );
3274 }
3275 }
3276
3277 #[test]
3278 fn test_backend_search_coverage_allows_split_substrates_and_event_only_secondary() {
3279 let dir = tempfile::tempdir().unwrap();
3280 let path = write_toml(
3281 &dir,
3282 r#"
3283[[backends]]
3284name = "main"
3285kind = "memory"
3286served_kinds = ["entity"]
3287
3288[[backends]]
3289name = "notes"
3290kind = "memory"
3291served_kinds = ["note"]
3292
3293[[backends]]
3294name = "events"
3295kind = "memory"
3296served_kinds = ["event"]
3297"#,
3298 );
3299 KhiveConfig::load(Some(&path)).expect("search coverage is the union of backends");
3300 }
3301
3302 #[test]
3303 fn test_backend_cache_mb_rejected_at_validate() {
3304 let dir = tempfile::tempdir().unwrap();
3305 let path = write_toml(
3306 &dir,
3307 r#"
3308[[backends]]
3309name = "main"
3310kind = "memory"
3311cache_mb = 128
3312"#,
3313 );
3314 let err = KhiveConfig::load(Some(&path)).expect_err("cache_mb must be rejected");
3315 assert!(
3316 matches!(config_error_root(&err), ConfigError::UnsupportedBackendField { ref name, field: "cache_mb" } if name == "main"),
3317 "expected UnsupportedBackendField {{ name: \"main\", field: \"cache_mb\" }}, got {err:?}"
3318 );
3319 }
3320
3321 #[test]
3322 fn test_backend_journal_mode_rejected_at_validate() {
3323 let dir = tempfile::tempdir().unwrap();
3324 let path = write_toml(
3325 &dir,
3326 r#"
3327[[backends]]
3328name = "main"
3329kind = "memory"
3330journal_mode = "wal"
3331"#,
3332 );
3333 let err = KhiveConfig::load(Some(&path)).expect_err("journal_mode must be rejected");
3334 assert!(
3335 matches!(config_error_root(&err), ConfigError::UnsupportedBackendField { ref name, field: "journal_mode" } if name == "main"),
3336 "expected UnsupportedBackendField {{ name: \"main\", field: \"journal_mode\" }}, got {err:?}"
3337 );
3338 }
3339
3340 #[test]
3343 fn test_top_level_db_rejected_at_validate() {
3344 let dir = tempfile::tempdir().unwrap();
3345 let path = write_toml(
3346 &dir,
3347 r#"
3348db = "/tmp/scratch/demo.db"
3349"#,
3350 );
3351 let err = KhiveConfig::load(Some(&path)).expect_err("top-level db must be rejected");
3352 assert!(
3353 matches!(config_error_root(&err), ConfigError::UnsupportedTopLevelDb { ref value } if value == "/tmp/scratch/demo.db"),
3354 "expected UnsupportedTopLevelDb {{ value: \"/tmp/scratch/demo.db\" }}, got {err:?}"
3355 );
3356 }
3357
3358 #[test]
3359 fn gate_caller_enrollment_config_loads_for_runtime_enforcement() {
3360 let dir = tempfile::tempdir().unwrap();
3361 let path = write_toml(
3362 &dir,
3363 r#"
3364[gate]
3365granted_actors = ["lambda:enrolled"]
3366grant_unattributed = false
3367"#,
3368 );
3369
3370 let config = KhiveConfig::load(Some(&path))
3371 .expect("the supported caller-enrollment policy must parse")
3372 .expect("config exists");
3373 let gate = config.gate.expect("gate section");
3374 assert_eq!(gate.granted_actors, vec!["lambda:enrolled"]);
3375 assert!(!gate.grant_unattributed);
3376 }
3377
3378 #[test]
3379 fn unknown_actor_key_fails_to_load() {
3380 let dir = tempfile::tempdir().unwrap();
3381
3382 let supported = write_toml(
3386 &dir,
3387 r#"
3388[actor]
3389id = "lambda:example"
3390visible_namespaces = ["lambda:other"]
3391"#,
3392 );
3393 KhiveConfig::load(Some(&supported))
3394 .expect("a config using only supported [actor] keys must parse")
3395 .expect("config exists");
3396
3397 let misplaced = write_toml(
3401 &dir,
3402 r#"
3403[actor]
3404id = "lambda:example"
3405grant_unattributed = false
3406"#,
3407 );
3408 let err = KhiveConfig::load(Some(&misplaced))
3409 .expect_err("a [gate] key written under [actor] must fail startup");
3410 assert!(
3411 err.to_string().contains("grant_unattributed"),
3412 "the refusal must name the offending key, got: {err}"
3413 );
3414 }
3415
3416 #[test]
3417 fn caller_enrollment_policy_is_enforced_at_authorization() {
3418 let dir = tempfile::tempdir().unwrap();
3419 let path = write_toml(
3420 &dir,
3421 r#"
3422[actor]
3423id = "lambda:enrolled"
3424
3425[gate]
3426granted_actors = ["lambda:enrolled"]
3427grant_unattributed = false
3428"#,
3429 );
3430 let mut config = KhiveConfig::load(Some(&path))
3431 .expect("load")
3432 .expect("config exists");
3433 let allowed = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3434 let runtime = crate::KhiveRuntime::new(allowed).expect("runtime");
3435 runtime
3436 .authorize(Namespace::local())
3437 .expect("listed actor is admitted");
3438
3439 config.actor.id = Some("lambda:other".to_string());
3440 let denied = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3441 let runtime = crate::KhiveRuntime::new(denied).expect("runtime");
3442 assert!(matches!(
3443 runtime.authorize(Namespace::local()),
3444 Err(crate::RuntimeError::PermissionDenied { ref verb, ref reason, .. })
3445 if verb == "authorize" && reason == "actor is not enrolled"
3446 ));
3447 }
3448
3449 #[test]
3450 fn grant_unattributed_controls_anonymous_authorization() {
3451 let dir = tempfile::tempdir().unwrap();
3452 let path = write_toml(&dir, "[gate]\ngrant_unattributed = false\n");
3453 let mut config = KhiveConfig::load(Some(&path))
3454 .expect("load")
3455 .expect("config exists");
3456 let denied = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3457 let runtime = crate::KhiveRuntime::new(denied).expect("runtime");
3458 assert!(matches!(
3459 runtime.authorize(Namespace::local()),
3460 Err(crate::RuntimeError::PermissionDenied { ref reason, .. })
3461 if reason == "unattributed caller is not enrolled"
3462 ));
3463
3464 config.gate.as_mut().expect("gate").grant_unattributed = true;
3465 let allowed = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3466 crate::KhiveRuntime::new(allowed)
3467 .expect("runtime")
3468 .authorize(Namespace::local())
3469 .expect("anonymous caller is explicitly admitted");
3470 }
3471
3472 #[test]
3473 fn empty_gate_table_is_explicit_deny_all_policy() {
3474 let dir = tempfile::tempdir().unwrap();
3475 let path = write_toml(&dir, "[gate]\n");
3476 let config = KhiveConfig::load(Some(&path))
3477 .expect("empty gate table parses")
3478 .expect("config exists");
3479 assert_eq!(config.gate, Some(GateSectionConfig::default()));
3480 let runtime_config =
3481 crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3482 let runtime = crate::KhiveRuntime::new(runtime_config).expect("runtime");
3483 assert!(matches!(
3484 runtime.authorize(Namespace::local()),
3485 Err(crate::RuntimeError::PermissionDenied { .. })
3486 ));
3487 }
3488
3489 #[test]
3490 fn unknown_gate_key_fails_startup() {
3491 let dir = tempfile::tempdir().unwrap();
3492 let path = write_toml(&dir, "[gate]\ngranted_actor = [\"lambda:typo\"]\n");
3493 let err = KhiveConfig::load(Some(&path)).expect_err("unknown gate key must fail");
3494 assert!(matches!(err, ConfigError::Parse { .. }));
3495 assert!(err.to_string().contains("unknown field"), "{err}");
3496 }
3497
3498 #[test]
3499 fn write_denials_survive_both_runtime_config_paths() {
3500 let dir = tempfile::tempdir().unwrap();
3501 for engines in [
3502 "",
3503 "\n[[engines]]\nname = 'main'\nmodel = 'all-minilm-l6-v2'\ndefault = true\n",
3504 ] {
3505 let path = write_toml(&dir, &format!(
3506 "[actor]\nid='seat:duty'\n[gate]\ngranted_actors=['seat:duty','seat:writer']\ndeny_writes_for=['*:duty']\n{engines}"
3507 ));
3508 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3509 let runtime =
3510 crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3511 for (actor, verb, allowed) in [
3512 ("seat:duty", "list", true),
3513 ("seat:duty", "create", false),
3514 ("seat:writer", "create", true),
3515 ("unlisted", "list", false),
3516 ] {
3517 let req = crate::GateRequest::new(
3518 crate::ActorRef::new("actor", actor),
3519 Namespace::local(),
3520 verb,
3521 serde_json::Value::Null,
3522 );
3523 assert_eq!(
3524 runtime.gate.check(&req).unwrap().is_allow(),
3525 allowed,
3526 "{actor} {verb}"
3527 );
3528 }
3529 }
3530 }
3531
3532 #[test]
3533 fn invalid_write_denials_fail_config_load_and_direct_config_fails_closed() {
3534 let dir = tempfile::tempdir().unwrap();
3535 for value in [
3536 "['']".to_string(),
3537 "[' ']".into(),
3538 format!("['{}']", "é".repeat(129)),
3539 format!("[{}]", vec!["'*'"; 257].join(",")),
3540 ] {
3541 let path = write_toml(&dir, &format!("[gate]\ndeny_writes_for={value}\n"));
3542 let error = KhiveConfig::load(Some(&path)).unwrap_err();
3543 assert!(
3544 matches!(
3545 config_error_root(&error),
3546 ConfigError::InvalidWriteDenyPatterns { .. }
3547 ),
3548 "{error}"
3549 );
3550 }
3551 for field in ["deny_write_for=['*']", "deny_writes_for=[17]"] {
3552 let path = write_toml(&dir, &format!("[gate]\n{field}\n"));
3553 assert!(KhiveConfig::load(Some(&path)).is_err());
3554 }
3555 let path = write_toml(
3556 &dir,
3557 "[gate]\ngranted_actors=['writer']\ndeny_writes_for=['用户@*/[?]']\n",
3558 );
3559 let mut config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3560 config.gate.as_mut().unwrap().deny_writes_for = vec![String::new()];
3561 let runtime = crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3562 let req = crate::GateRequest::new(
3563 crate::ActorRef::new("actor", "writer"),
3564 Namespace::local(),
3565 "list",
3566 serde_json::Value::Null,
3567 );
3568 assert!(matches!(
3569 runtime.gate.check(&req),
3570 Err(crate::GateError::Policy(_))
3571 ));
3572 }
3573
3574 #[test]
3575 fn absent_gate_preserves_the_programmatic_gate() {
3576 let mut base = in_memory_runtime_config();
3577 base.gate = std::sync::Arc::new(crate::CallerEnrollmentGate::new(vec![], false));
3578 let configured =
3579 crate::runtime_config_from_khive_config(&KhiveConfig::default(), base.clone());
3580 assert!(std::sync::Arc::ptr_eq(&base.gate, &configured.gate));
3581 }
3582
3583 #[test]
3584 fn invalid_granted_actor_fails_startup() {
3585 let dir = tempfile::tempdir().unwrap();
3586 let path = write_toml(&dir, "[gate]\ngranted_actors = [\"not valid\"]\n");
3587 let err = KhiveConfig::load(Some(&path)).expect_err("invalid actor id must fail");
3588 assert!(matches!(
3589 config_error_root(&err),
3590 ConfigError::InvalidGrantedActorId { id, .. } if id == "not valid"
3591 ));
3592 }
3593
3594 #[test]
3595 fn unrelated_unknown_top_level_sections_remain_forward_compatible() {
3596 let dir = tempfile::tempdir().unwrap();
3597 let path = write_toml(&dir, "[future_feature]\nenabled = true\n");
3598 KhiveConfig::load(Some(&path))
3599 .expect("unrelated future config stays forward compatible")
3600 .expect("config exists");
3601 }
3602
3603 #[test]
3604 fn brain_fleet_readers_default_to_empty() {
3605 assert!(KhiveConfig::default().brain.fleet_readers.is_empty());
3606 assert!(in_memory_runtime_config().brain.fleet_readers.is_empty());
3607
3608 let dir = tempfile::tempdir().unwrap();
3609 for engines in [
3610 "",
3611 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3612 ] {
3613 for brain in ["", "[brain]\n", "[brain]\nfleet_readers = []\n"] {
3614 let path = write_toml(&dir, &format!("{engines}\n{brain}"));
3615 let config = KhiveConfig::load(Some(&path))
3616 .expect("load")
3617 .expect("config exists");
3618 assert!(config.brain.fleet_readers.is_empty());
3619
3620 let mut base = in_memory_runtime_config();
3621 base.brain.fleet_readers = vec!["lambda:previous".to_string()];
3622 let resolved = crate::runtime_config_from_khive_config(&config, base);
3623 assert!(resolved.brain.fleet_readers.is_empty());
3624 }
3625 }
3626 }
3627
3628 #[test]
3629 fn brain_fleet_readers_parse_and_resolve_with_or_without_engines() {
3630 let dir = tempfile::tempdir().unwrap();
3631 for engines in [
3632 "",
3633 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3634 ] {
3635 let path = write_toml(
3636 &dir,
3637 &format!(
3638 "{engines}\n[brain]\nfleet_readers = [\"lambda:reader\", \"lambda:auditor\"]\n"
3639 ),
3640 );
3641 let config = KhiveConfig::load(Some(&path))
3642 .expect("load")
3643 .expect("config exists");
3644 assert_eq!(
3645 config.brain.fleet_readers,
3646 vec!["lambda:reader", "lambda:auditor"]
3647 );
3648
3649 let mut base = in_memory_runtime_config();
3650 base.brain.fleet_readers = vec!["lambda:previous".to_string()];
3651 let resolved = crate::runtime_config_from_khive_config(&config, base);
3652 assert_eq!(
3653 resolved.brain.fleet_readers,
3654 vec!["lambda:reader", "lambda:auditor"]
3655 );
3656 }
3657 }
3658
3659 #[test]
3660 fn unknown_brain_key_fails_startup() {
3661 let dir = tempfile::tempdir().unwrap();
3662 let path = write_toml(&dir, "[brain]\nfleet_reader = [\"lambda:reader\"]\n");
3663 let err = KhiveConfig::load(Some(&path)).expect_err("unknown brain key must fail");
3664 assert!(matches!(err, ConfigError::Parse { .. }));
3665 assert!(err.to_string().contains("unknown field"), "{err}");
3666 }
3667
3668 #[test]
3669 fn telemetry_missing_default_stays_absent_with_or_without_engines() {
3670 use crate::{TelemetryCarrier, TelemetryConfig};
3671
3672 assert_eq!(KhiveConfig::default().telemetry, TelemetryConfig::default());
3673 assert_eq!(
3674 in_memory_runtime_config().telemetry,
3675 TelemetryConfig::default()
3676 );
3677 let dir = tempfile::tempdir().unwrap();
3678 for engines in [
3679 "",
3680 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3681 ] {
3682 for telemetry in ["", "[telemetry]\n"] {
3683 let path = write_toml(&dir, &format!("{engines}\n{telemetry}"));
3684 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3685 let mut base = in_memory_runtime_config();
3686 base.telemetry.stream = "previous".to_string();
3687 base.telemetry.default_carrier = Some(TelemetryCarrier::Durable);
3688 let resolved = crate::runtime_config_from_khive_config(&config, base);
3689 assert_eq!(resolved.telemetry, TelemetryConfig::default());
3690 assert_eq!(resolved.telemetry.stream, "telemetry");
3691 assert_eq!(resolved.telemetry.default_carrier, None);
3692 let error = resolved
3693 .telemetry
3694 .validate_activation()
3695 .expect_err("activating telemetry requires the declared default");
3696 assert!(error.to_string().contains("telemetry.default_carrier"));
3697 }
3698 }
3699 }
3700
3701 #[test]
3702 fn telemetry_table_loads_and_resolves_with_or_without_engines() {
3703 use crate::{TelemetryCarrier, TelemetryFailurePosture};
3704
3705 let dir = tempfile::tempdir().unwrap();
3706 for engines in [
3707 "",
3708 "[[engines]]\nname = \"primary\"\nmodel = \"all-minilm-l6-v2\"\ndefault = true\n",
3709 ] {
3710 let path = write_toml(
3711 &dir,
3712 &format!(
3713 r#"{engines}
3714[telemetry]
3715stream = "operations"
3716default_carrier = "durable"
3717[[telemetry.channels]]
3718kinds = ["run.started", "run.completed"]
3719carrier = "durable"
3720failure_posture = "gap"
3721[[telemetry.channels]]
3722kinds = ["turn.delta", "*.heartbeat"]
3723carrier = "ephemeral"
3724failure_posture = "stop"
3725"#
3726 ),
3727 );
3728 let config = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3729 assert_eq!(config.telemetry.channels.len(), 2);
3730 let resolved =
3731 crate::runtime_config_from_khive_config(&config, in_memory_runtime_config());
3732 assert_eq!(resolved.telemetry, config.telemetry);
3733 assert_eq!(resolved.telemetry.stream, "operations");
3734 for kind in ["run.started", "run.completed"] {
3735 let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3736 assert_eq!(policy.carrier, TelemetryCarrier::Durable);
3737 assert_eq!(policy.failure_posture, TelemetryFailurePosture::Gap);
3738 }
3739 for kind in ["turn.delta", "run.heartbeat", "turn.child.heartbeat"] {
3740 let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3741 assert_eq!(policy.carrier, TelemetryCarrier::Ephemeral);
3742 assert_eq!(policy.failure_posture, TelemetryFailurePosture::Stop);
3743 }
3744 for kind in [
3745 "unclassified",
3746 "heartbeat",
3747 "run.notheartbeat",
3748 "run.heartbeat.extra",
3749 ] {
3750 let policy = resolved.telemetry.policy_for_kind(kind).unwrap();
3751 assert_eq!(policy.carrier, TelemetryCarrier::Durable);
3752 assert_eq!(policy.failure_posture, TelemetryFailurePosture::Stop);
3753 }
3754 }
3755 }
3756
3757 #[test]
3758 fn telemetry_invalid_policy_values_name_the_channel() {
3759 let dir = tempfile::tempdir().unwrap();
3760 for (carrier, posture, field, value) in [
3761 ("disk", "stop", "carrier", "disk"),
3762 ("Durable", "stop", "carrier", "Durable"),
3763 ("durable", "ignore", "failure_posture", "ignore"),
3764 ("durable", "Stop", "failure_posture", "Stop"),
3765 ] {
3766 let path = write_toml(
3767 &dir,
3768 &format!(
3769 r#"[[telemetry.channels]]
3770kinds = ["first"]
3771carrier = "ephemeral"
3772failure_posture = "gap"
3773[[telemetry.channels]]
3774kinds = ["second"]
3775carrier = "{carrier}"
3776failure_posture = "{posture}"
3777"#
3778 ),
3779 );
3780 let error = KhiveConfig::load(Some(&path)).expect_err("invalid policy must refuse");
3781 let message = error.to_string();
3782 for expected in ["telemetry.channels[1]", field, value] {
3783 assert!(message.contains(expected), "{message}");
3784 }
3785 }
3786 let path = write_toml(&dir, "[telemetry]\ndefault_carrier = \"disk\"\n");
3787 let error = KhiveConfig::load(Some(&path)).expect_err("unknown fallback must refuse");
3788 assert!(
3789 error.to_string().contains("telemetry.default_carrier"),
3790 "{error}"
3791 );
3792 }
3793
3794 #[test]
3795 fn telemetry_overlapping_channels_name_both_entries() {
3796 let dir = tempfile::tempdir().unwrap();
3797 for (first, second) in [
3798 ("run.started", "run.started"),
3799 ("run.heartbeat", "*.heartbeat"),
3800 ("*.heartbeat", "run.heartbeat"),
3801 ("*.heartbeat", "*.heartbeat"),
3802 ("*.heartbeat", "*.child.heartbeat"),
3803 ("*.child.heartbeat", "*.heartbeat"),
3804 ] {
3805 let path = write_toml(
3806 &dir,
3807 &format!(
3808 r#"[[telemetry.channels]]
3809kinds = ["{first}"]
3810carrier = "ephemeral"
3811failure_posture = "gap"
3812[[telemetry.channels]]
3813kinds = ["{second}"]
3814carrier = "durable"
3815failure_posture = "stop"
3816"#
3817 ),
3818 );
3819 let error = KhiveConfig::load(Some(&path)).expect_err("overlap must refuse");
3820 let message = error.to_string();
3821 for expected in [
3822 "telemetry.channels[1]",
3823 "telemetry.channels[0]",
3824 first,
3825 second,
3826 ] {
3827 assert!(message.contains(expected), "{message}");
3828 }
3829 }
3830 }
3831
3832 #[test]
3833 fn telemetry_empty_and_invalid_kind_patterns_name_the_channel() {
3834 let dir = tempfile::tempdir().unwrap();
3835 for kinds in [
3836 "[]",
3837 "[\"\"]",
3838 "[\" \"]",
3839 "[\"two names\"]",
3840 "[\"*\"]",
3841 "[\"run.*\"]",
3842 "[\"*.\"]",
3843 "[\"**.heartbeat\"]",
3844 "[\"*.heart*beat\"]",
3845 ] {
3846 let path = write_toml(
3847 &dir,
3848 &format!(
3849 r#"[[telemetry.channels]]
3850kinds = ["first"]
3851carrier = "ephemeral"
3852failure_posture = "gap"
3853[[telemetry.channels]]
3854kinds = {kinds}
3855carrier = "durable"
3856failure_posture = "stop"
3857"#
3858 ),
3859 );
3860 let error = KhiveConfig::load(Some(&path)).expect_err("invalid kinds must refuse");
3861 assert!(
3862 error.to_string().contains("telemetry.channels[1]"),
3863 "{error}"
3864 );
3865 }
3866 }
3867
3868 #[test]
3869 fn telemetry_tables_reject_unknown_keys() {
3870 let dir = tempfile::tempdir().unwrap();
3871 for content in [
3872 "[telemetry]\ndefault_carrrier = \"durable\"\n",
3873 "[telemetry.ring]\ncapacity = 4096\n",
3874 "[[telemetry.channels]]\nkinds = [\"run\"]\ncarrier = \"durable\"\nfailure_posture = \"stop\"\ncarrrier = \"ephemeral\"\n",
3875 ] {
3876 let path = write_toml(&dir, content);
3877 let error = KhiveConfig::load(Some(&path)).expect_err("unknown key must refuse");
3878 assert!(error.to_string().contains("unknown field"), "{error}");
3879 }
3880 }
3881
3882 #[test]
3886 fn test_no_git_write_section_is_valid_and_empty() {
3887 let dir = tempfile::tempdir().unwrap();
3888 let path = write_toml(&dir, "# no git_write section\n");
3889 let cfg = KhiveConfig::load(Some(&path))
3890 .expect("no error")
3891 .expect("file found");
3892 assert!(cfg.git_write.allowed.is_empty());
3893 }
3894
3895 fn write_git_program_config(dir: &tempfile::TempDir, program: &Path) -> PathBuf {
3896 let program = toml::Value::String(program.to_str().unwrap().to_string());
3897 write_toml(dir, &format!("[git_write]\nprogram = {program}\n"))
3898 }
3899
3900 #[test]
3901 fn git_program_absent_preserves_path_default() {
3902 let dir = tempfile::tempdir().unwrap();
3903 for content in ["# no git_write section\n", "[git_write]\n"] {
3904 let path = write_toml(&dir, content);
3905 let cfg = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3906 assert!(cfg.git_write.program.is_none());
3907 assert_eq!(cfg.git_write.git_program(), Path::new("git"));
3908 }
3909 assert!(GitWriteSectionConfig::default().program.is_none());
3910 assert_eq!(
3911 GitWriteSectionConfig::default().git_program(),
3912 Path::new("git")
3913 );
3914 }
3915
3916 #[cfg(any(unix, windows))]
3917 #[test]
3918 fn git_program_absolute_executable_loads() {
3919 let dir = tempfile::tempdir().unwrap();
3920 let program = std::env::current_exe().unwrap();
3921 let path = write_git_program_config(&dir, &program);
3922 let cfg = KhiveConfig::load(Some(&path)).unwrap().unwrap();
3923 assert_eq!(cfg.git_write.program.as_deref(), Some(program.as_path()));
3924 assert_eq!(cfg.git_write.git_program(), program);
3925 }
3926
3927 #[test]
3928 fn git_program_relative_path_is_rejected_at_load() {
3929 let dir = tempfile::tempdir().unwrap();
3930 for program in ["git", "relative/git"] {
3931 let path = write_git_program_config(&dir, Path::new(program));
3932 let error = KhiveConfig::load(Some(&path)).expect_err("relative program must fail");
3933 assert!(
3934 matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3935 if key == "git_write.program" && reason == "must be absolute"),
3936 "unexpected error: {error}"
3937 );
3938 assert!(error.to_string().contains("git_write.program"));
3939 }
3940 }
3941
3942 #[test]
3943 fn git_program_missing_file_is_rejected_at_load() {
3944 let dir = tempfile::tempdir().unwrap();
3945 let path = write_git_program_config(&dir, &dir.path().join("missing-git"));
3946 let error = KhiveConfig::load(Some(&path)).expect_err("missing program must fail");
3947 assert!(
3948 matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3949 if key == "git_write.program" && reason == "does not exist"),
3950 "unexpected error: {error}"
3951 );
3952 assert!(error.to_string().contains("git_write.program"));
3953 }
3954
3955 #[test]
3956 fn git_program_nonexecutable_file_is_rejected_at_load() {
3957 let dir = tempfile::tempdir().unwrap();
3958 let program = dir.path().join("git.txt");
3959 std::fs::write(&program, "not executable\n").unwrap();
3960 #[cfg(unix)]
3961 {
3962 use std::os::unix::fs::PermissionsExt;
3963 std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o600)).unwrap();
3964 }
3965 let path = write_git_program_config(&dir, &program);
3966 let error = KhiveConfig::load(Some(&path)).expect_err("nonexecutable program must fail");
3967 assert!(
3968 matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3969 if key == "git_write.program" && reason == "is not executable"),
3970 "unexpected error: {error}"
3971 );
3972 assert!(error.to_string().contains("git_write.program"));
3973 }
3974
3975 #[test]
3976 fn git_program_directory_is_rejected_at_load() {
3977 let dir = tempfile::tempdir().unwrap();
3978 let program = dir.path().join("git.exe");
3979 std::fs::create_dir(&program).unwrap();
3980 #[cfg(unix)]
3981 {
3982 use std::os::unix::fs::PermissionsExt;
3983 std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
3984 }
3985 let path = write_git_program_config(&dir, &program);
3986 let error = KhiveConfig::load(Some(&path)).expect_err("directory program must fail");
3987 assert!(
3988 matches!(config_error_root(&error), ConfigError::InvalidGitWriteConfig { key, reason }
3989 if key == "git_write.program" && reason == "is not executable"),
3990 "unexpected error: {error}"
3991 );
3992 assert!(error.to_string().contains("git_write.program"));
3993 }
3994
3995 #[test]
3997 fn test_git_write_entry_parses() {
3998 let dir = tempfile::tempdir().unwrap();
3999 let path = write_toml(
4000 &dir,
4001 r#"
4002[[git_write.allowed]]
4003repo = "/abs/path/repo"
4004branches = ["feat/*", "fix/*"]
4005"#,
4006 );
4007 let cfg = KhiveConfig::load(Some(&path))
4008 .expect("no error")
4009 .expect("file found");
4010 assert_eq!(cfg.git_write.allowed.len(), 1);
4011 assert_eq!(cfg.git_write.allowed[0].repo, "/abs/path/repo");
4012 assert_eq!(
4013 cfg.git_write.allowed[0].branches,
4014 vec!["feat/*".to_string(), "fix/*".to_string()]
4015 );
4016 }
4017
4018 #[test]
4020 fn test_git_write_relative_repo_rejected() {
4021 let dir = tempfile::tempdir().unwrap();
4022 let path = write_toml(
4023 &dir,
4024 r#"
4025[[git_write.allowed]]
4026repo = "relative/path"
4027branches = ["main"]
4028"#,
4029 );
4030 let err = KhiveConfig::load(Some(&path)).expect_err("relative repo must be rejected");
4031 assert!(
4032 matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "relative/path"),
4033 "expected InvalidGitWriteEntry, got {err:?}"
4034 );
4035 }
4036
4037 #[test]
4041 fn test_git_write_multi_star_branch_pattern_rejected() {
4042 let dir = tempfile::tempdir().unwrap();
4043 let path = write_toml(
4044 &dir,
4045 r#"
4046[[git_write.allowed]]
4047repo = "/abs/path"
4048branches = ["**"]
4049"#,
4050 );
4051 let err = KhiveConfig::load(Some(&path)).expect_err("** must be rejected");
4052 assert!(
4053 matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
4054 "expected InvalidGitWriteEntry, got {err:?}"
4055 );
4056
4057 let dir2 = tempfile::tempdir().unwrap();
4058 let path2 = write_toml(
4059 &dir2,
4060 r#"
4061[[git_write.allowed]]
4062repo = "/abs/path"
4063branches = ["rel-*-*-final"]
4064"#,
4065 );
4066 let err2 = KhiveConfig::load(Some(&path2)).expect_err("rel-*-*-final must be rejected");
4067 assert!(
4068 matches!(
4069 config_error_root(&err2),
4070 ConfigError::InvalidGitWriteEntry { .. }
4071 ),
4072 "expected InvalidGitWriteEntry, got {err2:?}"
4073 );
4074 }
4075
4076 #[test]
4078 fn test_git_write_single_star_branch_pattern_accepted() {
4079 let dir = tempfile::tempdir().unwrap();
4080 let path = write_toml(
4081 &dir,
4082 r#"
4083[[git_write.allowed]]
4084repo = "/abs/path"
4085branches = ["a*b", "main"]
4086"#,
4087 );
4088 let cfg = KhiveConfig::load(Some(&path))
4089 .expect("no error")
4090 .expect("file found");
4091 assert_eq!(cfg.git_write.allowed[0].branches, vec!["a*b", "main"]);
4092 }
4093
4094 #[test]
4097 fn test_git_write_empty_branches_rejected() {
4098 let dir = tempfile::tempdir().unwrap();
4099 let path = write_toml(
4100 &dir,
4101 r#"
4102[[git_write.allowed]]
4103repo = "/abs/path"
4104branches = []
4105"#,
4106 );
4107 let err = KhiveConfig::load(Some(&path)).expect_err("empty branches must be rejected");
4108 assert!(
4109 matches!(config_error_root(&err), ConfigError::InvalidGitWriteEntry { ref repo, .. } if repo == "/abs/path"),
4110 "expected InvalidGitWriteEntry, got {err:?}"
4111 );
4112 }
4113
4114 #[test]
4115 fn git_actor_mapping_and_resolver_defaults_parse_without_resolution() {
4116 let cfg: KhiveConfig = toml::from_str(
4117 r#"
4118[git_write.actors."lambda:example"]
4119name = "Example"
4120email = "example@example.invalid"
4121credential_ref = "example-reference"
4122platform_identity = "example-login"
4123"#,
4124 )
4125 .unwrap();
4126 if cfg!(unix) {
4127 cfg.validate().unwrap();
4128 } else {
4129 assert!(cfg.validate().is_err());
4130 }
4131 let identity = &cfg.git_write.actors["lambda:example"];
4132 assert_eq!(identity.name, "Example");
4133 assert_eq!(identity.credential_ref, "example-reference");
4134 assert_eq!(
4135 cfg.git_write.credential_resolver,
4136 GitWriteSectionConfig::default().credential_resolver
4137 );
4138 }
4139
4140 #[test]
4141 fn git_resolver_accepts_only_absolute_argv_with_ref_template() {
4142 for argv in [
4143 vec![],
4144 vec!["relative-resolver", "{ref}"],
4145 vec!["/bin/sh", "-c", "{ref}"],
4146 vec!["/usr/bin/env", "sh", "{ref}"],
4147 vec!["/absolute/resolver", "{token}"],
4148 vec!["/absolute/resolver", "--service={ref}"],
4149 vec!["/absolute/resolver"],
4150 vec!["/absolute/resolver", "{ref}", "bad\0arg"],
4151 ] {
4152 let config = GitWriteSectionConfig {
4153 credential_resolver: argv.into_iter().map(str::to_string).collect(),
4154 ..Default::default()
4155 };
4156 assert!(matches!(
4157 config.validate_dev_loop(),
4158 Err(ConfigError::InvalidGitWriteConfig { key, .. }) if key == "credential_resolver"
4159 ));
4160 }
4161 let config = GitWriteSectionConfig {
4162 credential_resolver: vec![
4163 std::env::temp_dir()
4164 .join("not-installed-yet/resolver")
4165 .to_string_lossy()
4166 .into_owned(),
4167 "--reference".to_string(),
4168 "{ref}".to_string(),
4169 ],
4170 ..Default::default()
4171 };
4172 config.validate_dev_loop().unwrap();
4173 }
4174
4175 #[test]
4176 fn git_actor_mapping_rejects_invalid_identity_and_unknown_fields() {
4177 let actor = GitWriteActorConfig {
4178 name: "Example".to_string(),
4179 email: "example@example.invalid".to_string(),
4180 credential_ref: "example-reference".to_string(),
4181 platform_identity: "example-login".to_string(),
4182 };
4183 for field in ["name", "email", "credential_ref", "platform_identity"] {
4184 let mut invalid = actor.clone();
4185 match field {
4186 "name" => invalid.name.clear(),
4187 "email" => invalid.email = "bad\nemail".to_string(),
4188 "credential_ref" => invalid.credential_ref.clear(),
4189 "platform_identity" => invalid.platform_identity.clear(),
4190 _ => unreachable!(),
4191 }
4192 let config = GitWriteSectionConfig {
4193 actors: BTreeMap::from([("example".to_string(), invalid)]),
4194 ..Default::default()
4195 };
4196 assert!(config.validate_dev_loop().is_err());
4197 }
4198 assert!(toml::from_str::<GitWriteActorConfig>(
4199 r#"name = "Example"
4200email = "example@example.invalid"
4201credential_ref = "reference"
4202platform_identity = "login"
4203credential = "not-an-accepted-field""#
4204 )
4205 .is_err());
4206 }
4207
4208 #[test]
4209 fn git_repository_merge_refusals_accept_only_the_two_named_entries() {
4210 let row = |refusals: &[&str]| GitWriteSectionConfig {
4211 repositories: BTreeMap::from([(
4212 "/repo".to_string(),
4213 GitWriteRepositoryConfig {
4214 remote: "https://github.com/example/repo".to_string(),
4215 slug: "example/repo".to_string(),
4216 visibility: "private".to_string(),
4217 merge_refusals: refusals.iter().map(|entry| entry.to_string()).collect(),
4218 },
4219 )]),
4220 ..Default::default()
4221 };
4222 for refusals in [
4223 &[][..],
4224 &["opener"][..],
4225 &["last_pusher"][..],
4226 &["opener", "last_pusher"][..],
4227 ] {
4228 row(refusals).validate_dev_loop().unwrap();
4229 }
4230 for refusals in [
4231 &["author"][..],
4232 &["Opener"][..],
4233 &["opener", "opener"][..],
4234 &["last_pusher", "opener", "last_pusher"][..],
4235 ] {
4236 assert!(matches!(
4237 row(refusals).validate_dev_loop(),
4238 Err(ConfigError::InvalidGitWriteConfig { key, .. })
4239 if key == "repositories./repo.merge_refusals"
4240 ));
4241 }
4242 let parsed: GitWriteRepositoryConfig = toml::from_str(
4243 r#"remote = "https://github.com/example/repo"
4244slug = "example/repo"
4245visibility = "private""#,
4246 )
4247 .unwrap();
4248 assert!(parsed.merge_refusals.is_empty());
4249 assert!(toml::from_str::<GitWriteRepositoryConfig>(
4250 r#"remote = "https://github.com/example/repo"
4251slug = "example/repo"
4252visibility = "private"
4253merge_refusal = ["opener"]"#
4254 )
4255 .is_err());
4256 }
4257
4258 #[test]
4259 fn git_contract_faults_are_feature_gated_before_empty_engines_return() {
4260 let cfg = KhiveConfig {
4261 git_write: GitWriteSectionConfig {
4262 contract_faults: true,
4263 ..Default::default()
4264 },
4265 ..Default::default()
4266 };
4267 if cfg!(feature = "contract-faults") {
4268 cfg.validate().unwrap();
4269 } else {
4270 let error = cfg.validate().unwrap_err();
4271 assert!(matches!(error, ConfigError::InvalidGitWriteConfig { .. }));
4272 assert!(error.to_string().contains("contract-faults"));
4273 }
4274 }
4275
4276 #[test]
4277 fn git_unmapped_legacy_default_remains_valid_on_every_platform() {
4278 GitWriteSectionConfig::default()
4279 .validate_dev_loop()
4280 .unwrap();
4281 let config = GitWriteSectionConfig {
4282 credential_resolver: vec!["relative-resolver".to_string(), "{ref}".to_string()],
4283 ..Default::default()
4284 };
4285 assert!(config.validate_dev_loop().is_err());
4286 }
4287
4288 #[test]
4289 fn git_fault_selectors_require_opt_in() {
4290 let config = GitWriteSectionConfig {
4291 fault: Some("git.push:reply-lost-after-effect".to_string()),
4292 ..Default::default()
4293 };
4294 assert!(matches!(
4295 config.validate_dev_loop(),
4296 Err(ConfigError::InvalidGitWriteConfig { key, .. }) if key == "fault"
4297 ));
4298 }
4299
4300 #[test]
4305 fn test_no_storage_section_defaults_to_fs() {
4306 let dir = tempfile::tempdir().unwrap();
4307 let path = write_toml(&dir, "# no storage section\n");
4308 let cfg = KhiveConfig::load(Some(&path))
4309 .expect("no error")
4310 .expect("file found");
4311 assert!(cfg.storage.blob.is_none());
4312 }
4313
4314 #[test]
4315 fn test_storage_blob_fs_selection_parses() {
4316 let dir = tempfile::tempdir().unwrap();
4317 let path = write_toml(
4318 &dir,
4319 r#"
4320[storage.blob]
4321backend = "fs"
4322root = "/var/lib/khive/blobs"
4323floor_bytes = 100000000000
4324"#,
4325 );
4326 let cfg = KhiveConfig::load(Some(&path))
4327 .expect("no error")
4328 .expect("file found");
4329 match cfg.storage.blob {
4330 Some(BlobConfig::Fs { root, floor_bytes }) => {
4331 assert_eq!(root.as_deref(), Some("/var/lib/khive/blobs"));
4332 assert_eq!(floor_bytes, Some(100_000_000_000));
4333 }
4334 other => panic!("expected BlobConfig::Fs, got {other:?}"),
4335 }
4336 }
4337
4338 #[test]
4339 fn test_storage_blob_s3_selection_parses() {
4340 let dir = tempfile::tempdir().unwrap();
4341 let path = write_toml(
4342 &dir,
4343 r#"
4344[storage.blob]
4345backend = "s3"
4346bucket = "khive-blobs"
4347region = "us-east-1"
4348endpoint = "https://objects.example.invalid"
4349prefix = "blobs"
4350"#,
4351 );
4352 let cfg = KhiveConfig::load(Some(&path))
4353 .expect("no error")
4354 .expect("file found");
4355 match cfg.storage.blob {
4356 Some(BlobConfig::S3 {
4357 bucket,
4358 region,
4359 endpoint,
4360 prefix,
4361 allow_http,
4362 }) => {
4363 assert_eq!(bucket, "khive-blobs");
4364 assert_eq!(region, "us-east-1");
4365 assert_eq!(endpoint.as_deref(), Some("https://objects.example.invalid"));
4366 assert_eq!(prefix.as_deref(), Some("blobs"));
4367 assert_eq!(allow_http, None);
4368 }
4369 other => panic!("expected BlobConfig::S3, got {other:?}"),
4370 }
4371 }
4372
4373 #[test]
4377 fn test_storage_blob_unknown_field_rejected() {
4378 let dir = tempfile::tempdir().unwrap();
4379 let path = write_toml(
4380 &dir,
4381 r#"
4382[storage.blob]
4383backend = "fs"
4384made_up_field = "x"
4385"#,
4386 );
4387 let err = KhiveConfig::load(Some(&path)).expect_err("unknown field must be rejected");
4388 assert!(
4389 matches!(config_error_root(&err), ConfigError::Parse { .. }),
4390 "got {err:?}"
4391 );
4392 }
4393
4394 #[test]
4398 fn test_storage_blob_other_backend_field_rejected() {
4399 let dir = tempfile::tempdir().unwrap();
4400 let path = write_toml(
4401 &dir,
4402 r#"
4403[storage.blob]
4404backend = "fs"
4405bucket = "khive-blobs"
4406"#,
4407 );
4408 let err = KhiveConfig::load(Some(&path)).expect_err("s3 field under fs must be rejected");
4409 assert!(
4410 matches!(config_error_root(&err), ConfigError::Parse { .. }),
4411 "got {err:?}"
4412 );
4413 }
4414
4415 #[test]
4419 fn test_storage_blob_credential_field_rejected() {
4420 let dir = tempfile::tempdir().unwrap();
4421 let path = write_toml(
4422 &dir,
4423 r#"
4424[storage.blob]
4425backend = "s3"
4426bucket = "khive-blobs"
4427region = "us-east-1"
4428access_key_id = "AKIAEXAMPLE"
4429"#,
4430 );
4431 let err = KhiveConfig::load(Some(&path))
4432 .expect_err("a credential field in TOML must be rejected");
4433 assert!(
4434 matches!(config_error_root(&err), ConfigError::Parse { .. }),
4435 "got {err:?}"
4436 );
4437 }
4438
4439 #[test]
4442 fn test_storage_blob_unknown_backend_value_rejected() {
4443 let dir = tempfile::tempdir().unwrap();
4444 let path = write_toml(
4445 &dir,
4446 r#"
4447[storage.blob]
4448backend = "gcs"
4449"#,
4450 );
4451 let err = KhiveConfig::load(Some(&path)).expect_err("unknown backend must be rejected");
4452 assert!(
4453 matches!(config_error_root(&err), ConfigError::Parse { .. }),
4454 "got {err:?}"
4455 );
4456 }
4457
4458 #[test]
4462 fn test_no_display_section_defaults_to_none() {
4463 let dir = tempfile::tempdir().unwrap();
4464 let path = write_toml(&dir, "# no display section\n");
4465 let cfg = KhiveConfig::load(Some(&path))
4466 .expect("no error")
4467 .expect("file found");
4468 assert!(cfg.display.timezone.is_none());
4469 }
4470
4471 #[test]
4472 fn test_display_timezone_valid_iana_name_parses() {
4473 let dir = tempfile::tempdir().unwrap();
4474 let path = write_toml(
4475 &dir,
4476 r#"
4477[display]
4478timezone = "America/New_York"
4479"#,
4480 );
4481 let cfg = KhiveConfig::load(Some(&path))
4482 .expect("no error")
4483 .expect("file found");
4484 assert_eq!(cfg.display.timezone.as_deref(), Some("America/New_York"));
4485 }
4486
4487 #[test]
4488 fn test_display_timezone_unrecognized_name_rejected() {
4489 let dir = tempfile::tempdir().unwrap();
4490 let path = write_toml(
4491 &dir,
4492 r#"
4493[display]
4494timezone = "Mars/Olympus_Mons"
4495"#,
4496 );
4497 let err = KhiveConfig::load(Some(&path))
4498 .expect_err("an unrecognized IANA zone name must fail at load, not silently fall back");
4499 assert!(
4500 matches!(config_error_root(&err), ConfigError::InvalidDisplayTimezone { ref timezone } if timezone == "Mars/Olympus_Mons"),
4501 "expected InvalidDisplayTimezone, got {err:?}"
4502 );
4503 }
4504
4505 #[test]
4506 fn test_display_timezone_empty_string_rejected() {
4507 let dir = tempfile::tempdir().unwrap();
4508 let path = write_toml(
4509 &dir,
4510 r#"
4511[display]
4512timezone = ""
4513"#,
4514 );
4515 let err =
4516 KhiveConfig::load(Some(&path)).expect_err("an empty timezone string must be rejected");
4517 assert!(
4518 matches!(
4519 config_error_root(&err),
4520 ConfigError::InvalidDisplayTimezone { .. }
4521 ),
4522 "expected InvalidDisplayTimezone, got {err:?}"
4523 );
4524 }
4525 include!("engine_config_backend_batch_tests.rs");
4526}