- 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.