- 3492
"{}: unknown ui key 'ui.{key}' (ignored)", - 3493
path.display() - 3494
)); - 3495
} - 3496
} - 3497
if let Some(acc) = ui.get("accessibility").and_then(toml::Value::as_table) { - 3498
for key in acc.keys() { - 3499
if !KNOWN_ACCESSIBILITY_KEYS.contains(&key.as_str()) { - 3500
out.push(format!( - 3501
"{}: unknown ui.accessibility key 'ui.accessibility.{key}' (ignored)", - 3502
path.display() - 3503
)); - 3504
} - 3505
} - 3506
} - 3507
if let Some(themes) = ui.get("themes").and_then(toml::Value::as_table) { - 3508
for (name, t) in themes { - 3509
if let Some(t) = t.as_table() { - 3510
for key in t.keys() { - 3511
if !KNOWN_THEME_COLORS.contains(&key.as_str()) { - 3512
out.push(format!( - 3513
"{}: unknown theme color 'ui.themes.{name}.{key}' (ignored)", - 3514
path.display() - 3515
)); - 3516
} - 3517
} - 3518
} - 3519
} - 3520
} - 3521
} - 3522
if let Some(sp) = top.get("stop_policy").and_then(toml::Value::as_table) { - 3523
for key in sp.keys() { - 3524
if !KNOWN_STOP_POLICY_KEYS.contains(&key.as_str()) { - 3525
out.push(format!( - 3526
"{}: unknown stop_policy key 'stop_policy.{key}' (ignored)", - 3527
path.display() - 3528
)); - 3529
} - 3530
} - 3531
} - 3532
if let Some(gl) = top.get("goal").and_then(toml::Value::as_table) { - 3533
for key in gl.keys() { - 3534
if !KNOWN_GOAL_KEYS.contains(&key.as_str()) { - 3535
out.push(format!( - 3536
"{}: unknown goal key 'goal.{key}' (ignored)", - 3537
path.display() - 3538
)); - 3539
} - 3540
} - 3541
} - 3542
if let Some(fo) = top.get("finops").and_then(toml::Value::as_table) { - 3543
for key in fo.keys() { - 3544
if !KNOWN_FINOPS_KEYS.contains(&key.as_str()) { - 3545
out.push(format!( - 3546
"{}: unknown finops key 'finops.{key}' (ignored)", - 3547
path.display() - 3548
)); - 3549
} - 3550
} - 3551
} - 3552
if let Some(work) = top.get("work").and_then(toml::Value::as_table) { - 3553
for key in work.keys() { - 3554
if !KNOWN_WORK_KEYS.contains(&key.as_str()) { - 3555
out.push(format!( - 3556
"{}: unknown work key 'work.{key}' (ignored)", - 3557
path.display() - 3558
)); - 3559
} - 3560
} - 3561
} - 3562
if let Some(gw) = top.get("gateway").and_then(toml::Value::as_table) { - 3563
for key in gw.keys() { - 3564
if !KNOWN_GATEWAY_KEYS.contains(&key.as_str()) { - 3565
out.push(format!( - 3566
"{}: unknown gateway key 'gateway.{key}' (ignored)", - 3567
path.display() - 3568
)); - 3569
} - 3570
} - 3571
if let Some(ob) = gw.get("outbound").and_then(toml::Value::as_table) { - 3572
for key in ob.keys() { - 3573
if !KNOWN_OUTBOUND_KEYS.contains(&key.as_str()) { - 3574
out.push(format!( - 3575
"{}: unknown gateway.outbound key 'gateway.outbound.{key}' (ignored)", - 3576
path.display() - 3577
)); - 3578
} - 3579
} - 3580
if let Some(hooks) = ob.get("webhooks").and_then(toml::Value::as_table) { - 3581
for (name, t) in hooks { - 3582
if let Some(t) = t.as_table() { - 3583
for key in t.keys() { - 3584
if !KNOWN_WEBHOOK_KEYS.contains(&key.as_str()) { - 3585
out.push(format!( - 3586
"{}: unknown webhook key 'gateway.outbound.webhooks.{name}.{key}' (ignored)", - 3587
path.display() - 3588
)); - 3589
} - 3590
} - 3591
} - 3592
} - 3593
} - 3594
} - 3595
} - 3596
if let Some(mem) = top.get("memory").and_then(toml::Value::as_table) { - 3597
for key in mem.keys() { - 3598
if !KNOWN_MEMORY_KEYS.contains(&key.as_str()) { - 3599
out.push(format!( - 3600
"{}: unknown memory key 'memory.{key}' (ignored)", - 3601
path.display() - 3602
)); - 3603
} - 3604
} - 3605
} - 3606
if let Some(sb) = top.get("sandbox").and_then(toml::Value::as_table) { - 3607
for key in sb.keys() { - 3608
if !KNOWN_SANDBOX_KEYS.contains(&key.as_str()) { - 3609
out.push(format!( - 3610
"{}: unknown sandbox key 'sandbox.{key}' (ignored)", - 3611
path.display() - 3612
)); - 3613
} - 3614
} - 3615
} - 3616
if let Some(t) = top.get("automation").and_then(toml::Value::as_table) { - 3617
for key in t.keys() { - 3618
if !KNOWN_AUTOMATION_KEYS.contains(&key.as_str()) { - 3619
out.push(format!( - 3620
"{}: unknown automation key 'automation.{key}' (ignored)", - 3621
path.display() - 3622
)); - 3623
} - 3624
} - 3625
} - 3626
if let Some(t) = top.get("update").and_then(toml::Value::as_table) { - 3627
for key in t.keys() { - 3628
if !KNOWN_UPDATE_KEYS.contains(&key.as_str()) { - 3629
out.push(format!( - 3630
"{}: unknown update key 'update.{key}' (ignored)", - 3631
path.display() - 3632
)); - 3633
} - 3634
} - 3635
} - 3636
if let Some(t) = top.get("tools").and_then(toml::Value::as_table) { - 3637
for key in t.keys() { - 3638
if !KNOWN_TOOLS_KEYS.contains(&key.as_str()) { - 3639
out.push(format!( - 3640
"{}: unknown tools key 'tools.{key}' (ignored)", - 3641
path.display() - 3642
)); - 3643
} - 3644
} - 3645
} - 3646
if let Some(t) = top.get("heartbeat").and_then(toml::Value::as_table) { - 3647
for key in t.keys() { - 3648
if !KNOWN_HEARTBEAT_KEYS.contains(&key.as_str()) { - 3649
out.push(format!( - 3650
"{}: unknown heartbeat key 'heartbeat.{key}' (ignored)", - 3651
path.display() - 3652
)); - 3653
} - 3654
} - 3655
} - 3656
if let Some(t) = top.get("plugins").and_then(toml::Value::as_table) { - 3657
for key in t.keys() { - 3658
if !KNOWN_PLUGINS_KEYS.contains(&key.as_str()) { - 3659
out.push(format!( - 3660
"{}: unknown plugins key 'plugins.{key}' (ignored)", - 3661
path.display() - 3662
)); - 3663
} - 3664
} - 3665
} - 3666
if let Some(t) = top.get("providers").and_then(toml::Value::as_table) { - 3667
for key in t.keys() { - 3668
if !KNOWN_PROVIDERS_KEYS.contains(&key.as_str()) { - 3669
out.push(format!( - 3670
"{}: unknown providers key 'providers.{key}' (ignored)", - 3671
path.display() - 3672
)); - 3673
} - 3674
} - 3675
if let Some(t) = t.get("ollama").and_then(toml::Value::as_table) { - 3676
for key in t.keys() { - 3677
if !KNOWN_OLLAMA_KEYS.contains(&key.as_str()) { - 3678
out.push(format!( - 3679
"{}: unknown providers key 'providers.ollama.{key}' (ignored)", - 3680
path.display() - 3681
)); - 3682
} - 3683
} - 3684
} - 3685
if let Some(t) = t.get("anthropic").and_then(toml::Value::as_table) { - 3686
for key in t.keys() { - 3687
if !KNOWN_ANTHROPIC_PROVIDER_KEYS.contains(&key.as_str()) { - 3688
out.push(format!( - 3689
"{}: unknown providers key 'providers.anthropic.{key}' (ignored)", - 3690
path.display() - 3691
)); - 3692
} - 3693
} - 3694
} - 3695
} - 3696
out - 3697
} - 3698
- 3699
fn merge_into(base: &mut FileConfig, over: FileConfig) { - 3700
if over.provider.is_some() { - 3701
base.provider = over.provider; - 3702
} - 3703
if over.model.is_some() { - 3704
base.model = over.model; - 3705
} - 3706
if over.max_tokens.is_some() { - 3707
base.max_tokens = over.max_tokens; - 3708
} - 3709
if over.max_turns.is_some() { - 3710
base.max_turns = over.max_turns; - 3711
} - 3712
if over.permission_mode.is_some() { - 3713
base.permission_mode = over.permission_mode; - 3714
} - 3715
if over.approval_mode.is_some() { - 3716
base.approval_mode = over.approval_mode; - 3717
} - 3718
if over.profile.is_some() { - 3719
base.profile = over.profile; - 3720
} - 3721
if over.anthropic_base_url.is_some() { - 3722
base.anthropic_base_url = over.anthropic_base_url; - 3723
} - 3724
for r in over.allow { - 3725
if !base.allow.contains(&r) { - 3726
base.allow.push(r); - 3727
} - 3728
} - 3729
for r in over.ask { - 3730
if !base.ask.contains(&r) { - 3731
base.ask.push(r); - 3732
} - 3733
} - 3734
for r in over.deny { - 3735
if !base.deny.contains(&r) { - 3736
base.deny.push(r); - 3737
} - 3738
} - 3739
if over.workers.is_some() { - 3740
base.workers = over.workers; - 3741
} - 3742
if over.max_retries.is_some() { - 3743
base.max_retries = over.max_retries; - 3744
} - 3745
if over.retry_base_backoff_ms.is_some() { - 3746
base.retry_base_backoff_ms = over.retry_base_backoff_ms; - 3747
} - 3748
if over.request_timeout_secs.is_some() { - 3749
base.request_timeout_secs = over.request_timeout_secs; - 3750
} - 3751
if over.run_retry_attempts.is_some() { - 3752
base.run_retry_attempts = over.run_retry_attempts; - 3753
} - 3754
if over.run_retry_base_backoff_ms.is_some() { - 3755
base.run_retry_base_backoff_ms = over.run_retry_base_backoff_ms; - 3756
} - 3757
if over.circuit_breaker_threshold.is_some() { - 3758
base.circuit_breaker_threshold = over.circuit_breaker_threshold; - 3759
} - 3760
if over.circuit_breaker_cooldown_secs.is_some() { - 3761
base.circuit_breaker_cooldown_secs = over.circuit_breaker_cooldown_secs; - 3762
} - 3763
if over.context_window.is_some() { - 3764
base.context_window = over.context_window; - 3765
} - 3766
// Voice settings are scalar overrides; keep the narrower layer's intent. - 3767
if over.voice.is_some() { - 3768
base.voice = over.voice; - 3769
} - 3770
if over.capabilities.inherit_hooks == Some(false) { - 3771
base.hooks.clear(); - 3772
} - 3773
if over.capabilities.inherit_mcp == Some(false) { - 3774
base.mcp.servers.clear(); - 3775
} - 3776
if over.capabilities.inherit_mcp.is_some() { - 3777
base.capabilities.inherit_mcp = over.capabilities.inherit_mcp; - 3778
} - 3779
if over.capabilities.inherit_hooks.is_some() { - 3780
base.capabilities.inherit_hooks = over.capabilities.inherit_hooks; - 3781
} - 3782
if over.capabilities.inherit_skills.is_some() { - 3783
base.capabilities.inherit_skills = over.capabilities.inherit_skills; - 3784
} - 3785
if over.capabilities.inherit_commands.is_some() { - 3786
base.capabilities.inherit_commands = over.capabilities.inherit_commands; - 3787
} - 3788
if over.capabilities.inherit_plugins.is_some() { - 3789
base.capabilities.inherit_plugins = over.capabilities.inherit_plugins; - 3790
} - 3791
// Unlike allow/ask/deny just above, this used to be a plain `.extend` - 3792
// with no dedup. `PUT /config/hooks` (vak-server) always resubmitted the - 3793
// full *effective* list it had just read — global layer included — so - 3794
// every hook edit re-wrote the project's own inherited copy of every - 3795
// global hook back into the project file, and the next load merged - 3796
// both: the same hook doubled, then tripled on the next edit, without - 3797
// bound. `get_hooks` no longer echoes inherited hooks for this reason, - 3798
// but a hand-edited config with a genuine duplicate should not compound - 3799
// either. - 3800
for h in over.hooks { - 3801
let key = |hook: &HookConfig| { - 3802
( - 3803
hook.event.clone(), - 3804
hook.matcher.clone(), - 3805
hook.command.clone(), - 3806
) - 3807
}; - 3808
let incoming = key(&h); - 3809
if let Some(existing) = base - 3810
.hooks - 3811
.iter_mut() - 3812
.find(|candidate| key(candidate) == incoming) - 3813
{ - 3814
*existing = h; - 3815
} else { - 3816
base.hooks.push(h); - 3817
} - 3818
} - 3819
for (name, srv) in over.mcp.servers { - 3820
base.mcp.servers.insert(name, srv); - 3821
} - 3822
if over.ui.theme.is_some() { - 3823
base.ui.theme = over.ui.theme; - 3824
} - 3825
if over.ui.bell.is_some() { - 3826
base.ui.bell = over.ui.bell; - 3827
} - 3828
for (k, v) in &over.ui.keymap { - 3829
base.ui.keymap.insert(k.clone(), v.clone()); - 3830
} - 3831
if over.ui.composer.is_some() { - 3832
base.ui.composer = over.ui.composer; - 3833
} - 3834
if over.ui.osc52.is_some() { - 3835
base.ui.osc52 = over.ui.osc52; - 3836
} - 3837
if let Some(acc) = over.ui.accessibility { - 3838
let entry = base - 3839
.ui - 3840
.accessibility - 3841
.get_or_insert_with(AccessibilitySettings::default); - 3842
if acc.plain.is_some() { - 3843
entry.plain = acc.plain; - 3844
} - 3845
if acc.reduced_motion.is_some() { - 3846
entry.reduced_motion = acc.reduced_motion; - 3847
} - 3848
if acc.screen_reader.is_some() { - 3849
entry.screen_reader = acc.screen_reader; - 3850
} - 3851
} - 3852
for (name, colors) in &over.ui.themes { - 3853
let entry = base.ui.themes.entry(name.clone()).or_default(); - 3854
for (k, v) in colors { - 3855
entry.insert(k.clone(), v.clone()); - 3856
} - 3857
} - 3858
if let Some(sp) = over.stop_policy { - 3859
base.stop_policy = Some(sp); - 3860
} - 3861
if over.gateway.enabled.is_some() { - 3862
base.gateway.enabled = over.gateway.enabled; - 3863
} - 3864
if over.gateway.approvals.is_some() { - 3865
base.gateway.approvals = over.gateway.approvals; - 3866
} - 3867
if over.gateway.approver.is_some() { - 3868
base.gateway.approver = over.gateway.approver; - 3869
} - 3870
if over.gateway.approval_timeout_secs.is_some() { - 3871
base.gateway.approval_timeout_secs = over.gateway.approval_timeout_secs; - 3872
} - 3873
if over.gateway.rate_limit.is_some() { - 3874
base.gateway.rate_limit = over.gateway.rate_limit; - 3875
} - 3876
if !over.gateway.chat_allowlist.is_empty() { - 3877
base.gateway.chat_allowlist = over.gateway.chat_allowlist; - 3878
} - 3879
if over.gateway.chat_allowlist_open.is_some() { - 3880
base.gateway.chat_allowlist_open = over.gateway.chat_allowlist_open; - 3881
} - 3882
if over.gateway.core_pool_max.is_some() { - 3883
base.gateway.core_pool_max = over.gateway.core_pool_max; - 3884
} - 3885
if over.gateway.core_pool_idle_secs.is_some() { - 3886
base.gateway.core_pool_idle_secs = over.gateway.core_pool_idle_secs; - 3887
} - 3888
if over.gateway.pending_expiry_days.is_some() { - 3889
base.gateway.pending_expiry_days = over.gateway.pending_expiry_days; - 3890
} - 3891
if over.memory.search_enabled.is_some() { - 3892
base.memory.search_enabled = over.memory.search_enabled; - 3893
} - 3894
if over.memory.write_enabled.is_some() { - 3895
base.memory.write_enabled = over.memory.write_enabled; - 3896
} - 3897
if over.memory.skill_proposals.is_some() { - 3898
base.memory.skill_proposals = over.memory.skill_proposals; - 3899
} - 3900
if over.memory.reflection.is_some() { - 3901
base.memory.reflection = over.memory.reflection; - 3902
} - 3903
if over.sandbox.backend.is_some() { - 3904
base.sandbox.backend = over.sandbox.backend; - 3905
} - 3906
if over.sandbox.image.is_some() { - 3907
base.sandbox.image = over.sandbox.image; - 3908
} - 3909
if over.finops.max_run_usd.is_some() { - 3910
base.finops.max_run_usd = over.finops.max_run_usd; - 3911
} - 3912
if over.finops.max_day_usd.is_some() { - 3913
base.finops.max_day_usd = over.finops.max_day_usd; - 3914
} - 3915
for (k, v) in over.finops.price_overrides { - 3916
base.finops.price_overrides.insert(k, v); - 3917
} - 3918
if over.goal.handoff_reset.is_some() { - 3919
base.goal.handoff_reset = over.goal.handoff_reset; - 3920
} - 3921
if over.goal.max_audit_blocks.is_some() { - 3922
base.goal.max_audit_blocks = over.goal.max_audit_blocks; - 3923
} - 3924
if over.work.enabled.is_some() { - 3925
base.work.enabled = over.work.enabled; - 3926
} - 3927
if over.work.default_mode.is_some() { - 3928
base.work.default_mode = over.work.default_mode; - 3929
} - 3930
if over.work.max_items.is_some() { - 3931
base.work.max_items = over.work.max_items; - 3932
} - 3933
if over.work.max_revisions.is_some() { - 3934
base.work.max_revisions = over.work.max_revisions; - 3935
} - 3936
if over.work.max_parallel.is_some() { - 3937
base.work.max_parallel = over.work.max_parallel; - 3938
} - 3939
if over.work.confirmation.is_some() { - 3940
base.work.confirmation = over.work.confirmation; - 3941
} - 3942
if over.route.objective.is_some() { - 3943
base.route.objective = over.route.objective; - 3944
} - 3945
for m in over.route.fallback_models { - 3946
if !base.route.fallback_models.contains(&m) { - 3947
base.route.fallback_models.push(m); - 3948
} - 3949
} - 3950
if over.route.max_fallbacks.is_some() { - 3951
base.route.max_fallbacks = over.route.max_fallbacks; - 3952
} - 3953
if over.probe.hosted.is_some() { - 3954
base.probe.hosted = over.probe.hosted; - 3955
} - 3956
if over.providers.ollama.keep_alive.is_some() { - 3957
base.providers.ollama.keep_alive = over.providers.ollama.keep_alive; - 3958
} - 3959
if over.providers.ollama.num_ctx.is_some() { - 3960
base.providers.ollama.num_ctx = over.providers.ollama.num_ctx; - 3961
} - 3962
if over.providers.anthropic.fast_mode.is_some() { - 3963
base.providers.anthropic.fast_mode = over.providers.anthropic.fast_mode; - 3964
} - 3965
if over.intent.enabled.is_some() { - 3966
base.intent.enabled = over.intent.enabled; - 3967
} - 3968
if over.intent.accept_confidence.is_some() { - 3969
base.intent.accept_confidence = over.intent.accept_confidence; - 3970
} - 3971
if over.intent.provisional_confidence.is_some() { - 3972
base.intent.provisional_confidence = over.intent.provisional_confidence; - 3973
} - 3974
if over.intent.slice_capabilities.is_some() { - 3975
base.intent.slice_capabilities = over.intent.slice_capabilities; - 3976
} - 3977
if over.intent.posture.is_some() { - 3978
base.intent.posture = over.intent.posture; - 3979
} - 3980
if over.intent.escalate.is_some() { - 3981
base.intent.escalate = over.intent.escalate; - 3982
} - 3983
if over.intent.classify_model.is_some() { - 3984
base.intent.classify_model = over.intent.classify_model; - 3985
} - 3986
if over.intent.max_classify_usd.is_some() { - 3987
base.intent.max_classify_usd = over.intent.max_classify_usd; - 3988
} - 3989
if over.intent.classify_timeout_secs.is_some() { - 3990
base.intent.classify_timeout_secs = over.intent.classify_timeout_secs; - 3991
} - 3992
if over.intent.autonomy.is_some() { - 3993
base.intent.autonomy = over.intent.autonomy; - 3994
} - 3995
if over.intent.evidence_max_age_secs.is_some() { - 3996
base.intent.evidence_max_age_secs = over.intent.evidence_max_age_secs; - 3997
} - 3998
if over.commitment.enabled.is_some() { - 3999
base.commitment.enabled = over.commitment.enabled; - 4000
} - 4001
if over.commitment.lifetime_budget_usd.is_some() { - 4002
base.commitment.lifetime_budget_usd = over.commitment.lifetime_budget_usd; - 4003
} - 4004
if over.commitment.stall_limit.is_some() { - 4005
base.commitment.stall_limit = over.commitment.stall_limit; - 4006
} - 4007
if over.commitment.review_every_hours.is_some() { - 4008
base.commitment.review_every_hours = over.commitment.review_every_hours; - 4009
} - 4010
if over.commitment.default_ttl_days.is_some() { - 4011
base.commitment.default_ttl_days = over.commitment.default_ttl_days; - 4012
} - 4013
for h in over.route.quality_hints { - 4014
if !base.route.quality_hints.contains(&h) { - 4015
base.route.quality_hints.push(h); - 4016
} - 4017
} - 4018
for h in over.route.modality_hints { - 4019
if !base.route.modality_hints.contains(&h) { - 4020
base.route.modality_hints.push(h); - 4021
} - 4022
} - 4023
if over.automation.catch_up_missed.is_some() { - 4024
base.automation.catch_up_missed = over.automation.catch_up_missed; - 4025
} - 4026
if over.update.url.is_some() { - 4027
base.update.url = over.update.url; - 4028
} - 4029
if over.update.interval_hours.is_some() { - 4030
base.update.interval_hours = over.update.interval_hours; - 4031
} - 4032
if over.tools.web_fetch.is_some() { - 4033
base.tools.web_fetch = over.tools.web_fetch; - 4034
} - 4035
if over.tools.browse.is_some() { - 4036
base.tools.browse = over.tools.browse; - 4037
} - 4038
if over.heartbeat.enabled.is_some() { - 4039
base.heartbeat.enabled = over.heartbeat.enabled; - 4040
} - 4041
if over.heartbeat.interval_secs.is_some() { - 4042
base.heartbeat.interval_secs = over.heartbeat.interval_secs; - 4043
} - 4044
if over.heartbeat.model.is_some() { - 4045
base.heartbeat.model = over.heartbeat.model; - 4046
} - 4047
if over.heartbeat.quiet_hours.is_some() { - 4048
base.heartbeat.quiet_hours = over.heartbeat.quiet_hours; - 4049
} - 4050
if over.heartbeat.max_findings.is_some() { - 4051
base.heartbeat.max_findings = over.heartbeat.max_findings; - 4052
} - 4053
for (name, hook) in over.gateway.outbound.webhooks { - 4054
base.gateway.outbound.webhooks.insert(name, hook); - 4055
} - 4056
if over.feeds.enabled.is_some() { - 4057
base.feeds.enabled = over.feeds.enabled; - 4058
} - 4059
if over.feeds.config_path.is_some() { - 4060
base.feeds.config_path = over.feeds.config_path; - 4061
} - 4062
if over.feeds.db_path.is_some() { - 4063
base.feeds.db_path = over.feeds.db_path; - 4064
} - 4065
if over.feeds.default_check_interval.is_some() { - 4066
base.feeds.default_check_interval = over.feeds.default_check_interval; - 4067
} - 4068
if over.feeds.max_items_per_feed.is_some() { - 4069
base.feeds.max_items_per_feed = over.feeds.max_items_per_feed; - 4070
} - 4071
if over.feeds.dedup_window_days.is_some() { - 4072
base.feeds.dedup_window_days = over.feeds.dedup_window_days; - 4073
} - 4074
for p in over.plugins.enabled { - 4075
if !base.plugins.enabled.contains(&p) { - 4076
base.plugins.enabled.push(p); - 4077
} - 4078
} - 4079
for p in over.plugins.disabled { - 4080
if !base.plugins.disabled.contains(&p) { - 4081
base.plugins.disabled.push(p); - 4082
} - 4083
} - 4084
if over.plugins.allow.is_some() { - 4085
base.plugins.allow = over.plugins.allow; - 4086
} - 4087
for p in over.plugins.deny { - 4088
if !base.plugins.deny.contains(&p) { - 4089
base.plugins.deny.push(p); - 4090
} - 4091
} - 4092
if over.plugins.network_allow.is_some() { - 4093
base.plugins.network_allow = over.plugins.network_allow; - 4094
} - 4095
for p in over.plugins.network_deny { - 4096
if !base.plugins.network_deny.contains(&p) { - 4097
base.plugins.network_deny.push(p); - 4098
} - 4099
} - 4100
for (k, v) in over.profiles { - 4101
base.profiles.insert(k, v); - 4102
} - 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);
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.