- 4522
fn channel_policy_merge_falls_back_to_bot_when_chat_is_unset() { - 4523
let bot = ChannelPolicy { - 4524
mcp_allow: Some(vec!["search/*".into()]), - 4525
..ChannelPolicy::default() - 4526
}; - 4527
let chat = ChannelPolicy::default(); - 4528
let merged = ChannelPolicy::merge(&bot, &chat); - 4529
assert_eq!(merged.mcp_allow, Some(vec!["search/*".to_string()])); - 4530
} - 4531
- 4532
#[test] - 4533
fn channel_policy_merge_of_two_defaults_is_default() { - 4534
assert_eq!( - 4535
ChannelPolicy::merge(&ChannelPolicy::default(), &ChannelPolicy::default()), - 4536
ChannelPolicy::default() - 4537
); - 4538
} - 4539
- 4540
fn write_project_config(dir: &Path, text: &str) { - 4541
let project = dir.join(".vak"); - 4542
std::fs::create_dir_all(&project).expect("project dir"); - 4543
std::fs::write(project.join("config.toml"), text).expect("write config"); - 4544
} - 4545
- 4546
#[test] - 4547
fn automation_update_tools_default_when_absent() { - 4548
let dir = tempfile::tempdir().unwrap(); - 4549
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4550
assert!(cfg.automation.catch_up_missed); - 4551
assert_eq!(cfg.update.url, None); - 4552
assert_eq!(cfg.update.interval_hours, 24); - 4553
assert!(cfg.tools.web_fetch); - 4554
assert!(cfg.tools.browse); - 4555
assert!( - 4556
!cfg.warnings.iter().any(|w| w.contains("automation")), - 4557
"absent sections must not warn: {:?}", - 4558
cfg.warnings - 4559
); - 4560
} - 4561
- 4562
#[test] - 4563
fn work_settings_parse_and_unknown_keys_warn() { - 4564
let dir = tempfile::tempdir().unwrap(); - 4565
write_project_config( - 4566
dir.path(), - 4567
"[work]\nenabled = true\ndefault_mode = \"managed\"\nmax_items = 12\nmax_revisions = 5\nmax_parallel = 3\nconfirmation = \"always\"\nunknown = true\n", - 4568
); - 4569
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4570
assert!(cfg.work.enabled); - 4571
assert_eq!(cfg.work.default_mode, "managed"); - 4572
assert_eq!(cfg.work.max_items, 12); - 4573
assert_eq!(cfg.work.max_revisions, 5); - 4574
assert_eq!(cfg.work.max_parallel, 3); - 4575
assert_eq!(cfg.work.confirmation, "always"); - 4576
assert!( - 4577
cfg.warnings - 4578
.iter() - 4579
.any(|warning| warning.contains("work.unknown")) - 4580
); - 4581
} - 4582
- 4583
#[test] - 4584
fn intent_section_parses_and_clamps() { - 4585
let dir = tempfile::tempdir().unwrap(); - 4586
write_project_config( - 4587
dir.path(), - 4588
"[intent]\nenabled = true\naccept_confidence = 0.9\n\ - 4589
provisional_confidence = 0.99\nslice_capabilities = false\n\ - 4590
escalate = \"local\"\nautonomy = \"delegated\"\n", - 4591
); - 4592
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4593
assert!(cfg.intent.enabled); - 4594
assert_eq!(cfg.intent.accept_confidence, 0.9); - 4595
// Inverted thresholds clamp rather than break the ordering invariant. - 4596
assert!(cfg.intent.provisional_confidence <= cfg.intent.accept_confidence); - 4597
assert!(!cfg.intent.slice_capabilities); - 4598
assert_eq!(cfg.intent.escalate, "local"); - 4599
assert_eq!(cfg.intent.autonomy, "delegated"); - 4600
} - 4601
- 4602
#[test] - 4603
fn unknown_intent_values_warn_and_fall_back_to_the_safe_default() { - 4604
let dir = tempfile::tempdir().unwrap(); - 4605
write_project_config( - 4606
dir.path(), - 4607
"[intent]\nescalate = \"telepathy\"\nautonomy = \"unlimited\"\n", - 4608
); - 4609
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4610
assert_eq!(cfg.intent.escalate, "none"); - 4611
assert_eq!(cfg.intent.autonomy, "assisted"); - 4612
assert!(cfg.warnings.iter().any(|w| w.contains("intent.escalate"))); - 4613
assert!(cfg.warnings.iter().any(|w| w.contains("intent.autonomy"))); - 4614
} - 4615
- 4616
/// A cloned repository must not be able to grant itself the right to act - 4617
/// without asking, nor to spend the user's credentials classifying. - 4618
#[test] - 4619
fn an_untrusted_project_cannot_grant_itself_autonomy_or_a_paid_classifier() { - 4620
let dir = tempfile::tempdir().unwrap(); - 4621
write_project_config( - 4622
dir.path(), - 4623
"[intent]\nautonomy = \"autonomous\"\nescalate = \"cloud\"\n\ - 4624
slice_capabilities = false\n", - 4625
); - 4626
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4627
assert_eq!(cfg.intent.autonomy, "assisted"); - 4628
assert_eq!(cfg.intent.escalate, "none"); - 4629
// The non-privileged half of the section still applies: choosing to - 4630
// see more of your own tools grants nothing. - 4631
assert!(!cfg.intent.slice_capabilities); - 4632
- 4633
// And with trust, the same file does take effect. - 4634
let trusted = load_with_trust(dir.path(), true).unwrap(); - 4635
assert_eq!(trusted.intent.autonomy, "autonomous"); - 4636
assert_eq!(trusted.intent.escalate, "cloud"); - 4637
} - 4638
- 4639
/// Switching the kernel or its posture off removes the approval floor it - 4640
/// raises for irreversible work; a cloned repository may not do that to - 4641
/// itself, and a trusted one still may. - 4642
#[test] - 4643
fn an_untrusted_project_cannot_switch_the_intent_floor_off() { - 4644
let dir = tempfile::tempdir().unwrap(); - 4645
write_project_config(dir.path(), "[intent]\nenabled = false\nposture = false\n"); - 4646
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4647
assert!(cfg.intent.enabled); - 4648
assert!(cfg.intent.posture); - 4649
let trusted = load_with_trust(dir.path(), true).unwrap(); - 4650
assert!(!trusted.intent.enabled); - 4651
assert!(!trusted.intent.posture); - 4652
} - 4653
- 4654
/// TOML can spell `nan` and `inf`. A threshold or a cap every comparison - 4655
/// is false against would silently switch its check off, so a - 4656
/// non-finite value falls back to the default with a warning. - 4657
#[test] - 4658
fn non_finite_intent_numbers_fall_back_to_their_defaults() { - 4659
let dir = tempfile::tempdir().unwrap(); - 4660
write_project_config( - 4661
dir.path(), - 4662
"[intent]\naccept_confidence = nan\nprovisional_confidence = inf\n\ - 4663
max_classify_usd = nan\n[commitment]\nlifetime_budget_usd = nan\n", - 4664
); - 4665
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4666
assert_eq!(cfg.intent.accept_confidence, 0.75); - 4667
assert_eq!(cfg.intent.provisional_confidence, 0.45); - 4668
assert_eq!(cfg.intent.max_classify_usd, 0.01); - 4669
assert_eq!(cfg.commitment.lifetime_budget_usd, None); - 4670
assert!( - 4671
cfg.warnings - 4672
.iter() - 4673
.any(|w| w.contains("intent.accept_confidence")), - 4674
"{:?}", - 4675
cfg.warnings - 4676
); - 4677
} - 4678
- 4679
#[test] - 4680
fn an_untrusted_project_may_still_restrict_itself_to_a_local_classifier() { - 4681
let dir = tempfile::tempdir().unwrap(); - 4682
write_project_config(dir.path(), "[intent]\nescalate = \"local\"\n"); - 4683
let cfg = load_with_trust(dir.path(), false).unwrap(); - 4684
assert_eq!(cfg.intent.escalate, "local"); - 4685
} - 4686
- 4687
#[test] - 4688
fn commitment_section_parses() { - 4689
let dir = tempfile::tempdir().unwrap(); - 4690
write_project_config( - 4691
dir.path(), - 4692
"[commitment]\nlifetime_budget_usd = 25.0\nstall_limit = 5\n\ - 4693
review_every_hours = 24\ndefault_ttl_days = 30\n", - 4694
); - 4695
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4696
assert_eq!(cfg.commitment.lifetime_budget_usd, Some(25.0)); - 4697
assert_eq!(cfg.commitment.stall_limit, 5); - 4698
assert_eq!(cfg.commitment.review_every_hours, Some(24)); - 4699
assert_eq!(cfg.commitment.default_ttl_days, Some(30)); - 4700
} - 4701
- 4702
/// The kernel must be switchable off entirely, because "reproduce the old - 4703
/// behaviour exactly" has to remain one line of config. - 4704
#[test] - 4705
fn the_intent_kernel_can_be_switched_off() { - 4706
let dir = tempfile::tempdir().unwrap(); - 4707
write_project_config(dir.path(), "[intent]\nenabled = false\n"); - 4708
assert!(!load_with_trust(dir.path(), true).unwrap().intent.enabled); - 4709
} - 4710
- 4711
#[test] - 4712
fn automation_catch_up_missed_parses() { - 4713
let dir = tempfile::tempdir().unwrap(); - 4714
write_project_config(dir.path(), "[automation]\ncatch_up_missed = false\n"); - 4715
let cfg = load_with_trust(dir.path(), true).unwrap(); - 4716
assert!(!cfg.automation.catch_up_missed); - 4717
} - 4718
- 4719
#[test] - 4720
fn update_url_and_interval_hours_parse() { - 4721
let dir = tempfile::tempdir().unwrap(); - 4722
write_project_config( - 4723
dir.path(), - 4724
"[update]\nurl = \"https://example.com/manifest.json\"\ninterval_hours = 6\n", - 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"));
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.