- 4725
); - 4726
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4727
assert_eq!( - 4728
cfg.update.url.as_deref(), - 4729
Some("https://example.com/manifest.json") - 4730
); - 4731
assert_eq!(cfg.update.interval_hours, 6); - 4732
} - 4733
- 4734
#[test] - 4735
fn tools_web_fetch_switch_parses() { - 4736
let dir = tempfile::tempdir().unwrap(); - 4737
write_project_config(dir.path(), "[tools]\nweb_fetch = false\n"); - 4738
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4739
assert!(!cfg.tools.web_fetch); - 4740
} - 4741
- 4742
#[test] - 4743
fn tools_browse_switch_parses() { - 4744
let dir = tempfile::tempdir().unwrap(); - 4745
write_project_config(dir.path(), "[tools]\nbrowse = false\n"); - 4746
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4747
assert!(!cfg.tools.browse); - 4748
let dir2 = tempfile::tempdir().unwrap(); - 4749
write_project_config(dir2.path(), "[tools]\nbrowse = true\nweb_fetch = false\n"); - 4750
let cfg2 = load_with_trust(dir2.path(), true).unwrap(); - 4751
assert!(cfg2.tools.browse); - 4752
assert!(!cfg2.tools.web_fetch); - 4753
} - 4754
- 4755
#[test] - 4756
fn unknown_keys_in_new_sections_warn() { - 4757
let dir = tempfile::tempdir().unwrap(); - 4758
write_project_config( - 4759
dir.path(), - 4760
"[automation]\nbogus = 1\n\n[update]\nbogus = 1\n\n[tools]\nbogus = 1\n", - 4761
); - 4762
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4763
for key in ["automation.bogus", "update.bogus", "tools.bogus"] { - 4764
assert!( - 4765
cfg.warnings.iter().any(|w| w.contains(key)), - 4766
"unknown {key} must warn: {:?}", - 4767
cfg.warnings - 4768
); - 4769
} - 4770
assert!(cfg.automation.catch_up_missed); - 4771
assert_eq!(cfg.update.interval_hours, 24); - 4772
assert!(cfg.tools.web_fetch); - 4773
} - 4774
- 4775
#[test] - 4776
fn heartbeat_defaults_when_absent() { - 4777
let dir = tempfile::tempdir().unwrap(); - 4778
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4779
assert!(!cfg.heartbeat.enabled); - 4780
assert_eq!(cfg.heartbeat.interval_secs, 1800); - 4781
assert_eq!(cfg.heartbeat.model, None); - 4782
assert_eq!(cfg.heartbeat.quiet_hours, None); - 4783
assert_eq!(cfg.heartbeat.max_findings, 3); - 4784
assert!( - 4785
!cfg.warnings.iter().any(|w| w.contains("heartbeat")), - 4786
"absent heartbeat section must not warn: {:?}", - 4787
cfg.warnings - 4788
); - 4789
} - 4790
- 4791
#[test] - 4792
fn heartbeat_full_section_parses() { - 4793
let dir = tempfile::tempdir().unwrap(); - 4794
write_project_config( - 4795
dir.path(), - 4796
"[heartbeat]\nenabled = true\ninterval_secs = 900\nmodel = \"openai/gpt-5-mini\"\nquiet_hours = \"22:00-07:00\"\nmax_findings = 5\n", - 4797
); - 4798
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4799
assert!(cfg.heartbeat.enabled); - 4800
assert_eq!(cfg.heartbeat.interval_secs, 900); - 4801
assert_eq!(cfg.heartbeat.model.as_deref(), Some("openai/gpt-5-mini")); - 4802
assert_eq!( - 4803
cfg.heartbeat.quiet_hours, - 4804
Some(QuietWindow { - 4805
start_min: 22 * 60, - 4806
end_min: 7 * 60 - 4807
}) - 4808
); - 4809
assert_eq!(cfg.heartbeat.max_findings, 5); - 4810
} - 4811
- 4812
#[test] - 4813
fn heartbeat_interval_below_minimum_warns_and_clamps() { - 4814
let dir = tempfile::tempdir().unwrap(); - 4815
write_project_config(dir.path(), "[heartbeat]\ninterval_secs = 10\n"); - 4816
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4817
assert_eq!(cfg.heartbeat.interval_secs, 300); - 4818
assert!(cfg.warnings.iter().any(|w| w.contains("interval_secs"))); - 4819
} - 4820
- 4821
#[test] - 4822
fn heartbeat_zero_max_findings_clamps_with_warning() { - 4823
let dir = tempfile::tempdir().unwrap(); - 4824
write_project_config(dir.path(), "[heartbeat]\nmax_findings = 0\n"); - 4825
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4826
assert_eq!(cfg.heartbeat.max_findings, 1); - 4827
assert!(cfg.warnings.iter().any(|w| w.contains("max_findings"))); - 4828
} - 4829
- 4830
#[test] - 4831
fn heartbeat_bad_quiet_hours_warns_and_ignores() { - 4832
for bad in [ - 4833
"9am-5pm", - 4834
"25:00-07:00", - 4835
"07:00-07:00", - 4836
"22:00", - 4837
"22:61-07:00", - 4838
] { - 4839
let dir = tempfile::tempdir().unwrap(); - 4840
write_project_config( - 4841
dir.path(), - 4842
&format!("[heartbeat]\nquiet_hours = \"{bad}\"\n"), - 4843
); - 4844
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4845
assert_eq!(cfg.heartbeat.quiet_hours, None, "{bad} must be ignored"); - 4846
assert!( - 4847
cfg.warnings.iter().any(|w| w.contains("quiet_hours")), - 4848
"{bad} must warn: {:?}", - 4849
cfg.warnings - 4850
); - 4851
} - 4852
} - 4853
- 4854
#[test] - 4855
fn quiet_window_contains_boundary_matrix() { - 4856
// Wrapping window 22:00-07:00. - 4857
let night = QuietWindow { - 4858
start_min: 22 * 60, - 4859
end_min: 7 * 60, - 4860
}; - 4861
assert!(night.contains(22 * 60), "start bound inclusive"); - 4862
assert!(night.contains(23 * 60 + 59)); - 4863
assert!(night.contains(0)); - 4864
assert!(night.contains(6 * 60 + 59)); - 4865
assert!(!night.contains(7 * 60), "end bound exclusive"); - 4866
assert!(!night.contains(21 * 60 + 59)); - 4867
assert!(!night.contains(12 * 60)); - 4868
- 4869
// Plain window 13:00-14:00. - 4870
let lunch = QuietWindow { - 4871
start_min: 13 * 60, - 4872
end_min: 14 * 60, - 4873
}; - 4874
assert!(lunch.contains(13 * 60)); - 4875
assert!(lunch.contains(13 * 60 + 59)); - 4876
assert!(!lunch.contains(14 * 60), "end bound exclusive"); - 4877
assert!(!lunch.contains(12 * 60 + 59)); - 4878
} - 4879
- 4880
#[test] - 4881
fn unknown_heartbeat_key_warns() { - 4882
let dir = tempfile::tempdir().unwrap(); - 4883
write_project_config(dir.path(), "[heartbeat]\nbogus = 1\n"); - 4884
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4885
assert!( - 4886
cfg.warnings.iter().any(|w| w.contains("heartbeat.bogus")), - 4887
"{:?}", - 4888
cfg.warnings - 4889
); - 4890
assert!(!cfg.heartbeat.enabled); - 4891
} - 4892
- 4893
#[test] - 4894
fn persisted_preferences_preserve_unrelated_project_config() { - 4895
let dir = tempfile::tempdir().unwrap(); - 4896
write_project_config( - 4897
dir.path(), - 4898
"deny = [\"bash\"]\n[route]\nobjective = \"quality-critical\"\n", - 4899
); - 4900
persist_project_preferences( - 4901
dir.path(), - 4902
Some("google"), - 4903
Some("gemini-test"), - 4904
Some(17), - 4905
Some(PermissionMode::WorkspaceWrite), - 4906
None, - 4907
Some("dark"), - 4908
) - 4909
.unwrap(); - 4910
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4911
assert_eq!(cfg.provider, "google"); - 4912
assert_eq!(cfg.model, "gemini-test"); - 4913
assert_eq!(cfg.max_turns, 17); - 4914
assert_eq!(cfg.permission_mode, PermissionMode::WorkspaceWrite); - 4915
assert_eq!(cfg.ui.theme, "dark"); - 4916
assert_eq!(cfg.deny, vec!["bash"]); - 4917
assert_eq!(cfg.route.objective, "quality-critical"); - 4918
} - 4919
- 4920
#[test] - 4921
fn probe_hosted_defaults_to_none_and_accepts_full() { - 4922
let dir = tempfile::tempdir().unwrap(); - 4923
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4924
assert_eq!(cfg.probe.hosted, "none"); - 4925
- 4926
let dir = tempfile::tempdir().unwrap(); - 4927
write_project_config(dir.path(), "[probe]\nhosted = \"full\"\n"); - 4928
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4929
assert_eq!(cfg.probe.hosted, "full"); - 4930
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings); - 4931
- 4932
let dir = tempfile::tempdir().unwrap(); - 4933
write_project_config(dir.path(), "[probe]\nhosted = \"bogus\"\n"); - 4934
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4935
assert_eq!(cfg.probe.hosted, "none"); - 4936
assert!(cfg.warnings.iter().any(|w| w.contains("probe.hosted"))); - 4937
} - 4938
- 4939
#[test] - 4940
fn ollama_settings_default_to_thirty_minute_keep_alive_and_no_num_ctx() { - 4941
let dir = tempfile::tempdir().unwrap(); - 4942
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4943
assert_eq!(cfg.ollama.keep_alive, "30m"); - 4944
assert_eq!(cfg.ollama.num_ctx, None); - 4945
} - 4946
- 4947
#[test] - 4948
fn ollama_settings_parse_keep_alive_and_num_ctx() { - 4949
let dir = tempfile::tempdir().unwrap(); - 4950
write_project_config( - 4951
dir.path(), - 4952
"[providers.ollama]\nkeep_alive = \"10m\"\nnum_ctx = 8192\n", - 4953
); - 4954
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4955
assert_eq!(cfg.ollama.keep_alive, "10m"); - 4956
assert_eq!(cfg.ollama.num_ctx, Some(8192)); - 4957
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings); - 4958
} - 4959
- 4960
#[test] - 4961
fn ollama_settings_accept_hour_and_zero_durations() { - 4962
for (input, expected) in [("24h", "24h"), ("0", "0"), ("1h30m", "1h30m")] { - 4963
let dir = tempfile::tempdir().unwrap(); - 4964
write_project_config( - 4965
dir.path(), - 4966
&format!("[providers.ollama]\nkeep_alive = \"{input}\"\n"), - 4967
); - 4968
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4969
assert_eq!(cfg.ollama.keep_alive, expected); - 4970
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings); - 4971
} - 4972
} - 4973
- 4974
#[test] - 4975
fn ollama_settings_reject_malformed_keep_alive() { - 4976
let dir = tempfile::tempdir().unwrap(); - 4977
write_project_config(dir.path(), "[providers.ollama]\nkeep_alive = \"soon\"\n"); - 4978
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4979
// Falls back to the default rather than failing config load. - 4980
assert_eq!(cfg.ollama.keep_alive, "30m"); - 4981
assert!( - 4982
cfg.warnings - 4983
.iter() - 4984
.any(|w| w.contains("providers.ollama.keep_alive")), - 4985
"{:?}", - 4986
cfg.warnings - 4987
); - 4988
} - 4989
- 4990
#[test] - 4991
fn ollama_settings_reject_num_ctx_below_1024() { - 4992
let dir = tempfile::tempdir().unwrap(); - 4993
write_project_config(dir.path(), "[providers.ollama]\nnum_ctx = 512\n"); - 4994
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4995
assert_eq!(cfg.ollama.num_ctx, None); - 4996
assert!( - 4997
cfg.warnings - 4998
.iter() - 4999
.any(|w| w.contains("providers.ollama.num_ctx")), - 5000
"{:?}", - 5001
cfg.warnings - 5002
); - 5003
} - 5004
- 5005
#[test] - 5006
fn anthropic_settings_default_fast_mode_off() { - 5007
let dir = tempfile::tempdir().unwrap(); - 5008
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5009
assert!(!cfg.anthropic.fast_mode); - 5010
} - 5011
- 5012
#[test] - 5013
fn anthropic_settings_parse_fast_mode() { - 5014
let dir = tempfile::tempdir().unwrap(); - 5015
write_project_config(dir.path(), "[providers.anthropic]\nfast_mode = true\n"); - 5016
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5017
assert!(cfg.anthropic.fast_mode); - 5018
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings); - 5019
} - 5020
- 5021
#[test] - 5022
fn anthropic_settings_unknown_key_warns_but_does_not_fail_load() { - 5023
let dir = tempfile::tempdir().unwrap(); - 5024
write_project_config(dir.path(), "[providers.anthropic]\nturbo = true\n"); - 5025
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5026
assert!( - 5027
cfg.warnings - 5028
.iter() - 5029
.any(|w| w.contains("providers.anthropic.turbo")), - 5030
"{:?}", - 5031
cfg.warnings - 5032
); - 5033
} - 5034
- 5035
#[test] - 5036
fn plugins_network_allow_persists_and_clears_in_place() { - 5037
crate::paths::isolate_home_for_tests(); - 5038
let dir = tempfile::tempdir().unwrap(); - 5039
write_project_config( - 5040
dir.path(), - 5041
"deny = [\"bash\"]\n[plugins]\nnetwork_deny = [\"older-plugin\"]\n", - 5042
); - 5043
let grant = Some(vec!["plugin-alpha".into(), "plugin-beta".into()]); - 5044
persist_plugins_network_allow(&project_path(dir.path()), grant).unwrap(); - 5045
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5046
assert!(cfg.plugins.is_network_allowed("plugin-alpha")); - 5047
assert!(cfg.plugins.is_network_allowed("plugin-beta")); - 5048
assert!(!cfg.plugins.is_network_allowed("web-search-plugin")); - 5049
assert!(cfg.plugins.network_deny.contains(&"older-plugin".into())); - 5050
assert_eq!(cfg.deny, vec!["bash"]); - 5051
- 5052
persist_plugins_network_allow(&project_path(dir.path()), Some(vec![])).unwrap(); - 5053
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5054
assert!(!cfg.plugins.is_network_allowed("plugin-alpha")); - 5055
assert_eq!(cfg.plugins.network_deny, vec!["older-plugin"]); - 5056
assert_eq!(cfg.deny, vec!["bash"]); - 5057
} - 5058
- 5059
#[test] - 5060
fn seed_plugins_network_allow_if_empty_seeds_and_is_idempotent() { - 5061
let dir = tempfile::tempdir().unwrap(); - 5062
let path = dir.path().join(".vak/config.toml"); - 5063
- 5064
// First run: seeds [plugins] network_allow - 5065
assert!(seed_plugins_network_allow_if_empty(&path).unwrap()); - 5066
let text = std::fs::read_to_string(&path).unwrap(); - 5067
assert!(text.contains("network_allow")); - 5068
- 5069
// Second run: no-op, returns false - 5070
assert!(!seed_plugins_network_allow_if_empty(&path).unwrap()); - 5071
- 5072
// Custom config with existing network_allow is preserved - 5073
let dir2 = tempfile::tempdir().unwrap(); - 5074
let path2 = dir2.path().join(".vak/config.toml"); - 5075
std::fs::create_dir_all(path2.parent().unwrap()).unwrap(); - 5076
std::fs::write(&path2, "[plugins]\nnetwork_allow = [\"custom\"]\n").unwrap(); - 5077
assert!(!seed_plugins_network_allow_if_empty(&path2).unwrap()); - 5078
let text2 = std::fs::read_to_string(&path2).unwrap(); - 5079
assert_eq!(text2, "[plugins]\nnetwork_allow = [\"custom\"]\n"); - 5080
} - 5081
- 5082
fn hook(command: &str, enabled: bool) -> HookConfig { - 5083
HookConfig { - 5084
event: "pre_tool_use".into(), - 5085
matcher: None, - 5086
command: command.into(), - 5087
timeout_ms: None, - 5088
enabled, - 5089
failure_mode: None, - 5090
} - 5091
} - 5092
- 5093
/// `PUT /config/hooks` (vak-server) always resubmits whatever `GET - 5094
/// /config/hooks` last reported, and used to report the merged - 5095
/// *effective* list — global layer included. Every save re-extended an - 5096
/// already-inherited global hook into the project layer, so the next - 5097
/// merge doubled it, then the next save tripled it. `base.hooks.extend` - 5098
/// without a dedup check (unlike allow/ask/deny two blocks above it) - 5099
/// let that compound with no bound. This is the one guard against it - 5100
/// staying fixed at the merge layer even if the endpoint-level fix - 5101
/// (`get_hooks` reading its own file instead of the merged config) - 5102
/// ever regresses. - 5103
#[test] - 5104
fn merge_into_does_not_duplicate_an_already_inherited_hook() { - 5105
let mut base = FileConfig { - 5106
hooks: vec![hook("global.sh", true)], - 5107
..FileConfig::default() - 5108
}; - 5109
let over = FileConfig { - 5110
// Exactly what a naive resubmit of the merged list looks like: - 5111
// the inherited hook, unchanged, plus one genuinely new to this - 5112
// layer. - 5113
hooks: vec![hook("global.sh", true), hook("project.sh", true)], - 5114
..FileConfig::default() - 5115
}; - 5116
merge_into(&mut base, over); - 5117
assert_eq!( - 5118
base.hooks, - 5119
vec![hook("global.sh", true), hook("project.sh", true)], - 5120
"the shared hook must appear once, not twice" - 5121
); - 5122
} - 5123
- 5124
/// A project hook with the same identity replaces the inherited hook, - 5125
/// including when the edit only changes `enabled`. - 5126
#[test] - 5127
fn merge_into_keeps_a_hook_whose_enabled_state_changed() { - 5128
let mut base = FileConfig { - 5129
hooks: vec![hook("audit.sh", true)], - 5130
..FileConfig::default() - 5131
}; - 5132
let over = FileConfig { - 5133
hooks: vec![hook("audit.sh", false)], - 5134
..FileConfig::default() - 5135
}; - 5136
merge_into(&mut base, over); - 5137
assert_eq!(base.hooks, vec![hook("audit.sh", false)]); - 5138
} - 5139
- 5140
/// A `[[hooks]]` entry written before `enabled` existed has no such key - 5141
/// in its TOML; it must still deserialize as enabled, not silently vanish. - 5142
/// Parsed directly from the project file (not `load_with_trust`) so the - 5143
/// assertion is hermetic and does not inherit an ambient user-global hook. - 5144
#[test] - 5145
fn hook_without_enabled_key_deserializes_as_enabled() { - 5146
let dir = tempfile::tempdir().unwrap(); - 5147
write_project_config( - 5148
dir.path(), - 5149
"[[hooks]]\nevent = \"pre_tool_use\"\ncommand = \"echo hi\"\n", - 5150
); - 5151
let (fc, _warnings) = parse_file(&dir.path().join(".vak/config.toml")).unwrap(); - 5152
assert_eq!(fc.hooks.len(), 1); - 5153
assert!(fc.hooks[0].enabled); - 5154
} - 5155
- 5156
/// `PUT /config/mcp` and `PATCH /config` can reach the server at the - 5157
/// same moment, and every setting has its own writer that rewrites the - 5158
/// whole file from what it read. Run the writers against one file at - 5159
/// once, round after round: no write may fail, every round's changes - 5160
/// must all land, and a reader running alongside must only ever see a - 5161
/// whole document that still carries the keys no writer owns. - 5162
#[test] - 5163
fn concurrent_config_writers_all_land_and_the_file_always_parses() { - 5164
use std::collections::BTreeMap; - 5165
use std::sync::atomic::{AtomicBool, Ordering}; - 5166
- 5167
type Write = fn(&Path, u32) -> Result<(), ConfigError>; - 5168
type Landed = fn(&toml::Table, u32) -> bool; - 5169
- 5170
fn at<'a>(table: &'a toml::Table, keys: &[&str]) -> Option<&'a toml::Value> { - 5171
let (last, parents) = keys.split_last()?; - 5172
let mut table = table; - 5173
for key in parents { - 5174
table = table.get(*key)?.as_table()?; - 5175
} - 5176
table.get(*last) - 5177
} - 5178
fn text_at(table: &toml::Table, keys: &[&str]) -> Option<String> { - 5179
at(table, keys) - 5180
.and_then(toml::Value::as_str) - 5181
.map(str::to_string) - 5182
} - 5183
fn strings_at(table: &toml::Table, keys: &[&str]) -> Option<Vec<String>> { - 5184
at(table, keys)? - 5185
.as_array()? - 5186
.iter() - 5187
.map(|value| value.as_str().map(str::to_string)) - 5188
.collect() - 5189
} - 5190
- 5191
struct StopOnDrop<'a>(&'a AtomicBool); - 5192
impl Drop for StopOnDrop<'_> { - 5193
fn drop(&mut self) { - 5194
self.0.store(true, Ordering::Release); - 5195
} - 5196
} - 5197
- 5198
let writers: [(&str, Write, Landed); 14] = [ - 5199
( - 5200
"mcp servers", - 5201
|cwd, round| { - 5202
let server = McpServerConfig { - 5203
command: format!("mcp-{round}"), - 5204
args: Vec::new(), - 5205
env: BTreeMap::new(), - 5206
network: false, - 5207
serves: Vec::new(), - 5208
}; - 5209
persist_mcp_servers( - 5210
&project_path(cwd), - 5211
&BTreeMap::from([(format!("server-{round}"), server)]), - 5212
) - 5213
}, - 5214
|table, round| { - 5215
text_at( - 5216
table, - 5217
&["mcp", "servers", &format!("server-{round}"), "command"], - 5218
) == Some(format!("mcp-{round}")) - 5219
}, - 5220
), - 5221
( - 5222
"preferences", - 5223
|cwd, round| { - 5224
let model = format!("model-{round}"); - 5225
persist_project_preferences(cwd, None, Some(&model), None, None, None, None) - 5226
}, - 5227
|table, round| text_at(table, &["model"]) == Some(format!("model-{round}")), - 5228
), - 5229
( - 5230
"voice", - 5231
|cwd, round| { - 5232
let patch = VoicePatch { - 5233
transcription_model: Some(Some(format!("stt-{round}"))), - 5234
..VoicePatch::default() - 5235
}; - 5236
persist_voice_settings_at(project_path(cwd), &patch) - 5237
}, - 5238
|table, round| { - 5239
text_at(table, &["voice", "transcription_model"]) - 5240
== Some(format!("stt-{round}")) - 5241
}, - 5242
), - 5243
( - 5244
"bus", - 5245
|cwd, round| { - 5246
let url = format!("nats://bus-{round}"); - 5247
persist_bus_settings(project_path(cwd), Some(Some(&url)), None) - 5248
}, - 5249
|table, round| { - 5250
text_at(table, &["server", "bus", "nats_url"]) - 5251
== Some(format!("nats://bus-{round}")) - 5252
}, - 5253
), - 5254
( - 5255
"evidence policy", - 5256
|cwd, round| persist_evidence_max_age(project_path(cwd), i64::from(round)), - 5257
|table, round| { - 5258
at(table, &["intent", "evidence_max_age_secs"]) - 5259
.and_then(toml::Value::as_integer) - 5260
== Some(i64::from(round)) - 5261
}, - 5262
), - 5263
( - 5264
"memory", - 5265
|cwd, round| { - 5266
persist_project_memory_prefs(cwd, Some(round % 2 == 0), None, None, None) - 5267
}, - 5268
|table, round| { - 5269
at(table, &["memory", "search_enabled"]).and_then(toml::Value::as_bool) - 5270
== Some(round % 2 == 0) - 5271
}, - 5272
), - 5273
( - 5274
"workers", - 5275
|cwd, round| persist_project_workers(cwd, round % 2 == 0), - 5276
|table, round| { - 5277
at(table, &["workers"]).and_then(toml::Value::as_bool) == Some(round % 2 == 0) - 5278
}, - 5279
), - 5280
( - 5281
"work policy", - 5282
|cwd, round| { - 5283
persist_work_preferences( - 5284
project_path(cwd), - 5285
None, - 5286
None, - 5287
Some(round as usize + 1), - 5288
None, - 5289
None, - 5290
None, - 5291
) - 5292
}, - 5293
|table, round| { - 5294
at(table, &["work", "max_items"]).and_then(toml::Value::as_integer) - 5295
== Some(i64::from(round) + 1) - 5296
}, - 5297
), - 5298
( - 5299
"gateway approvals", - 5300
|cwd, round| { - 5301
persist_gateway_approvals( - 5302
project_path(cwd), - 5303
None, - 5304
None, - 5305
Some(u64::from(round) + 1), - 5306
) - 5307
}, - 5308
|table, round| { - 5309
at(table, &["gateway", "approval_timeout_secs"]) - 5310
.and_then(toml::Value::as_integer) - 5311
== Some(i64::from(round) + 1) - 5312
}, - 5313
), - 5314
( - 5315
"permission rules", - 5316
|cwd, round| { - 5317
let deny = [format!("Bash(rm-{round} *)")]; - 5318
persist_permission_rules(project_path(cwd), None, None, Some(&deny)) - 5319
}, - 5320
|table, round| { - 5321
strings_at(table, &["deny"]) == Some(vec![format!("Bash(rm-{round} *)")]) - 5322
}, - 5323
), - 5324
( - 5325
"plugin network grants", - 5326
|cwd, round| { - 5327
persist_plugins_network_allow( - 5328
&project_path(cwd), - 5329
Some(vec![format!("plugin-{round}")]), - 5330
) - 5331
}, - 5332
|table, round| { - 5333
strings_at(table, &["plugins", "network_allow"]) - 5334
== Some(vec![format!("plugin-{round}")]) - 5335
}, - 5336
), - 5337
( - 5338
"finops caps", - 5339
|cwd, round| { - 5340
persist_project_finops_caps(cwd, Some(Some(f64::from(round) + 0.5)), None) - 5341
}, - 5342
|table, round| { - 5343
at(table, &["finops", "max_run_usd"]).and_then(toml::Value::as_float) - 5344
== Some(f64::from(round) + 0.5) - 5345
}, - 5346
), - 5347
( - 5348
"capability inheritance", - 5349
|cwd, round| { - 5350
persist_capability_inheritance( - 5351
&project_path(cwd), - 5352
Some(round % 2 == 0), - 5353
None, - 5354
None, - 5355
None, - 5356
None, - 5357
) - 5358
}, - 5359
|table, round| { - 5360
at(table, &["capabilities", "inherit_mcp"]).and_then(toml::Value::as_bool) - 5361
== Some(round % 2 == 0) - 5362
}, - 5363
), - 5364
( - 5365
"hooks", - 5366
|cwd, round| { - 5367
let hook = HookConfig { - 5368
event: "stop".into(), - 5369
matcher: None, - 5370
command: format!("hook-{round}.sh"), - 5371
timeout_ms: Some(1_000), - 5372
enabled: true, - 5373
failure_mode: Some("open".into()), - 5374
}; - 5375
persist_hooks(&project_path(cwd), &[hook]) - 5376
}, - 5377
|table, round| { - 5378
at(table, &["hooks"]) - 5379
.and_then(toml::Value::as_array) - 5380
.and_then(|hooks| hooks.first()) - 5381
.and_then(|hook| hook.get("command")) - 5382
.and_then(toml::Value::as_str) - 5383
== Some(format!("hook-{round}.sh").as_str()) - 5384
}, - 5385
), - 5386
]; - 5387
- 5388
let dir = tempfile::tempdir().unwrap(); - 5389
let cwd = dir.path(); - 5390
let path = project_path(cwd); - 5391
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 5392
std::fs::write( - 5393
&path, - 5394
"future_key = \"kept\"\n\n[future_table]\nnested = 1\n", - 5395
) - 5396
.unwrap(); - 5397
- 5398
const ROUNDS: u32 = 100; - 5399
let stop = AtomicBool::new(false); - 5400
let mut failures = Vec::new(); - 5401
let (reads, torn) = std::thread::scope(|scope| { - 5402
let stop_reader = StopOnDrop(&stop); - 5403
let reader = scope.spawn(|| { - 5404
let mut reads = 0_u32; - 5405
let mut torn = Vec::new(); - 5406
while !stop.load(Ordering::Acquire) { - 5407
let seen = match std::fs::read_to_string(&path) { - 5408
Ok(text) => match toml::from_str::<toml::Table>(&text) { - 5409
Ok(table) if text_at(&table, &["future_key"]).is_some() => None, - 5410
Ok(_) => Some(format!("a key no writer owns was dropped: {text:?}")), - 5411
Err(error) => Some(format!("unparseable: {error} in {text:?}")), - 5412
}, - 5413
Err(error) => Some(format!("unreadable: {error}")), - 5414
}; - 5415
reads += 1; - 5416
torn.extend(seen); - 5417
} - 5418
(reads, torn) - 5419
}); - 5420
for round in 0..ROUNDS { - 5421
let barrier = std::sync::Barrier::new(writers.len()); - 5422
let results: Vec<_> = std::thread::scope(|round_scope| { - 5423
let handles: Vec<_> = writers - 5424
.iter() - 5425
.map(|&(name, write, _)| { - 5426
let barrier = &barrier; - 5427
round_scope.spawn(move || { - 5428
barrier.wait(); - 5429
(name, write(cwd, round)) - 5430
}) - 5431
}) - 5432
.collect(); - 5433
handles - 5434
.into_iter() - 5435
.map(|handle| handle.join().expect("writer thread")) - 5436
.collect() - 5437
}); - 5438
for (name, result) in results { - 5439
if let Err(error) = result { - 5440
failures.push(format!("round {round}: {name} failed: {error}")); - 5441
} - 5442
} - 5443
let text = std::fs::read_to_string(&path).expect("config file"); - 5444
match toml::from_str::<toml::Table>(&text) { - 5445
Ok(table) => { - 5446
for (name, _, landed) in &writers { - 5447
if !landed(&table, round) { - 5448
failures.push(format!("round {round}: {name}'s change was lost")); - 5449
} - 5450
} - 5451
if at(&table, &["future_table", "nested"]).is_none() { - 5452
failures.push(format!("round {round}: [future_table] was dropped")); - 5453
} - 5454
} - 5455
Err(error) => failures.push(format!("round {round}: unparseable: {error}")), - 5456
} - 5457
} - 5458
drop(stop_reader); - 5459
reader.join().expect("reader thread") - 5460
}); - 5461
- 5462
assert!(reads > 0, "the reader never ran"); - 5463
assert!( - 5464
torn.is_empty() && failures.is_empty(), - 5465
"{} of {reads} concurrent reads saw a broken document (first: {:?}); \ - 5466
{} writes failed or were lost across {ROUNDS} rounds (first few: {:#?})", - 5467
torn.len(), - 5468
torn.first(), - 5469
failures.len(), - 5470
&failures[..failures.len().min(6)] - 5471
); - 5472
let left: Vec<_> = std::fs::read_dir(path.parent().unwrap()) - 5473
.unwrap() - 5474
.map(|entry| entry.unwrap().file_name()) - 5475
.collect(); - 5476
assert_eq!(left, ["config.toml"], "no temporary file is left behind"); - 5477
} - 5478
- 5479
/// The management API owns `command`, `args`, `env` and `network`. - 5480
/// Rewriting the server list must keep what it does not own: `serves`, - 5481
/// a key a later version adds, and any other key in `[mcp]` (invariant - 5482
/// 29). A single-server change must leave every other server alone. - 5483
#[test] - 5484
fn mcp_writers_keep_the_keys_they_do_not_own() { - 5485
use std::collections::BTreeMap; - 5486
let dir = tempfile::tempdir().unwrap(); - 5487
let path = dir.path().join("config.toml"); - 5488
std::fs::write( - 5489
&path, - 5490
"[mcp]\nfuture_mcp_key = true\n\n\ - 5491
[mcp.servers.search]\ncommand = \"old\"\nnetwork = true\n\ - 5492
serves = [\"web\"]\nfuture_server_key = 3\n\ - 5493
[mcp.servers.search.env]\nOLD = \"1\"\n\n\ - 5494
[mcp.servers.gone]\ncommand = \"gone\"\n", - 5495
) - 5496
.unwrap(); - 5497
let server = |command: &str| McpServerConfig { - 5498
command: command.into(), - 5499
args: vec!["--stdio".into()], - 5500
env: BTreeMap::new(), - 5501
network: false, - 5502
serves: Vec::new(), - 5503
}; - 5504
let read = - 5505
|| -> toml::Table { toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() }; - 5506
- 5507
persist_mcp_servers( - 5508
&path, - 5509
&BTreeMap::from([("search".to_string(), server("new"))]), - 5510
) - 5511
.unwrap(); - 5512
let document = read(); - 5513
let mcp = document["mcp"].as_table().unwrap(); - 5514
assert_eq!(mcp["future_mcp_key"].as_bool(), Some(true)); - 5515
let servers = mcp["servers"].as_table().unwrap(); - 5516
assert!( - 5517
!servers.contains_key("gone"), - 5518
"a server left out is removed" - 5519
); - 5520
let search = servers["search"].as_table().unwrap(); - 5521
assert_eq!(search["command"].as_str(), Some("new")); - 5522
assert_eq!(search["args"].as_array().unwrap().len(), 1); - 5523
assert!(!search.contains_key("env"), "an emptied env is cleared"); - 5524
assert!(!search.contains_key("network"), "network off is cleared"); - 5525
assert_eq!(search["serves"].as_array().unwrap().len(), 1); - 5526
assert_eq!(search["future_server_key"].as_integer(), Some(3)); - 5527
- 5528
persist_mcp_server(&path, "other", Some(&server("other"))).unwrap(); - 5529
persist_mcp_server(&path, "search", Some(&server("newer"))).unwrap(); - 5530
let document = read(); - 5531
let servers = document["mcp"]["servers"].as_table().unwrap(); - 5532
assert_eq!(servers["other"]["command"].as_str(), Some("other")); - 5533
assert_eq!(servers["search"]["command"].as_str(), Some("newer")); - 5534
assert_eq!(servers["search"]["future_server_key"].as_integer(), Some(3)); - 5535
- 5536
persist_mcp_server(&path, "other", None).unwrap(); - 5537
let document = read(); - 5538
let servers = document["mcp"]["servers"].as_table().unwrap(); - 5539
assert!(!servers.contains_key("other")); - 5540
assert!(servers.contains_key("search")); - 5541
assert_eq!(document["mcp"]["future_mcp_key"].as_bool(), Some(true)); - 5542
} - 5543
- 5544
/// Saving a value the file already holds is not a change, so the file, - 5545
/// comments included, is left exactly as the operator wrote it. - 5546
#[test] - 5547
fn saving_an_unchanged_setting_leaves_the_file_untouched() { - 5548
let dir = tempfile::tempdir().unwrap(); - 5549
let text = "# chosen by hand\nmodel = \"kept\" # keep this\n"; - 5550
write_project_config(dir.path(), text); - 5551
persist_project_preferences(dir.path(), None, Some("kept"), None, None, None, None) - 5552
.unwrap(); - 5553
assert_eq!( - 5554
std::fs::read_to_string(project_path(dir.path())).unwrap(), - 5555
text - 5556
); - 5557
persist_project_preferences(dir.path(), None, Some("changed"), None, None, None, None) - 5558
.unwrap(); - 5559
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5560
assert_eq!(cfg.model, "changed"); - 5561
} - 5562
} - 5563
- 5564
#[cfg(test)] - 5565
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 5566
mod channel_autonomy_tests { - 5567
use super::*; - 5568
- 5569
fn policy(ceiling: Option<&str>) -> ChannelPolicy { - 5570
ChannelPolicy { - 5571
autonomy_ceiling: ceiling.map(str::to_string), - 5572
..ChannelPolicy::default() - 5573
} - 5574
} - 5575
- 5576
/// Restrictive only, like every other key on this type. A chat may say - 5577
/// "propose only, in here"; it may never say "act freely" on a workspace - 5578
/// whose operator did not. - 5579
#[test] - 5580
fn a_channel_can_only_lower_autonomy_never_raise_it() { - 5581
let composed = ChannelPolicy::merge(&policy(Some("delegated")), &policy(Some("manual"))); - 5582
assert_eq!(composed.autonomy_ceiling.as_deref(), Some("manual")); - 5583
- 5584
// The narrower tier asking for MORE does not get it. - 5585
let composed = ChannelPolicy::merge(&policy(Some("manual")), &policy(Some("autonomous"))); - 5586
assert_eq!(composed.autonomy_ceiling.as_deref(), Some("manual")); - 5587
} - 5588
- 5589
#[test] - 5590
fn silence_on_one_tier_inherits_rather_than_permitting_everything() { - 5591
assert_eq!( - 5592
ChannelPolicy::merge(&policy(Some("assisted")), &policy(None)) - 5593
.autonomy_ceiling - 5594
.as_deref(), - 5595
Some("assisted") - 5596
); - 5597
assert_eq!( - 5598
ChannelPolicy::merge(&policy(None), &policy(Some("manual"))) - 5599
.autonomy_ceiling - 5600
.as_deref(), - 5601
Some("manual") - 5602
); - 5603
assert!( - 5604
ChannelPolicy::merge(&policy(None), &policy(None)) - 5605
.autonomy_ceiling - 5606
.is_none() - 5607
); - 5608
} - 5609
- 5610
/// An unparseable ceiling must not read as maximum delegation. - 5611
#[test] - 5612
fn an_unknown_ceiling_is_treated_as_assisted_not_autonomous() { - 5613
let composed = ChannelPolicy::merge(&policy(Some("banana")), &policy(Some("autonomous"))); - 5614
assert_eq!(composed.autonomy_ceiling.as_deref(), Some("banana")); - 5615
assert!(autonomy_rank("banana") < autonomy_rank("autonomous")); - 5616
} - 5617
- 5618
#[test] - 5619
fn plugin_resolved_allow_deny_network_matrix() { - 5620
let mut resolved = PluginResolved::default(); - 5621
assert!(resolved.is_enabled("test-plugin")); - 5622
assert!(!resolved.is_network_allowed("test-plugin")); - 5623
- 5624
// Enable network - 5625
resolved.network_allow = Some(vec!["test-plugin".into()]); - 5626
assert!(resolved.is_network_allowed("test-plugin")); - 5627
- 5628
// Global or channel deny takes priority - 5629
resolved.network_deny.push("test-plugin".into()); - 5630
assert!(!resolved.is_network_allowed("test-plugin")); - 5631
assert!(resolved.is_enabled("test-plugin")); - 5632
- 5633
// Disabling or denying plugin shuts it off completely - 5634
resolved.disabled.push("test-plugin".into()); - 5635
assert!(!resolved.is_enabled("test-plugin")); - 5636
assert!(!resolved.is_network_allowed("test-plugin")); - 5637
} - 5638
- 5639
#[test] - 5640
fn channel_policy_merges_plugin_network_and_allow_deny() { - 5641
let bot = ChannelPolicy { - 5642
plugins_allow: Some(vec!["test-plugin-a".into(), "test-plugin-b".into()]), - 5643
plugins_deny: vec!["untrusted-plugin".into()], - 5644
plugins_network_deny: vec!["test-plugin-b".into()], - 5645
..ChannelPolicy::default() - 5646
}; - 5647
let chat = ChannelPolicy { - 5648
plugins_allow: Some(vec!["test-plugin-a".into()]), - 5649
plugins_deny: vec!["banned-plugin".into()], - 5650
plugins_network_deny: vec!["test-plugin-a".into()], - 5651
..ChannelPolicy::default() - 5652
}; - 5653
let merged = ChannelPolicy::merge(&bot, &chat); - 5654
assert_eq!(merged.plugins_allow, Some(vec!["test-plugin-a".into()])); - 5655
assert!( - 5656
merged - 5657
.plugins_deny - 5658
.contains(&"untrusted-plugin".to_string()) - 5659
); - 5660
assert!(merged.plugins_deny.contains(&"banned-plugin".to_string())); - 5661
assert!( - 5662
merged - 5663
.plugins_network_deny - 5664
.contains(&"test-plugin-b".to_string()) - 5665
); - 5666
assert!( - 5667
merged - 5668
.plugins_network_deny - 5669
.contains(&"test-plugin-a".to_string()) - 5670
); - 5671
} - 5672
- 5673
/// The persisted TOML field was renamed from `subagents` to `workers`. - 5674
/// An existing `vak.toml` with the old key (and no `workers` key) must - 5675
/// still be honored rather than silently falling back to the default. - 5676
#[test] - 5677
fn legacy_subagents_key_is_honored_as_workers() { - 5678
let dir = tempfile::tempdir().unwrap(); - 5679
let path = project_path(dir.path()); - 5680
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 5681
std::fs::write(&path, "subagents = false\n").unwrap(); - 5682
- 5683
let (fc, warnings) = parse_file(&path).unwrap(); - 5684
assert_eq!(fc.workers, Some(false)); - 5685
assert!( - 5686
warnings.is_empty(), - 5687
"legacy `subagents` key should not warn as unknown: {warnings:?}" - 5688
); - 5689
- 5690
let cfg = load_with_trust(dir.path(), true).unwrap(); - 5691
assert!(!cfg.workers); - 5692
} - 5693
} - 5694
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.