- 4103
} - 4104
- 4105
/// Process-wide extra environment sourced from .env files. Real - 4106
/// environment variables always take precedence. - 4107
type ExtraMap = std::collections::BTreeMap<String, String>; - 4108
- 4109
fn dotenv_extra() -> std::sync::MutexGuard<'static, ExtraMap> { - 4110
static EXTRA: std::sync::OnceLock<std::sync::Mutex<ExtraMap>> = std::sync::OnceLock::new(); - 4111
EXTRA - 4112
.get_or_init(|| std::sync::Mutex::new(ExtraMap::new())) - 4113
.lock() - 4114
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4115
} - 4116
- 4117
/// Loads a scope's stored secrets (docs/design/44-shared-config.md, - 4118
/// "Secrets Chain") into the extra-env table. `path` is a scope hint — the - 4119
/// directory that used to hold a literal `.env` file — not a file read - 4120
/// directly; see [`credentials`]. Existing real environment variables are - 4121
/// never overridden. Only the encrypted-file credential backend can - 4122
/// enumerate a scope's contents; an OS-native secret service is reached by - 4123
/// point lookup only, so bulk-seeding this cache has no effect there (see - 4124
/// `CredentialStore::list`) — callers needing a specific key from that - 4125
/// backend should resolve it explicitly instead of relying on this cache. - 4126
pub fn load_env_file(path: &std::path::Path) { - 4127
// `credentials::list` can itself take the `dotenv_extra` lock further - 4128
// down (e.g. `EncryptedFileStore::new` reads `VAK_HOME` via `get_var` - 4129
// on first use) — it must run to completion BEFORE this function takes - 4130
// that lock itself, or a thread deadlocks against its own held guard. - 4131
let entries = credentials::list(path); - 4132
let mut extra = dotenv_extra(); - 4133
for (key, value) in entries { - 4134
extra.entry(key).or_insert(value); - 4135
} - 4136
} - 4137
- 4138
/// Replaces credential-sourced environment values as one atomic scope - 4139
/// change. Runtime overrides and real environment variables remain - 4140
/// untouched. - 4141
pub fn replace_env_files(paths: &[&std::path::Path]) { - 4142
// Same ordering requirement as `load_env_file` above: resolve every - 4143
// scope's entries before touching the `dotenv_extra` lock. - 4144
let entries: Vec<_> = paths - 4145
.iter() - 4146
.flat_map(|path| credentials::list(path)) - 4147
.collect(); - 4148
let mut extra = dotenv_extra(); - 4149
extra.clear(); - 4150
for (key, value) in entries { - 4151
extra.entry(key).or_insert(value); - 4152
} - 4153
} - 4154
- 4155
/// Environment lookup: runtime overrides first (values set this session, - 4156
/// e.g. a key the user just saved), then real env, then loaded .env files. - 4157
pub fn get_var(key: &str) -> Option<String> { - 4158
var_overrides() - 4159
.get(key) - 4160
.cloned() - 4161
.or_else(|| std::env::var(key).ok()) - 4162
.or_else(|| dotenv_extra().get(key).cloned()) - 4163
} - 4164
- 4165
fn var_overrides() -> std::sync::MutexGuard<'static, ExtraMap> { - 4166
static OVERRIDES: std::sync::OnceLock<std::sync::Mutex<ExtraMap>> = std::sync::OnceLock::new(); - 4167
OVERRIDES - 4168
.get_or_init(|| std::sync::Mutex::new(ExtraMap::new())) - 4169
.lock() - 4170
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4171
} - 4172
- 4173
/// Registers a value with the highest lookup precedence for THIS process. - 4174
/// Persistence is the caller's job (see `upsert_env_file`); overrides die - 4175
/// with the process, and a real environment variable set after this call - 4176
/// still loses to it until the process restarts. - 4177
pub fn set_override(key: impl Into<String>, value: impl Into<String>) { - 4178
var_overrides().insert(key.into(), value.into()); - 4179
} - 4180
- 4181
/// Drops a runtime override so lookups fall back to the real environment - 4182
/// and .env files again. Used when a key is revoked mid-session. - 4183
pub fn clear_override(key: &str) { - 4184
var_overrides().remove(key); - 4185
} - 4186
- 4187
/// Forgets a key loaded from a .env file earlier this session. Without - 4188
/// this a revoked key keeps resolving from the in-memory dotenv map. - 4189
pub fn forget_dotenv_var(key: &str) { - 4190
dotenv_extra().remove(key); - 4191
} - 4192
- 4193
/// Scope hint for the shared secret layer inherited by every workspace - 4194
/// (docs/design/44-shared-config.md, "Secrets Chain"). No file is written - 4195
/// at this literal path anymore — it only identifies the scope passed to - 4196
/// [`credentials`]; kept as a `~/vak-home/.env`-shaped path so existing - 4197
/// callers' scoping (same directory = same layer) is unchanged. - 4198
pub fn user_env_path() -> Option<std::path::PathBuf> { - 4199
Some(crate::paths::default_workspace().join(".env")) - 4200
} - 4201
- 4202
/// Reads one credential from a specific scope without merging it into the - 4203
/// process-wide environment cache. Scoped MCP credentials use this so two - 4204
/// pooled workspaces can resolve different values for the same variable. - 4205
/// `path` is a scope hint (previously a literal `.env` path), not a file - 4206
/// read directly — see [`credentials`]. - 4207
pub fn read_env_file_var(path: &std::path::Path, key: &str) -> Option<String> { - 4208
credentials::get(path, key) - 4209
} - 4210
- 4211
/// Removes `key` from the scope named by `path`. A missing scope or key is - 4212
/// a no-op success. - 4213
pub fn remove_env_file_key(path: &std::path::Path, key: &str) -> std::io::Result<()> { - 4214
credentials::remove(path, key) - 4215
} - 4216
- 4217
/// Upserts `key = value` into the scope named by `path`, persisted through - 4218
/// whichever [`credentials::CredentialStore`] backend this host resolved - 4219
/// (OS-native secret service, or the encrypted-file fallback) — never as - 4220
/// plaintext. - 4221
pub fn upsert_env_file(path: &std::path::Path, key: &str, value: &str) -> std::io::Result<()> { - 4222
credentials::set(path, key, value) - 4223
} - 4224
- 4225
#[cfg(test)] - 4226
#[allow( - 4227
clippy::unwrap_used, - 4228
clippy::expect_used, - 4229
clippy::field_reassign_with_default - 4230
)] - 4231
mod tests { - 4232
#[test] - 4233
fn voice_defaults_are_safe_and_serializable() { - 4234
let settings = VoiceSettings::default(); - 4235
assert!(!settings.enabled); - 4236
assert_eq!(settings.max_session_secs, 900); - 4237
assert_eq!(settings.max_concurrent, 2); - 4238
assert_eq!(settings.max_audio_bytes, 16 * 1024 * 1024); - 4239
assert_eq!(settings.max_requests_per_minute, 60); - 4240
assert_eq!(settings.max_text_chars, 100_000); - 4241
assert!(settings.validate().is_ok()); - 4242
let encoded = toml::to_string(&settings).expect("voice settings serialize"); - 4243
let decoded: VoiceSettings = toml::from_str(&encoded).expect("voice settings deserialize"); - 4244
assert_eq!(decoded, settings); - 4245
let routed = VoiceSettings { - 4246
provider: Some("openai".into()), - 4247
transcription_model: Some("stt-v1".into()), - 4248
..settings - 4249
}; - 4250
let encoded = toml::to_string(&routed).expect("routed voice settings serialize"); - 4251
let decoded: VoiceSettings = - 4252
toml::from_str(&encoded).expect("routed voice settings deserialize"); - 4253
assert_eq!(decoded.provider.as_deref(), Some("openai")); - 4254
assert_eq!(decoded.transcription_model.as_deref(), Some("stt-v1")); - 4255
assert!(decoded.validate().is_ok()); - 4256
} - 4257
- 4258
#[test] - 4259
fn ollama_settings_validate_accepts_go_style_durations() { - 4260
for keep_alive in ["10m", "24h", "0", "1h30m", "500ms", "90s"] { - 4261
let settings = OllamaSettings { - 4262
keep_alive: Some(keep_alive.to_string()), - 4263
num_ctx: Some(4096), - 4264
}; - 4265
assert!(settings.validate().is_ok(), "{keep_alive} should be valid"); - 4266
} - 4267
} - 4268
- 4269
#[test] - 4270
fn ollama_settings_validate_rejects_malformed_duration_and_small_num_ctx() { - 4271
let settings = OllamaSettings { - 4272
keep_alive: Some("forever".into()), - 4273
num_ctx: None, - 4274
}; - 4275
assert!(settings.validate().is_err()); - 4276
- 4277
let settings = OllamaSettings { - 4278
keep_alive: None, - 4279
num_ctx: Some(1023), - 4280
}; - 4281
assert!(settings.validate().is_err()); - 4282
- 4283
let settings = OllamaSettings { - 4284
keep_alive: None, - 4285
num_ctx: Some(1024), - 4286
}; - 4287
assert!(settings.validate().is_ok()); - 4288
} - 4289
- 4290
#[test] - 4291
fn voice_validation_rejects_zero_and_excessive_limits() { - 4292
let mut settings = VoiceSettings::default(); - 4293
settings.max_concurrent = 0; - 4294
assert!(settings.validate().is_err()); - 4295
settings = VoiceSettings::default(); - 4296
settings.max_audio_bytes = 512 * 1024 * 1024; - 4297
assert!(settings.validate().is_err()); - 4298
settings = VoiceSettings::default(); - 4299
settings.max_requests_per_minute = 0; - 4300
assert!(settings.validate().is_err()); - 4301
settings = VoiceSettings::default(); - 4302
settings.max_text_chars = 0; - 4303
assert!(settings.validate().is_err()); - 4304
settings = VoiceSettings { - 4305
provider: Some(" ".into()), - 4306
..VoiceSettings::default() - 4307
}; - 4308
assert!(settings.validate().is_err()); - 4309
settings = VoiceSettings { - 4310
synthesis_model: Some("x".repeat(257)), - 4311
..VoiceSettings::default() - 4312
}; - 4313
assert!(settings.validate().is_err()); - 4314
} - 4315
- 4316
#[test] - 4317
fn voice_patch_sets_clears_and_preserves_keys() { - 4318
let dir = tempfile::tempdir().unwrap(); - 4319
let path = dir.path().join("config.toml"); - 4320
std::fs::write( - 4321
&path, - 4322
"[voice]\nprovider = \"gemini\"\nsynthesis_model = \"tts-a\"\nfuture_key = 7\n", - 4323
) - 4324
.unwrap(); - 4325
persist_voice_settings_at( - 4326
path.clone(), - 4327
&VoicePatch { - 4328
enabled: Some(true), - 4329
transcription_model: Some(Some("stt-a".into())), - 4330
synthesis_model: Some(None), - 4331
..VoicePatch::default() - 4332
}, - 4333
) - 4334
.unwrap(); - 4335
let value: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4336
let voice = value["voice"].as_table().unwrap(); - 4337
assert_eq!(voice["enabled"].as_bool(), Some(true)); - 4338
assert_eq!(voice["provider"].as_str(), Some("gemini")); - 4339
assert_eq!(voice["transcription_model"].as_str(), Some("stt-a")); - 4340
assert!(!voice.contains_key("synthesis_model")); - 4341
assert_eq!(voice["future_key"].as_integer(), Some(7)); - 4342
assert!(VoicePatch::default().is_empty()); - 4343
} - 4344
use super::*; - 4345
- 4346
#[test] - 4347
fn ensure_project_config_creates_inheriting_layer_without_overwriting_it() { - 4348
let project = tempfile::tempdir().unwrap(); - 4349
let path = ensure_project_config(project.path()).unwrap(); - 4350
assert_eq!(path, project.path().join(".vak/config.toml")); - 4351
assert_eq!( - 4352
std::fs::read_to_string(&path).unwrap(), - 4353
"# Project-local overrides. Unset values inherit from the user config.\n" - 4354
); - 4355
- 4356
std::fs::write(&path, "provider = \"ollama\"\n").unwrap(); - 4357
ensure_project_config(project.path()).unwrap(); - 4358
assert_eq!( - 4359
std::fs::read_to_string(path).unwrap(), - 4360
"provider = \"ollama\"\n" - 4361
); - 4362
} - 4363
- 4364
#[test] - 4365
fn evidence_policy_writer_preserves_other_layers_and_clamps_negative() { - 4366
let project = tempfile::tempdir().unwrap(); - 4367
let path = project.path().join(".vak/config.toml"); - 4368
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 4369
std::fs::write(&path, "provider = \"ollama\"\n\n[ui]\ntheme = \"dark\"\n").unwrap(); - 4370
persist_evidence_max_age(path.clone(), -5).unwrap(); - 4371
let text = std::fs::read_to_string(path).unwrap(); - 4372
assert!(text.contains("provider = \"ollama\"")); - 4373
assert!(text.contains("theme = \"dark\"")); - 4374
assert!(text.contains("evidence_max_age_secs = 0")); - 4375
} - 4376
- 4377
#[test] - 4378
fn evidence_policy_resolves_from_project_layer() { - 4379
let project = tempfile::tempdir().unwrap(); - 4380
let path = project.path().join(".vak/config.toml"); - 4381
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 4382
std::fs::write(&path, "[intent]\nevidence_max_age_secs = 120\n").unwrap(); - 4383
let config = load_with_trust(project.path(), true).unwrap(); - 4384
assert_eq!(config.intent.evidence_max_age_secs, 120); - 4385
assert!(config.intent.enabled); - 4386
} - 4387
- 4388
/// `capped_by` is the single arithmetic the gateway's per-channel - 4389
/// permission override rests on: it must be a true `min` over the - 4390
/// permissiveness ranking, in both argument orders, for every pair. - 4391
/// The whole point of a scoped writer: it must not disturb keys it was - 4392
/// not asked about. This one had no writer at all, so the only way to - 4393
/// set it was hand-editing the file — and hand-editing is exactly what - 4394
/// loses the rest of the document to a typo. - 4395
#[test] - 4396
fn writing_gateway_approvals_leaves_every_other_key_alone() { - 4397
let dir = tempfile::tempdir().unwrap(); - 4398
let path = dir.path().join("config.toml"); - 4399
std::fs::write( - 4400
&path, - 4401
"provider = \"anthropic\"\npermission_mode = \"read-only\"\n\n [gateway]\nenabled = true\n\n[mcp.servers.thing]\ncommand = \"sh\"\n", - 4402
) - 4403
.unwrap(); - 4404
- 4405
persist_gateway_approvals( - 4406
path.clone(), - 4407
Some("forward"), - 4408
Some(Some("telegram:42")), - 4409
Some(120), - 4410
) - 4411
.unwrap(); - 4412
- 4413
let text = std::fs::read_to_string(&path).unwrap(); - 4414
let parsed: toml::Value = toml::from_str(&text).unwrap(); - 4415
let gw = parsed.get("gateway").unwrap(); - 4416
assert_eq!(gw.get("approvals").unwrap().as_str(), Some("forward")); - 4417
assert_eq!(gw.get("approver").unwrap().as_str(), Some("telegram:42")); - 4418
assert_eq!( - 4419
gw.get("approval_timeout_secs").unwrap().as_integer(), - 4420
Some(120) - 4421
); - 4422
assert_eq!(gw.get("enabled").unwrap().as_bool(), Some(true)); - 4423
assert_eq!(parsed.get("provider").unwrap().as_str(), Some("anthropic")); - 4424
assert!(parsed.get("mcp").is_some(), "unrelated tables survive"); - 4425
} - 4426
- 4427
/// `Some(None)` clears the target, so returning to `deny` cannot leave a - 4428
/// stale chat behind for a later `forward` to reuse silently. - 4429
#[test] - 4430
fn clearing_the_approver_removes_the_key() { - 4431
let dir = tempfile::tempdir().unwrap(); - 4432
let path = dir.path().join("config.toml"); - 4433
persist_gateway_approvals( - 4434
path.clone(), - 4435
Some("forward"), - 4436
Some(Some("telegram:1")), - 4437
None, - 4438
) - 4439
.unwrap(); - 4440
persist_gateway_approvals(path.clone(), Some("deny"), Some(None), None).unwrap(); - 4441
- 4442
let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4443
let gw = parsed.get("gateway").unwrap(); - 4444
assert_eq!(gw.get("approvals").unwrap().as_str(), Some("deny")); - 4445
assert!(gw.get("approver").is_none()); - 4446
} - 4447
- 4448
/// Absent means "leave alone"; present replaces the list wholesale, so - 4449
/// an empty vec is how a list is cleared. - 4450
#[test] - 4451
fn writing_rule_lists_replaces_only_the_lists_named() { - 4452
let dir = tempfile::tempdir().unwrap(); - 4453
let path = dir.path().join("config.toml"); - 4454
std::fs::write(&path, "allow = [\"Bash(git *)\"]\nask = [\"Edit(~/**)\"]\n").unwrap(); - 4455
- 4456
persist_permission_rules(path.clone(), None, None, Some(&["Bash(rm *)".to_string()])) - 4457
.unwrap(); - 4458
let cfg: FileConfig = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4459
assert_eq!(cfg.allow, vec!["Bash(git *)"], "untouched"); - 4460
assert_eq!(cfg.ask, vec!["Edit(~/**)"], "untouched"); - 4461
assert_eq!(cfg.deny, vec!["Bash(rm *)"]); - 4462
- 4463
persist_permission_rules(path.clone(), Some(&[]), None, None).unwrap(); - 4464
let cfg: FileConfig = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - 4465
assert!(cfg.allow.is_empty(), "an empty list clears"); - 4466
assert_eq!(cfg.deny, vec!["Bash(rm *)"], "and still nothing else moved"); - 4467
} - 4468
- 4469
#[test] - 4470
fn capped_by_is_min_over_permissiveness_and_never_escalates() { - 4471
use PermissionMode::*; - 4472
let all = [ReadOnly, WorkspaceWrite, FullAccess]; - 4473
assert!(ReadOnly.rank() < WorkspaceWrite.rank()); - 4474
assert!(WorkspaceWrite.rank() < FullAccess.rank()); - 4475
for requested in all { - 4476
for ceiling in all { - 4477
let got = requested.capped_by(ceiling); - 4478
// Never more permissive than the ceiling — the whole point. - 4479
assert!( - 4480
got.rank() <= ceiling.rank(), - 4481
"{requested:?} capped by {ceiling:?} escalated to {got:?}" - 4482
); - 4483
// Never more permissive than what was asked for either. - 4484
assert!(got.rank() <= requested.rank()); - 4485
// And it is exactly the min, not an over-eager clamp. - 4486
assert_eq!(got.rank(), requested.rank().min(ceiling.rank())); - 4487
} - 4488
} - 4489
// Concrete spot checks of the security-relevant direction. - 4490
assert_eq!(FullAccess.capped_by(ReadOnly), ReadOnly); - 4491
assert_eq!(FullAccess.capped_by(WorkspaceWrite), WorkspaceWrite); - 4492
assert_eq!(ReadOnly.capped_by(FullAccess), ReadOnly); - 4493
assert_eq!(WorkspaceWrite.capped_by(WorkspaceWrite), WorkspaceWrite); - 4494
} - 4495
- 4496
/// `ChannelPolicy::merge` is the bot→chat fold used at dispatch - 4497
/// (`core_for_entry`): the chat's own `_allow` wins when set, denies - 4498
/// concatenate, and an unset chat field falls back to the bot's. - 4499
#[test] - 4500
fn channel_policy_merge_lets_chat_allow_win_and_denies_accumulate() { - 4501
let bot = ChannelPolicy { - 4502
tools_allow: Some(vec!["read".into(), "write".into()]), - 4503
tools_deny: vec!["shell".into()], - 4504
..ChannelPolicy::default() - 4505
}; - 4506
let chat = ChannelPolicy { - 4507
tools_allow: Some(vec!["read".into()]), - 4508
tools_deny: vec!["fetch".into()], - 4509
..ChannelPolicy::default() - 4510
}; - 4511
let merged = ChannelPolicy::merge(&bot, &chat); - 4512
// Chat's narrower allow list wins outright. - 4513
assert_eq!(merged.tools_allow, Some(vec!["read".to_string()])); - 4514
// Denies from both tiers accumulate — restrictive-only. - 4515
assert_eq!( - 4516
merged.tools_deny, - 4517
vec!["shell".to_string(), "fetch".to_string()] - 4518
); - 4519
} - 4520
- 4521
#[test] - 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.
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.