- 3410
} - 3411
layers - 3412
} - 3413
- 3414
/// Roles defined for this workspace, shared layer first so a project can - 3415
/// shadow a shared role by name — the same name-keyed shadowing MCP - 3416
/// servers already use. - 3417
pub fn prompt_role_names(&self) -> Vec<String> { - 3418
let mut names: Vec<String> = Vec::new(); - 3419
for root in [&vak_config::paths::default_workspace(), &self.inner.cwd] { - 3420
let dir = prompts::layer_dir(root).join("agents"); - 3421
let Ok(entries) = std::fs::read_dir(&dir) else { - 3422
continue; - 3423
}; - 3424
for entry in entries.flatten() { - 3425
if !entry.path().is_dir() { - 3426
continue; - 3427
} - 3428
let Some(name) = entry.file_name().to_str().map(str::to_string) else { - 3429
continue; - 3430
}; - 3431
if prompts::sub_layer_dir(root, "agents", &name).is_some() - 3432
&& !names.contains(&name) - 3433
&& !prompts::read_layer(&entry.path()).is_empty() - 3434
{ - 3435
names.push(name); - 3436
} - 3437
} - 3438
} - 3439
for builtin in ["analyst", "operator", "researcher", "writer"] { - 3440
if !names.contains(&builtin.to_string()) { - 3441
names.push(builtin.to_string()); - 3442
} - 3443
} - 3444
names.sort(); - 3445
names - 3446
} - 3447
- 3448
/// Fully resolved prompt per role, admitted up front so a child can only - 3449
/// run under a role that existed when this session was admitted. - 3450
pub fn role_prompts( - 3451
&self, - 3452
capabilities: &[CapabilityDescriptor], - 3453
) -> std::collections::BTreeMap<String, String> { - 3454
self.prompt_role_names() - 3455
.into_iter() - 3456
.map(|name| { - 3457
let prompt = self - 3458
.clone() - 3459
.with_prompt_role(Some(name.clone())) - 3460
.system_prompt_for_capabilities(capabilities); - 3461
(name, prompt) - 3462
}) - 3463
.collect() - 3464
} - 3465
- 3466
pub fn skills(&self) -> Vec<skills::Skill> { - 3467
let shared_root = self.shared_capability_root(); - 3468
let plugin_roots = self.enabled_plugin_skill_roots(); - 3469
let mut skills = - 3470
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3471
if !self.effective_capability_inheritance().inherit_skills { - 3472
let shared_skills = shared_root.join("skills"); - 3473
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3474
} - 3475
let Some(policy) = self.channel_policy() else { - 3476
return skills; - 3477
}; - 3478
skills - 3479
.into_iter() - 3480
.filter(|skill| { - 3481
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3482
}) - 3483
.collect() - 3484
} - 3485
- 3486
pub fn skills_with_shadowed(&self) -> Vec<skills::Skill> { - 3487
let shared_root = self.shared_capability_root(); - 3488
let plugin_roots = self.enabled_plugin_skill_roots(); - 3489
let mut skills = - 3490
skills::discover_all_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3491
if !self.effective_capability_inheritance().inherit_skills { - 3492
let shared_skills = shared_root.join("skills"); - 3493
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3494
} - 3495
let Some(policy) = self.channel_policy() else { - 3496
return skills; - 3497
}; - 3498
skills - 3499
.into_iter() - 3500
.filter(|skill| { - 3501
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3502
}) - 3503
.collect() - 3504
} - 3505
- 3506
/// Live workers spawned by this Core's runs, for attach/steer UIs. - 3507
pub fn workers(&self) -> Arc<vak_agent::WorkerRegistry> { - 3508
self.inner.workers.clone() - 3509
} - 3510
- 3511
pub fn custom_commands(&self) -> Vec<custom_commands::CustomCommand> { - 3512
let shared_root = self.shared_capability_root(); - 3513
let plugin_roots = self.enabled_plugin_skill_roots(); - 3514
let mut commands = - 3515
custom_commands::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3516
if !self.effective_capability_inheritance().inherit_commands { - 3517
// `discover_with_plugins` labels the shared root's commands - 3518
// "user"; workspace ones are "project" and plugin ones carry a - 3519
// "plugin:" prefix. - 3520
commands.retain(|command| command.source != "user"); - 3521
} - 3522
commands - 3523
} - 3524
- 3525
/// Every tool this Core offers a turn, constructed once. - 3526
/// - 3527
/// The only list of built-in tools: `tool_names`, the capability - 3528
/// declarations, and the turn itself all derive from it, and each tool - 3529
/// states its own domains and loading (`Tool::serves`, - 3530
/// `Tool::always_loaded`). Three tools are bound to what the turn - 3531
/// admitted and are added by the turn instead — `skill` (the admitted - 3532
/// skills), `mcp` (the admitted servers) and `task` (the admitted tools) - 3533
/// — plus the synthetic `find_tools`. - 3534
fn scoped_tools(&self, scope: &ToolScope) -> Vec<Arc<dyn vak_tools::Tool>> { - 3535
let worker = self - 3536
.inner - 3537
.tool_worker_exe - 3538
.lock() - 3539
.ok() - 3540
.map(|worker| worker.clone()) - 3541
.unwrap_or_else(|| PathBuf::from("__vak_tool_worker_unavailable__")); - 3542
let mut tools = vak_tools::brokered_tools(worker, &self.new_documents); - 3543
tools.push(Arc::new(vak_tools::RecallTool)); - 3544
tools.push(Arc::new(tools_tasks::TasksTool { - 3545
sessions_home: self.shared_data_home(), - 3546
cwd: self.inner.cwd.clone(), - 3547
default_deliver_to: self.default_deliver_to.clone(), - 3548
})); - 3549
if self.effective_commitment() { - 3550
tools.push(Arc::new(tools_commitments::CommitmentsTool { - 3551
sessions_home: self.sessions_home(), - 3552
audience_id: scope.audience_id.clone(), - 3553
})); - 3554
} - 3555
if self.effective_memory_search_enabled() { - 3556
tools.push(Arc::new(session_search::SessionSearchTool { - 3557
sessions_home: self.sessions_home(), - 3558
trash_home: self.shared_data_home(), - 3559
cwd: self.inner.cwd.clone(), - 3560
exclude_session_id: scope.session_id.clone(), - 3561
agent_id: scope.agent_id.clone(), - 3562
audience_id: scope.audience_id.clone(), - 3563
})); - 3564
} - 3565
if self.effective_memory_write_enabled() { - 3566
tools.push(Arc::new(learning::RememberTool { - 3567
sessions_home: self.sessions_home(), - 3568
cwd: self.inner.cwd.clone(), - 3569
session_id: scope.session_id.clone(), - 3570
})); - 3571
tools.push(Arc::new(entities::EntityRecordTool { - 3572
sessions_home: self.sessions_home(), - 3573
cwd: self.inner.cwd.clone(), - 3574
})); - 3575
} - 3576
if self.effective_memory_skill_proposals() { - 3577
tools.push(Arc::new(learning::ProposeSkillTool { - 3578
sessions_home: self.sessions_home(), - 3579
cwd: self.inner.cwd.clone(), - 3580
session_id: scope.session_id.clone(), - 3581
})); - 3582
} - 3583
tools.push(Arc::new(entities::EntityQueryTool { - 3584
sessions_home: self.sessions_home(), - 3585
cwd: self.inner.cwd.clone(), - 3586
})); - 3587
tools.push(Arc::new(data_engine::DataQueryTool)); - 3588
for emit_tool in presentation_tools::EmitCardTool::all() { - 3589
tools.push(Arc::new(emit_tool)); - 3590
} - 3591
if self.effective_web_fetch() { - 3592
tools.push(Arc::new(vak_tools::WebFetchTool)); - 3593
} - 3594
if self.effective_browse() { - 3595
tools.push(Arc::new(vak_tools::WebBrowseTool)); - 3596
} - 3597
// Inter-agent messaging, only while an operator has authorized this - 3598
// workspace on the broker (docs/design/25-docker-sandbox.md). The - 3599
// broker keys workspaces by canonical path, as the server registers - 3600
// them. - 3601
let workspace = self - 3602
.inner - 3603
.cwd - 3604
.canonicalize() - 3605
.unwrap_or_else(|_| self.inner.cwd.clone()) - 3606
.display() - 3607
.to_string(); - 3608
let network = self.agent_network_broker(); - 3609
if network.is_enabled(&workspace) { - 3610
tools.push(Arc::new(agent_network::AgentNetworkTool::new( - 3611
network, workspace, - 3612
))); - 3613
} - 3614
tools.retain(|tool| self.channel_tool_allowed(tool.name())); - 3615
tools - 3616
} - 3617
- 3618
/// `(name, serves)` for every tool a turn could be offered, including the - 3619
/// three the turn binds itself. The capability declarations read this. - 3620
fn tool_declarations(&self) -> Vec<(String, &'static [&'static str])> { - 3621
let mut out: Vec<(String, &'static [&'static str])> = self - 3622
.scoped_tools(&ToolScope::default()) - 3623
.iter() - 3624
.map(|tool| (tool.name().to_string(), tool.serves())) - 3625
.collect(); - 3626
let bound = [ - 3627
(!self.skills().is_empty()).then_some(("skill", skills::SkillTool::SERVES)), - 3628
(!self.effective_mcp().servers.is_empty()).then_some(("mcp", vak_mcp::McpTool::SERVES)), - 3629
self.effective_workers() - 3630
.then_some(("task", vak_agent::TaskTool::SERVES)), - 3631
]; - 3632
for (name, serves) in bound.into_iter().flatten() { - 3633
if self.channel_tool_allowed(name) { - 3634
out.push((name.to_string(), serves)); - 3635
} - 3636
} - 3637
out - 3638
} - 3639
- 3640
pub fn tool_names(&self) -> Vec<String> { - 3641
self.tool_declarations() - 3642
.into_iter() - 3643
.map(|(name, _)| name) - 3644
.collect() - 3645
} - 3646
- 3647
/// The capability packet, derived from the same declarations the - 3648
/// registry uses. - 3649
/// - 3650
/// This used to build descriptors a second time, by hand, and the two - 3651
/// constructions drifted: the same built-in tool came out stamped - 3652
/// `provenance: "vak-core"` here and `"builtin"` through the registry, - 3653
/// so which spelling a session recorded depended on which path admitted - 3654
/// it. That is the parallel-representation defect doc 41 exists to - 3655
/// remove, reintroduced one layer down. - 3656
/// - 3657
/// There is one construction now. `CapabilityProvider::declare` is the - 3658
/// single description of what exists, and both this and the registry - 3659
/// project from it. Note this is the *unresolved* view — anything that - 3660
/// needs probing is described but not yet proven usable — which is why - 3661
/// admission goes through [`Self::admitted_capabilities`] instead and - 3662
/// this remains only the synchronous fallback. - 3663
pub fn capability_descriptors(&self) -> Vec<CapabilityDescriptor> { - 3664
use crate::capability::registry::CapabilityProvider; - 3665
let mut out: Vec<CapabilityDescriptor> = self - 3666
.declare() - 3667
.into_iter() - 3668
.map(|declaration| capability::Capability { - 3669
id: declaration.id, - 3670
origin: declaration.origin, - 3671
summary: declaration.summary, - 3672
serves: declaration.serves, - 3673
digest: declaration.digest, - 3674
source: declaration.source, - 3675
// Unprobed: `Static` describes it without claiming a probe - 3676
// succeeded. Admission is what proves the rest. - 3677
resolution: capability::Resolution::Available, - 3678
configuration: declaration.configuration, - 3679
}) - 3680
.map(|capability| capability.to_descriptor()) - 3681
.collect(); - 3682
- 3683
// Strictly subtractive: `reach` never returns a capability that - 3684
// configuration did not already grant. - 3685
let standings = self.capability_standings(); - 3686
let unreachable_tools = reach::fully_blocked_tools(&standings); - 3687
let unreachable_servers = reach::blocked_mcp_servers(&standings); - 3688
let unreachable_skills = reach::blocked_skills(&standings); - 3689
out.retain(|capability| match capability.kind { - 3690
CapabilityKind::Tool => !unreachable_tools - 3691
.iter() - 3692
.any(|name| name == &capability.name), - 3693
CapabilityKind::McpServer => !unreachable_servers - 3694
.iter() - 3695
.any(|name| name == &capability.name), - 3696
CapabilityKind::Skill => !unreachable_skills - 3697
.iter() - 3698
.any(|name| name == &capability.name), - 3699
_ => true, - 3700
}); - 3701
out.sort_by(|a, b| { - 3702
format!("{:?}:{}", a.kind, a.name).cmp(&format!("{:?}:{}", b.kind, b.name)) - 3703
}); - 3704
out - 3705
} - 3706
- 3707
/// Every capability that was configured/discovered but excluded from - 3708
/// [`capability_descriptors`] — skill parse failures, reach-blocked - 3709
/// tools, channel-policy-filtered capabilities. The observability - 3710
/// counterpart: `capability_descriptors` says what a turn CAN do; - 3711
/// this says what it configured but CANNOT do, and why. - 3712
pub fn capability_diagnostics(&self) -> Vec<CapabilityDiagnostic> { - 3713
let mut out = Vec::new(); - 3714
- 3715
// 1. Skill parse failures. - 3716
let shared_root = self.shared_capability_root(); - 3717
let plugin_roots = self.enabled_plugin_skill_roots(); - 3718
let (_skills, skill_diags) = - 3719
skills::discover_with_diagnostics(&self.inner.cwd, &shared_root, &plugin_roots); - 3720
for diag in skill_diags { - 3721
out.push(CapabilityDiagnostic { - 3722
kind: "skill".into(), - 3723
name: diag - 3724
.path - 3725
.parent() - 3726
.and_then(|p| p.file_name()) - 3727
.map(|n| n.to_string_lossy().into_owned()) - 3728
.unwrap_or_else(|| diag.path.display().to_string()), - 3729
reason: diag.reason, - 3730
source: Some(diag.path.display().to_string()), - 3731
remedy: "fix the SKILL.md frontmatter (name must be lowercase kebab-case, \ - 3732
description must be present and non-empty)" - 3733
.into(), - 3734
// A skill that will not parse is broken, not chosen. - 3735
deliberate: false, - 3736
}); - 3737
} - 3738
- 3739
// 2. Reach-blocked capabilities (MCP servers, network tools). - 3740
let standings = self.capability_standings(); - 3741
for standing in &standings { - 3742
if standing.reach.is_blocked() { - 3743
out.push(CapabilityDiagnostic { - 3744
kind: if standing.tool == "mcp" { - 3745
"mcp-server".into() - 3746
} else if standing.tool == "skill" { - 3747
"skill".into() - 3748
} else { - 3749
"tool".into() - 3750
}, - 3751
name: standing.label.clone(), - 3752
reason: standing.reason.clone(), - 3753
source: None, - 3754
remedy: standing.remedy.clone(), - 3755
// Reach follows from permission mode, approval posture - 3756
// and channel policy — all chosen. The dedicated - 3757
// `capability reach` check already reports these, so - 3758
// this also stops `capability health` double-reporting. - 3759
deliberate: true, - 3760
}); - 3761
} - 3762
} - 3763
- 3764
// 3. Skills filtered by channel policy. - 3765
if let Some(policy) = self.channel_policy() { - 3766
let all_skills = - 3767
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3768
for skill in &all_skills { - 3769
if !Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) { - 3770
out.push(CapabilityDiagnostic { - 3771
kind: "skill".into(), - 3772
name: skill.name.clone(), - 3773
reason: "blocked by channel policy (skills_deny or not in skills_allow)" - 3774
.into(), - 3775
source: Some(skill.path.display().to_string()), - 3776
remedy: "adjust the channel's skills_allow/skills_deny in the \ - 3777
gateway allowlist" - 3778
.into(), - 3779
// The channel allowlist is a policy the operator set. - 3780
deliberate: true, - 3781
}); - 3782
} - 3783
} - 3784
} - 3785
- 3786
// Enabled hooks whose definition cannot be read. A fail-closed one - 3787
// refuses what it guards rather than disappearing (capability::turn), - 3788
// so this is how an operator learns why tools are being refused. - 3789
for hook in self - 3790
.effective_hooks() - 3791
.into_iter() - 3792
.filter(|hook| hook.enabled) - 3793
{ - 3794
if let Err(reason) = hook_def(&hook) { - 3795
out.push(CapabilityDiagnostic { - 3796
kind: "hook".into(), - 3797
name: format!("{}/{}", hook.event, hook.command), - 3798
reason, - 3799
source: None, - 3800
remedy: "fix the hook's event, match rule, or failure_mode".into(), - 3801
deliberate: false, - 3802
}); - 3803
} - 3804
} - 3805
- 3806
// 4. Disabled hooks (present in config but enabled=false). - 3807
for hook in self - 3808
.effective_hooks() - 3809
.into_iter() - 3810
.filter(|hook| !hook.enabled) - 3811
{ - 3812
out.push(CapabilityDiagnostic { - 3813
kind: "hook".into(), - 3814
name: format!("{}/{}", hook.event, hook.command), - 3815
reason: "hook is disabled (enabled = false)".into(), - 3816
source: None, - 3817
remedy: "set enabled = true in .vak/config.toml or the admin console".into(), - 3818
// `enabled = false` is the operator saying so. - 3819
deliberate: true, - 3820
}); - 3821
} - 3822
- 3823
// 5. MCP servers whose last on-demand attempt failed, as the pool - 3824
// observed it. Operator-facing only (`source: None`): the server is - 3825
// still callable — the next demand retries after the pool's backoff — - 3826
// so it must not join the prompt's "NOT usable" list; the model sees - 3827
// the failure on the server's own line in the MCP section instead. - 3828
for capability in self.capability_registry().current_blocking().all() { - 3829
if let Some(reason) = capability::report::mcp_failure(capability) { - 3830
out.push(CapabilityDiagnostic { - 3831
kind: "mcp-server".into(), - 3832
name: capability.id.name.clone(), - 3833
reason: reason.to_string(), - 3834
source: None, - 3835
remedy: capability::report::mcp_remedy(&capability.id.name), - 3836
// A server that will not answer is broken, not chosen. - 3837
deliberate: false, - 3838
}); - 3839
} - 3840
} - 3841
- 3842
out - 3843
} - 3844
- 3845
fn provider_auth(&self) -> Result<ProviderAuth, CoreError> { - 3846
self.provider_auth_for(&self.effective_provider()) - 3847
} - 3848
- 3849
/// Resolve credentials for an arbitrary provider, not just the active - 3850
/// one — model discovery needs to authenticate against whichever - 3851
/// provider the user is inspecting. - 3852
fn provider_auth_for(&self, provider: &str) -> Result<ProviderAuth, CoreError> { - 3853
let provider = provider.to_string(); - 3854
let required_key = |env: &str, provider: &str| { - 3855
let primary = self.provider_secret(env).or_else(|| { - 3856
Self::provider_pool_env_var(provider).and_then(|pool_env| { - 3857
self.provider_secret(pool_env).and_then(|value| { - 3858
value - 3859
.split([',', '\n']) - 3860
.map(str::trim) - 3861
.find(|key| !key.is_empty()) - 3862
.map(str::to_string) - 3863
}) - 3864
}) - 3865
}); - 3866
primary - 3867
.filter(|key| !key.trim().is_empty()) - 3868
.map(|key| key.trim().to_string()) - 3869
.ok_or_else(|| CoreError::MissingAuth { - 3870
env: env.into(), - 3871
provider: provider.into(), - 3872
}) - 3873
}; - 3874
match provider.as_str() { - 3875
"anthropic" => { - 3876
let api_key = required_key("ANTHROPIC_API_KEY", "anthropic")?; - 3877
let base_url = self - 3878
.inner - 3879
.config - 3880
.anthropic_base_url - 3881
.clone() - 3882
.or_else(|| vak_config::get_var("VAK_ANTHROPIC_BASE_URL")); - 3883
Ok(ProviderAuth { - 3884
credential_id: Some(vak_llm::credential_id( - 3885
base_url - 3886
.as_deref() - 3887
.unwrap_or(vak_llm::anthropic::DEFAULT_BASE_URL), - 3888
&api_key, - 3889
)), - 3890
api_key, - 3891
base_url, - 3892
..Default::default() - 3893
}) - 3894
} - 3895
"google" => { - 3896
let api_key = self - 3897
.provider_secret("GEMINI_API_KEY") - 3898
.or_else(|| self.provider_secret("GOOGLE_API_KEY")) - 3899
.or_else(|| { - 3900
self.provider_secret("GEMINI_API_KEYS").and_then(|value| { - 3901
value - 3902
.split([',', '\n']) - 3903
.map(str::trim) - 3904
.find(|key| !key.is_empty()) - 3905
.map(str::to_string) - 3906
}) - 3907
}) - 3908
.filter(|key| !key.trim().is_empty()) - 3909
.map(|key| key.trim().to_string()) - 3910
.ok_or_else(|| CoreError::MissingAuth { - 3911
env: "GEMINI_API_KEY".into(), - 3912
provider: provider.clone(), - 3913
})?; - 3914
Ok(ProviderAuth { - 3915
credential_id: Some(vak_llm::credential_id( - 3916
&vak_config::get_var("VAK_GOOGLE_BASE_URL").unwrap_or_else(|| { - 3917
"https://generativelanguage.googleapis.com/v1beta".into() - 3918
}), - 3919
&api_key, - 3920
)), - 3921
api_key, - 3922
base_url: vak_config::get_var("VAK_GOOGLE_BASE_URL").or_else(|| { - 3923
Some("https://generativelanguage.googleapis.com/v1beta".into()) - 3924
}), - 3925
..Default::default() - 3926
}) - 3927
} - 3928
"openai-responses" => { - 3929
let api_key = required_key("OPENAI_API_KEY", "openai-responses")?; - 3930
Ok(ProviderAuth { - 3931
credential_id: Some(vak_llm::credential_id( - 3932
&vak_config::get_var("VAK_OPENAI_BASE_URL") - 3933
.unwrap_or_else(|| "https://api.openai.com/v1".into()), - 3934
&api_key, - 3935
)), - 3936
api_key, - 3937
base_url: vak_config::get_var("VAK_OPENAI_BASE_URL") - 3938
.or_else(|| Some("https://api.openai.com/v1".into())), - 3939
..Default::default() - 3940
}) - 3941
} - 3942
// get_var (not raw env) so user-level and project secret - 3943
// scopes authenticate these providers exactly like every other one. - 3944
"openai" | "openrouter" | "openrouter-responses" => { - 3945
let (env, default_base, override_env) = if provider == "openai" { - 3946
( - 3947
"OPENAI_API_KEY", - 3948
"https://api.openai.com/v1", - 3949
"VAK_OPENAI_BASE_URL", - 3950
) - 3951
} else { - 3952
( - 3953
"OPENROUTER_API_KEY", - 3954
"https://openrouter.ai/api/v1", - 3955
"VAK_OPENROUTER_BASE_URL", - 3956
) - 3957
}; - 3958
let api_key = required_key(env, &provider)?; - 3959
Ok(ProviderAuth { - 3960
credential_id: Some(vak_llm::credential_id( - 3961
&vak_config::get_var(override_env).unwrap_or_else(|| default_base.into()), - 3962
&api_key, - 3963
)), - 3964
api_key, - 3965
base_url: vak_config::get_var(override_env) - 3966
.or_else(|| Some(default_base.into())), - 3967
..Default::default() - 3968
}) - 3969
} - 3970
"opencode-zen" => { - 3971
let api_key = required_key("OPENCODE_API_KEY", "opencode-zen")?; - 3972
Ok(ProviderAuth { - 3973
credential_id: Some(vak_llm::credential_id( - 3974
&vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3975
.unwrap_or_else(|| "https://opencode.ai/zen/v1".into()), - 3976
&api_key, - 3977
)), - 3978
api_key, - 3979
base_url: vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3980
.or_else(|| Some("https://opencode.ai/zen/v1".into())), - 3981
..Default::default() - 3982
}) - 3983
} - 3984
"ollama" => { - 3985
// Threaded through generically (registry.rs::ProviderAuth::options) - 3986
// rather than a provider-specific auth variant, per invariant - 3987
// 17 (one configuration contract) and docs/design/68 §8. - 3988
let mut options = std::collections::BTreeMap::new(); - 3989
options.insert( - 3990
"keep_alive".to_string(), - 3991
self.inner.config.ollama.keep_alive.clone(), - 3992
); - 3993
if let Some(num_ctx) = self.inner.config.ollama.num_ctx { - 3994
options.insert("num_ctx".to_string(), num_ctx.to_string()); - 3995
} - 3996
Ok(ProviderAuth { - 3997
api_key: "ollama".into(), - 3998
base_url: vak_config::get_var("VAK_OLLAMA_BASE_URL") - 3999
.or_else(|| Some("http://localhost:11434/v1".into())), - 4000
credential_id: Some(vak_llm::credential_id( - 4001
&vak_config::get_var("VAK_OLLAMA_BASE_URL") - 4002
.unwrap_or_else(|| "http://localhost:11434/v1".into()), - 4003
"ollama", - 4004
)), - 4005
options, - 4006
}) - 4007
} - 4008
"bedrock" => { - 4009
let api_key = required_key("AWS_BEARER_TOKEN_BEDROCK", "bedrock")?; - 4010
let base_url = vak_config::get_var("VAK_BEDROCK_BASE_URL") - 4011
.or_else(|| Some("https://bedrock-mantle.us-east-1.api.aws/v1".into())); - 4012
Ok(ProviderAuth { - 4013
credential_id: Some(vak_llm::credential_id( - 4014
base_url.as_deref().unwrap_or_default(), - 4015
&api_key, - 4016
)), - 4017
api_key, - 4018
base_url, - 4019
..Default::default() - 4020
}) - 4021
} - 4022
other => Err(CoreError::MissingAuth { - 4023
env: format!("(no auth wiring for '{other}' yet)"), - 4024
provider: other.into(), - 4025
}), - 4026
} - 4027
} - 4028
- 4029
pub fn provider_pool_env_var(provider: &str) -> Option<&'static str> { - 4030
match provider { - 4031
"anthropic" => Some("ANTHROPIC_API_KEYS"), - 4032
"google" => Some("GEMINI_API_KEYS"), - 4033
"openai" | "openai-responses" => Some("OPENAI_API_KEYS"), - 4034
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEYS"), - 4035
"opencode-zen" => Some("OPENCODE_API_KEYS"), - 4036
"ollama" => None, - 4037
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4038
_ => None, - 4039
} - 4040
} - 4041
- 4042
/// Resolve all credentials configured for one provider. The singular - 4043
/// provider variable remains the primary; the plural companion is an - 4044
/// operator-managed secret value separated by commas or newlines. - 4045
/// Returned identities are stable fingerprints, never the credentials. - 4046
fn provider_auth_pool_for(&self, provider: &str) -> Result<Vec<ProviderAuth>, CoreError> { - 4047
let primary = self.provider_auth_for(provider)?; - 4048
let Some(pool_env) = Self::provider_pool_env_var(provider) else { - 4049
return Ok(vec![primary]); - 4050
}; - 4051
let mut keys = vec![primary.api_key.clone()]; - 4052
if let Some(value) = self.provider_secret(pool_env) { - 4053
keys.extend( - 4054
value - 4055
.split([',', '\n']) - 4056
.map(str::trim) - 4057
.filter(|key| !key.is_empty()) - 4058
.map(str::to_string), - 4059
); - 4060
} - 4061
let mut unique_keys = Vec::with_capacity(keys.len()); - 4062
for key in keys { - 4063
if !unique_keys.iter().any(|existing| existing == &key) { - 4064
unique_keys.push(key); - 4065
} - 4066
} - 4067
let base_url = primary.base_url.clone(); - 4068
Ok(unique_keys - 4069
.into_iter() - 4070
.map(|api_key| ProviderAuth { - 4071
credential_id: Some(vak_llm::credential_id( - 4072
base_url.as_deref().unwrap_or_default(), - 4073
&api_key, - 4074
)), - 4075
api_key, - 4076
base_url: base_url.clone(), - 4077
..Default::default() - 4078
}) - 4079
.collect()) - 4080
} - 4081
- 4082
fn provider_auth_for_leg( - 4083
&self, - 4084
provider: &str, - 4085
credential_id: Option<&str>, - 4086
) -> Result<ProviderAuth, CoreError> { - 4087
let pool = self.provider_auth_pool_for(provider)?; - 4088
if let Some(id) = credential_id { - 4089
return pool - 4090
.into_iter() - 4091
.find(|auth| auth.credential_id.as_deref() == Some(id)) - 4092
.ok_or_else(|| CoreError::MissingAuth { - 4093
env: format!("credential pool for {provider}"), - 4094
provider: provider.to_string(), - 4095
}); - 4096
} - 4097
pool.into_iter() - 4098
.next() - 4099
.ok_or_else(|| CoreError::MissingAuth { - 4100
env: format!("credential pool for {provider}"), - 4101
provider: provider.to_string(), - 4102
}) - 4103
} - 4104
- 4105
/// Return only non-secret identities in the configured provider pool. - 4106
/// This is safe for picker/admin surfaces and lets operators verify that - 4107
/// a plural pool variable was actually discovered. - 4108
pub fn provider_credential_ids(&self, provider: &str) -> Vec<String> { - 4109
self.provider_auth_pool_for(provider) - 4110
.unwrap_or_default() - 4111
.into_iter() - 4112
.filter_map(|auth| auth.credential_id) - 4113
.collect() - 4114
} - 4115
- 4116
pub fn provider(&self) -> Result<Arc<dyn Provider>, CoreError> { - 4117
if let Ok(p) = self.inner.provider_instance.lock() - 4118
&& let Some(provider) = p.as_ref() - 4119
{ - 4120
return Ok(provider.clone()); - 4121
} - 4122
let auth = self.provider_auth()?; - 4123
Ok(self.inner.registry.get(&self.effective_provider(), &auth)?) - 4124
} - 4125
- 4126
/// The env var that authenticates `provider`, or None for keyless - 4127
/// providers (ollama). Unknown providers yield None as well — callers - 4128
/// distinguish via `provider_known`. - 4129
pub fn provider_env_var(provider: &str) -> Option<&'static str> { - 4130
match provider { - 4131
"anthropic" => Some("ANTHROPIC_API_KEY"), - 4132
"google" => Some("GEMINI_API_KEY"), - 4133
"openai" | "openai-responses" => Some("OPENAI_API_KEY"), - 4134
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEY"), - 4135
"opencode-zen" => Some("OPENCODE_API_KEY"), - 4136
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4137
_ => None, - 4138
} - 4139
} - 4140
- 4141
/// The name a person knows `provider` by, for every everyday screen; - 4142
/// the id stays for configuration and technical views. This table is - 4143
/// also the set of known providers (`provider_known`), so a provider - 4144
/// cannot be added without a name. - 4145
pub fn provider_label(provider: &str) -> Option<&'static str> { - 4146
match provider { - 4147
"anthropic" => Some("Anthropic"), - 4148
"google" => Some("Google Gemini"), - 4149
"openai" => Some("OpenAI"), - 4150
"openai-responses" => Some("OpenAI (Responses API)"), - 4151
"openrouter" => Some("OpenRouter"), - 4152
"openrouter-responses" => Some("OpenRouter (Responses API)"), - 4153
"opencode-zen" => Some("OpenCode Zen"), - 4154
"bedrock" => Some("Amazon Bedrock"), - 4155
"ollama" => Some("Ollama"), - 4156
_ => None, - 4157
} - 4158
} - 4159
- 4160
pub fn provider_known(provider: &str) -> bool { - 4161
Self::provider_label(provider).is_some() - 4162
} - 4163
- 4164
/// True when a run on `provider` would find credentials right now. - 4165
pub fn provider_configured(&self, provider: &str) -> bool { - 4166
if self - 4167
.inner - 4168
.provider_instance - 4169
.lock() - 4170
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4171
.is_some() - 4172
{ - 4173
return true; - 4174
} - 4175
self.provider_auth_for_leg(provider, None).is_ok() - 4176
} - 4177
- 4178
/// Secret provenance for administrative displays. Values are deliberately - 4179
/// reduced to booleans; credentials and their fingerprints never leave - 4180
/// the process through this API. - 4181
pub fn provider_key_sources(&self, provider: &str) -> (bool, bool, bool) { - 4182
let Some(env) = Self::provider_env_var(provider) else { - 4183
return (false, false, false); - 4184
}; - 4185
let project = vak_config::read_env_file_var(&self.inner.cwd.join(".env"), env).is_some(); - 4186
let agent = self.agent_identity.is_some() - 4187
&& vak_config::read_env_file_var(&self.sessions_home().join(".env"), env).is_some(); - 4188
let user = agent || vak_config::read_env_file_var(&self.user_env_file(), env).is_some(); - 4189
let process = std::env::var(env) - 4190
.ok() - 4191
.is_some_and(|v| !v.trim().is_empty()); - 4192
(project, user, process) - 4193
} - 4194
- 4195
/// Persists the Shared provider key. Prefer [`Self::set_provider_key_scoped`] - 4196
/// when the caller needs a project-local override. - 4197
pub fn set_provider_key(&self, provider: &str, key: &str) -> Result<String, CoreError> { - 4198
self.set_provider_key_scoped(provider, key, false) - 4199
} - 4200
- 4201
/// Persists a provider credential at Shared or project scope without - 4202
/// placing project credentials in the process-global environment. - 4203
pub fn set_provider_key_scoped( - 4204
&self, - 4205
provider: &str, - 4206
key: &str, - 4207
project: bool, - 4208
) -> Result<String, CoreError> { - 4209
let key = key.trim(); - 4210
if key.is_empty() { - 4211
return Err(CoreError::InvalidConfig("empty api key".into())); - 4212
} - 4213
let env = Self::provider_env_var(provider).ok_or_else(|| { - 4214
CoreError::InvalidConfig(format!( - 4215
"unknown provider '{provider}' (or it needs no key)" - 4216
)) - 4217
})?; - 4218
let path = if project { - 4219
self.inner.cwd.join(".env") - 4220
} else { - 4221
self.user_env_file() - 4222
}; - 4223
vak_config::upsert_env_file(&path, env, key) - 4224
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4225
// A different key reaches a different set of models, and any cached - 4226
// client still holds the old credential. - 4227
self.invalidate_models_cache(Some(provider)); - 4228
if let Ok(mut p) = self.inner.provider_instance.lock() { - 4229
*p = None; - 4230
} - 4231
Ok(env.to_string()) - 4232
} - 4233
- 4234
/// Removes the Shared provider key. Prefer - 4235
/// [`Self::remove_provider_key_scoped`] for a project-local override. - 4236
pub fn remove_provider_key(&self, provider: &str) -> Result<RemovedKey, CoreError> { - 4237
self.remove_provider_key_scoped(provider, false) - 4238
} - 4239
- 4240
/// Remove one provider-key layer. A removed project value resumes Shared - 4241
/// inheritance; process environment values remain outside Admin control. - 4242
pub fn remove_provider_key_scoped( - 4243
&self, - 4244
provider: &str, - 4245
project: bool, - 4246
) -> Result<RemovedKey, CoreError> { - 4247
let env = Self::provider_env_var(provider).ok_or_else(|| { - 4248
CoreError::InvalidConfig(format!( - 4249
"unknown provider '{provider}' (or it needs no key)" - 4250
)) - 4251
})?; - 4252
let path = if project { - 4253
self.inner.cwd.join(".env") - 4254
} else { - 4255
self.user_env_file() - 4256
}; - 4257
vak_config::remove_env_file_key(&path, env) - 4258
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4259
self.invalidate_models_cache(Some(provider)); - 4260
// Any cached client was built with the old key. - 4261
if let Ok(mut p) = self.inner.provider_instance.lock() { - 4262
*p = None; - 4263
} - 4264
Ok(RemovedKey { - 4265
env_var: env.to_string(), - 4266
// If it still resolves, it comes from the process environment. - 4267
shadowed_by_env: self.provider_secret(env).is_some(), - 4268
}) - 4269
} - 4270
- 4271
/// Store an MCP credential in the shared user secret file. The MCP config - 4272
/// should contain a `${VAR}` reference, never the credential itself. - 4273
pub fn set_mcp_secret(&self, env_var: &str, key: &str) -> Result<(), CoreError> { - 4274
self.set_mcp_secret_scoped(env_var, key, false) - 4275
} - 4276
- 4277
/// Persist an MCP credential at user or project scope. Project values are - 4278
/// deliberately not registered as process-global overrides: a gateway may - 4279
/// host several workspaces that use the same variable name with different - 4280
/// credentials. - 4281
pub fn set_mcp_secret_scoped( - 4282
&self, - 4283
env_var: &str, - 4284
key: &str, - 4285
project: bool, - 4286
) -> Result<(), CoreError> { - 4287
let env_var = env_var.trim(); - 4288
let key = key.trim(); - 4289
if env_var.is_empty() - 4290
|| !env_var - 4291
.chars() - 4292
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') - 4293
|| key.is_empty() - 4294
{ - 4295
return Err(CoreError::InvalidConfig("invalid MCP secret".into())); - 4296
} - 4297
let path = if project { - 4298
self.inner.cwd.join(".env") - 4299
} else { - 4300
self.user_env_file() - 4301
}; - 4302
vak_config::upsert_env_file(&path, env_var, key) - 4303
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4304
- 4305
self.invalidate_mcp_cache(); - 4306
// A changed secret changes the server set's fingerprint, so the next - 4307
// demand gets a fresh pool; the registry just needs to re-declare. - 4308
self.capability_registry() - 4309
.hint(capability::Hint::ConfigChanged); - 4310
Ok(()) - 4311
} - 4312
- 4313
/// Remove only the selected MCP secret layer. Removing a project value - 4314
/// resumes user/process inheritance; it never revokes the inherited key. - 4315
pub fn remove_mcp_secret_scoped(&self, env_var: &str, project: bool) -> Result<(), CoreError> { - 4316
let path = if project { - 4317
self.inner.cwd.join(".env") - 4318
} else { - 4319
self.user_env_file() - 4320
}; - 4321
vak_config::remove_env_file_key(&path, env_var) - 4322
.map_err(|e| CoreError::InvalidConfig(format!("writing {path:?}: {e}")))?; - 4323
- 4324
self.invalidate_mcp_cache(); - 4325
// A changed secret changes the server set's fingerprint, so the next - 4326
// demand gets a fresh pool; the registry just needs to re-declare. - 4327
self.capability_registry() - 4328
.hint(capability::Hint::ConfigChanged); - 4329
Ok(()) - 4330
} - 4331
- 4332
pub fn mcp_secret_at_scope(&self, env_var: &str, project: bool) -> bool { - 4333
let path = if project { - 4334
self.inner.cwd.join(".env") - 4335
} else { - 4336
self.user_env_file() - 4337
}; - 4338
vak_config::read_env_file_var(&path, env_var).is_some() - 4339
} - 4340
- 4341
pub fn mcp_secret(&self, env_var: &str) -> Option<String> { - 4342
self.scoped_secret(env_var) - 4343
} - 4344
- 4345
/// A distributed-bus secret (`vak_config::BUS_NATS_JWT_VAR`, - 4346
/// `BUS_NATS_NKEY_SEED_VAR`) through the secrets chain. - 4347
pub fn bus_secret(&self, env_var: &str) -> Option<String> { - 4348
self.scoped_secret(env_var) - 4349
} - 4350
- 4351
fn provider_secret(&self, env_var: &str) -> Option<String> { - 4352
self.scoped_secret(env_var) - 4353
.or_else(|| vak_config::get_var(env_var)) - 4354
} - 4355
- 4356
fn scoped_secret(&self, env_var: &str) -> Option<String> { - 4357
if self.agent_identity.is_some() - 4358
&& let Some(val) = - 4359
vak_config::read_env_file_var(&self.sessions_home().join(".env"), env_var) - 4360
{ - 4361
return Some(val); - 4362
} - 4363
vak_config::read_env_file_var(&self.inner.cwd.join(".env"), env_var) - 4364
.or_else(|| vak_config::read_env_file_var(&self.user_env_file(), env_var)) - 4365
.or_else(|| std::env::var(env_var).ok()) - 4366
} - 4367
- 4368
/// The chat transports a bot can be created on. - 4369
/// - 4370
/// A surface is a **transport, not a credential slot** (AGENTS.md - 4371
/// invariant 23): several bots can share one, each with its own token, - 4372
/// policy, permission mode, and route. The fixed per-surface env vars - 4373
/// this replaces (`TELEGRAM_BOT_TOKEN` and friends) could only ever - 4374
/// describe one bot per transport, which is why they are gone. - 4375
/// - 4376
/// **Alphabetical, and this is the only list.** Every surface — API, - 4377
/// admin console, desktop — renders from here rather than carrying its - 4378
/// own copy, so adding a transport is one edit and no channel can - 4379
/// quietly become the default by being first or by being the one a UI - 4380
/// happens to hardcode. - 4381
pub const SURFACES: &'static [ChatSurface] = &[ - 4382
ChatSurface { - 4383
id: "discord", - 4384
label: "Discord", - 4385
}, - 4386
ChatSurface { - 4387
id: "slack", - 4388
label: "Slack", - 4389
}, - 4390
ChatSurface { - 4391
id: "telegram", - 4392
label: "Telegram", - 4393
}, - 4394
]; - 4395
- 4396
/// True when `surface` names a transport vak can bridge. - 4397
pub fn is_surface(surface: &str) -> bool { - 4398
Self::SURFACES.iter().any(|s| s.id == surface) - 4399
} - 4400
- 4401
/// The human label for a surface id, falling back to the id itself so - 4402
/// an unknown value renders as data rather than as an empty cell. - 4403
pub fn surface_label(id: &str) -> &str { - 4404
Self::SURFACES - 4405
.iter() - 4406
.find(|s| s.id == id) - 4407
.map(|s| s.label) - 4408
.unwrap_or(id) - 4409
}
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.