- 14464
/// Grants are privileged and refused for an untrusted project layer. - 14465
#[serde(default)] - 14466
plugins_network_allow: Option<Vec<String>>, - 14467
#[serde(default)] - 14468
agent: Option<String>, - 14469
} - 14470
- 14471
async fn patch_config( - 14472
State(state): State<AppState>, - 14473
Json(body): Json<ConfigPatch>, - 14474
) -> axum::response::Response { - 14475
patch_config_scope(state, body, false).await - 14476
} - 14477
- 14478
async fn patch_global_config( - 14479
State(state): State<AppState>, - 14480
Json(body): Json<ConfigPatch>, - 14481
) -> axum::response::Response { - 14482
patch_config_scope(state, body, true).await - 14483
} - 14484
- 14485
#[derive(serde::Deserialize)] - 14486
struct EvidencePolicyBody { - 14487
seconds: i64, - 14488
#[serde(default)] - 14489
scope: Option<String>, - 14490
} - 14491
- 14492
async fn patch_evidence_policy( - 14493
State(state): State<AppState>, - 14494
Json(body): Json<EvidencePolicyBody>, - 14495
) -> axum::response::Response { - 14496
let path = if body.scope.as_deref() == Some("user") { - 14497
let Some(path) = vak_config::global_path() else { - 14498
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 14499
}; - 14500
path - 14501
} else { - 14502
vak_config::project_path(state.core.cwd()) - 14503
}; - 14504
if let Err(error) = vak_config::persist_evidence_max_age(path, body.seconds) { - 14505
return ( - 14506
StatusCode::INTERNAL_SERVER_ERROR, - 14507
Json(serde_json::json!({"error": error.to_string()})), - 14508
) - 14509
.into_response(); - 14510
} - 14511
if state.core.refresh_persisted_preferences().is_err() { - 14512
return ( - 14513
StatusCode::INTERNAL_SERVER_ERROR, - 14514
"could not apply evidence policy", - 14515
) - 14516
.into_response(); - 14517
} - 14518
Json(serde_json::json!({"saved": true, "seconds": body.seconds.max(0)})).into_response() - 14519
} - 14520
- 14521
/// Which keys this write landed on disk but did NOT put into force, because - 14522
/// a narrower layer already pins them. - 14523
/// - 14524
/// The project layer is merged after the global one, so a project - 14525
/// `permission_mode` wins. Writing the global value and then force-applying - 14526
/// it as a runtime override made the live process disagree with what the - 14527
/// files resolve to — right until the next restart, when the project pin - 14528
/// reasserted and the operator's change appeared to have been forgotten. - 14529
/// Persisting and then saying which keys are shadowed is honest; applying - 14530
/// them was not. - 14531
fn shadowed_by_project(core: &vak_core::Core, body: &ConfigPatch) -> Vec<&'static str> { - 14532
let path = vak_config::project_path(core.cwd()); - 14533
// A workspace that IS the default workspace has one file serving as both - 14534
// layers, and `load_with_trust` skips the project pass for exactly that - 14535
// case. Comparing the file against itself made every global write on the - 14536
// gateway's own workspace — the normal case — report as shadowed by a - 14537
// project layer that does not independently exist, telling an operator - 14538
// their change would not take effect when it would. - 14539
if vak_config::global_path().is_some_and(|global| global == path) { - 14540
return Vec::new(); - 14541
} - 14542
let Ok(project) = read_config_layer(&path) else { - 14543
return Vec::new(); - 14544
}; - 14545
let mut out = Vec::new(); - 14546
if body.permission_mode.is_some() && project.permission_mode.is_some() { - 14547
out.push("permission_mode"); - 14548
} - 14549
if body.approval_mode.is_some() && project.approval_mode.is_some() { - 14550
out.push("approval_mode"); - 14551
} - 14552
if body.plugins_network_allow.is_some() && project.plugins.network_allow.is_some() { - 14553
out.push("plugins_network_allow"); - 14554
} - 14555
out - 14556
} - 14557
- 14558
async fn patch_config_scope( - 14559
state: AppState, - 14560
body: ConfigPatch, - 14561
global: bool, - 14562
) -> axum::response::Response { - 14563
let core = scoped_core!(&state, None, body.agent.as_deref()); - 14564
if global - 14565
&& (body.inherit_mcp.is_some() - 14566
|| body.inherit_hooks.is_some() - 14567
|| body.inherit_skills.is_some() - 14568
|| body.inherit_commands.is_some() - 14569
|| body.inherit_plugins.is_some()) - 14570
{ - 14571
return StatusCode::BAD_REQUEST.into_response(); - 14572
} - 14573
if body - 14574
.provider - 14575
.as_deref() - 14576
.is_some_and(|value| value.trim().is_empty()) - 14577
|| body - 14578
.model - 14579
.as_deref() - 14580
.is_some_and(|value| value.trim().is_empty()) - 14581
|| body - 14582
.max_turns - 14583
.is_some_and(|value| !(1..=1000).contains(&value)) - 14584
|| body - 14585
.theme - 14586
.as_deref() - 14587
.is_some_and(|value| !matches!(value, "dark" | "light" | "plain")) - 14588
|| body - 14589
.permission_mode - 14590
.as_deref() - 14591
.is_some_and(|value| parse_mode(value).is_none()) - 14592
|| body - 14593
.approval_mode - 14594
.as_deref() - 14595
.is_some_and(|value| parse_approval_mode(value).is_none()) - 14596
|| body - 14597
.work_default_mode - 14598
.as_deref() - 14599
.is_some_and(|value| !matches!(value, "direct" | "managed" | "auto")) - 14600
|| body - 14601
.work_confirmation - 14602
.as_deref() - 14603
.is_some_and(|value| !matches!(value, "risk-based" | "always" | "never")) - 14604
|| body - 14605
.work_max_items - 14606
.is_some_and(|value| !(1..=128).contains(&value)) - 14607
|| body - 14608
.work_max_revisions - 14609
.is_some_and(|value| !(1..=64).contains(&value)) - 14610
|| body - 14611
.work_max_parallel - 14612
.is_some_and(|value| !(1..=32).contains(&value)) - 14613
{ - 14614
return StatusCode::BAD_REQUEST.into_response(); - 14615
} - 14616
let permission_mode = body.permission_mode.as_deref().and_then(parse_mode); - 14617
let approval_mode = body.approval_mode.as_deref().and_then(parse_approval_mode); - 14618
let voice_patch = vak_config::VoicePatch { - 14619
enabled: body.voice_enabled, - 14620
max_session_secs: body.voice_max_session_secs, - 14621
max_concurrent: body.voice_max_concurrent, - 14622
max_audio_bytes: body.voice_max_audio_bytes, - 14623
provider: body.voice_provider.clone(), - 14624
transcription_model: body.voice_transcription_model.clone(), - 14625
synthesis_model: body.voice_synthesis_model.clone(), - 14626
}; - 14627
let invalid_name = |value: &String| value.trim().is_empty() || value.chars().count() > 256; - 14628
if voice_patch - 14629
.max_session_secs - 14630
.is_some_and(|v| !(1..=86_400).contains(&v)) - 14631
|| voice_patch - 14632
.max_concurrent - 14633
.is_some_and(|v| !(1..=64).contains(&v)) - 14634
|| voice_patch - 14635
.max_audio_bytes - 14636
.is_some_and(|v| !(1..=256 * 1024 * 1024).contains(&v)) - 14637
|| voice_patch - 14638
.provider - 14639
.as_ref() - 14640
.and_then(Option::as_ref) - 14641
.is_some_and(|v| v.parse::<vak_voice::VoiceProvider>().is_err()) - 14642
|| [ - 14643
voice_patch.transcription_model.as_ref(), - 14644
voice_patch.synthesis_model.as_ref(), - 14645
] - 14646
.into_iter() - 14647
.flatten() - 14648
.flatten() - 14649
.any(invalid_name) - 14650
{ - 14651
return StatusCode::BAD_REQUEST.into_response(); - 14652
} - 14653
if !voice_patch.is_empty() { - 14654
let path = if global { - 14655
vak_config::global_path().ok_or_else(|| vak_config::ConfigError::Write { - 14656
path: std::path::PathBuf::from("<user-config>"), - 14657
source: std::io::Error::other("user home unavailable"), - 14658
}) - 14659
} else { - 14660
Ok(vak_config::project_path(core.cwd())) - 14661
}; - 14662
let result = - 14663
path.and_then(|path| vak_config::persist_voice_settings_at(path, &voice_patch)); - 14664
if result.is_err() { - 14665
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14666
} - 14667
// Re-read the persisted layered value and publish it immediately; - 14668
// new voice sessions observe the change without a restart. - 14669
if let Ok(effective) = - 14670
vak_config::load_with_trust(core.cwd(), core.project_config_trusted()) - 14671
{ - 14672
core.apply_persisted_voice(effective.voice); - 14673
} - 14674
} - 14675
let current_route = core.effective_route(); - 14676
let route_change = body.provider.is_some() || body.model.is_some(); - 14677
let provider = route_change.then(|| { - 14678
body.provider - 14679
.as_deref() - 14680
.map(str::trim) - 14681
.unwrap_or(¤t_route.provider) - 14682
.to_string() - 14683
}); - 14684
let model = route_change.then(|| { - 14685
body.model - 14686
.as_deref() - 14687
.map(str::trim) - 14688
.unwrap_or(¤t_route.model) - 14689
.to_string() - 14690
}); - 14691
if (body.provider.is_some() - 14692
|| body.model.is_some() - 14693
|| body.max_turns.is_some() - 14694
|| permission_mode.is_some() - 14695
|| approval_mode.is_some() - 14696
|| body.theme.is_some()) - 14697
&& (if global { - 14698
vak_config::persist_global_preferences( - 14699
provider.as_deref(), - 14700
model.as_deref(), - 14701
body.max_turns, - 14702
permission_mode, - 14703
approval_mode, - 14704
body.theme.as_deref(), - 14705
) - 14706
} else { - 14707
vak_config::persist_project_preferences( - 14708
core.cwd(), - 14709
provider.as_deref(), - 14710
model.as_deref(), - 14711
body.max_turns, - 14712
permission_mode, - 14713
approval_mode, - 14714
body.theme.as_deref(), - 14715
) - 14716
}) - 14717
.is_err() - 14718
{ - 14719
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14720
} - 14721
// Only a global write can be shadowed: the project layer is the last - 14722
// one merged, so a project write is already the winner. - 14723
let shadowed: Vec<&'static str> = if global { - 14724
shadowed_by_project(&core, &body) - 14725
} else { - 14726
Vec::new() - 14727
}; - 14728
let mut changes = Vec::new(); - 14729
if let (Some(provider), Some(model)) = (provider, model) { - 14730
let effective = vak_config::load_with_trust(core.cwd(), core.project_config_trusted()); - 14731
let Ok(effective) = effective else { - 14732
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14733
}; - 14734
core.apply_persisted_route(effective.provider, effective.model); - 14735
changes.push(format!("route={provider}/{model}")); - 14736
} - 14737
if let Some(max_turns) = body.max_turns { - 14738
if !(1..=1000).contains(&max_turns) { - 14739
return StatusCode::BAD_REQUEST.into_response(); - 14740
} - 14741
core.apply_persisted_max_turns(max_turns); - 14742
changes.push(format!("max_turns={max_turns}")); - 14743
} - 14744
if let Some(mode) = &body.permission_mode { - 14745
let Some(mode) = parse_mode(mode) else { - 14746
return StatusCode::BAD_REQUEST.into_response(); - 14747
}; - 14748
// Persisted above either way; only put into force when nothing - 14749
// narrower already pins it, so the live value and the files agree. - 14750
if shadowed.contains(&"permission_mode") { - 14751
changes.push(format!("permission_mode={mode:?} (persisted, shadowed)")); - 14752
} else { - 14753
apply_permission_mode(&core, &state, mode, true); - 14754
changes.push(format!("permission_mode={mode:?}")); - 14755
} - 14756
} - 14757
if let Some(raw) = &body.approval_mode { - 14758
let Some(mode) = parse_approval_mode(raw) else { - 14759
return StatusCode::BAD_REQUEST.into_response(); - 14760
}; - 14761
if shadowed.contains(&"approval_mode") { - 14762
changes.push(format!( - 14763
"approval_mode={} (persisted, shadowed)", - 14764
mode.as_str() - 14765
)); - 14766
} else { - 14767
core.apply_persisted_approval_mode(mode); - 14768
changes.push(format!("approval_mode={}", mode.as_str())); - 14769
} - 14770
} - 14771
if let Some(theme) = body.theme { - 14772
if !matches!(theme.as_str(), "dark" | "light" | "plain") { - 14773
return StatusCode::BAD_REQUEST.into_response(); - 14774
} - 14775
changes.push(format!("theme={theme}")); - 14776
core.apply_persisted_theme(theme); - 14777
} - 14778
if let Some(workers) = body.workers { - 14779
let persisted = if global { - 14780
vak_config::persist_global_workers(workers) - 14781
} else { - 14782
vak_config::persist_project_workers(core.cwd(), workers) - 14783
}; - 14784
if persisted.is_err() { - 14785
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14786
} - 14787
core.apply_persisted_workers(workers); - 14788
changes.push(format!("workers={workers}")); - 14789
} - 14790
if body.memory_search_enabled.is_some() - 14791
|| body.memory_write_enabled.is_some() - 14792
|| body.memory_reflection.is_some() - 14793
|| body.memory_skill_proposals.is_some() - 14794
{ - 14795
let persisted = if global { - 14796
vak_config::persist_global_memory_prefs( - 14797
body.memory_search_enabled, - 14798
body.memory_write_enabled, - 14799
body.memory_reflection, - 14800
body.memory_skill_proposals, - 14801
) - 14802
} else { - 14803
vak_config::persist_project_memory_prefs( - 14804
core.cwd(), - 14805
body.memory_search_enabled, - 14806
body.memory_write_enabled, - 14807
body.memory_reflection, - 14808
body.memory_skill_proposals, - 14809
) - 14810
}; - 14811
if persisted.is_err() { - 14812
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14813
} - 14814
// `apply_persisted_memory` sets all four flags at once, so fields - 14815
// this PATCH didn't mention keep their current effective value - 14816
// rather than reverting to whatever was on disk before. - 14817
core.apply_persisted_memory( - 14818
body.memory_search_enabled - 14819
.unwrap_or_else(|| core.effective_memory_search_enabled()), - 14820
body.memory_write_enabled - 14821
.unwrap_or_else(|| core.effective_memory_write_enabled()), - 14822
body.memory_reflection - 14823
.unwrap_or_else(|| core.effective_memory_reflection()), - 14824
body.memory_skill_proposals - 14825
.unwrap_or_else(|| core.effective_memory_skill_proposals()), - 14826
); - 14827
changes.push(format!( - 14828
"memory(search={}, write={}, reflection={}, skill_proposals={})", - 14829
core.effective_memory_search_enabled(), - 14830
core.effective_memory_write_enabled(), - 14831
core.effective_memory_reflection(), - 14832
core.effective_memory_skill_proposals(), - 14833
)); - 14834
} - 14835
if body.work_enabled.is_some() - 14836
|| body.work_default_mode.is_some() - 14837
|| body.work_max_items.is_some() - 14838
|| body.work_max_revisions.is_some() - 14839
|| body.work_max_parallel.is_some() - 14840
|| body.work_confirmation.is_some() - 14841
{ - 14842
let path = if global { - 14843
vak_config::global_path().ok_or(StatusCode::INTERNAL_SERVER_ERROR) - 14844
} else { - 14845
Ok(vak_config::project_path(core.cwd())) - 14846
}; - 14847
let Ok(path) = path else { - 14848
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14849
}; - 14850
if vak_config::persist_work_preferences( - 14851
path, - 14852
body.work_enabled, - 14853
body.work_default_mode.as_deref(), - 14854
body.work_max_items, - 14855
body.work_max_revisions, - 14856
body.work_max_parallel, - 14857
body.work_confirmation.as_deref(), - 14858
) - 14859
.is_err() - 14860
{ - 14861
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14862
} - 14863
let resolved = vak_config::load_with_trust(core.cwd(), core.project_config_trusted()); - 14864
let Ok(resolved) = resolved else { - 14865
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14866
}; - 14867
core.apply_persisted_work(resolved.work); - 14868
changes.push(format!( - 14869
"work(mode={}, enabled={})", - 14870
core.effective_work().default_mode, - 14871
core.effective_work().enabled - 14872
)); - 14873
} - 14874
if let Some(grant) = &body.plugins_network_allow { - 14875
if !grant.is_empty() && !global && !core.project_config_trusted() { - 14876
return ( - 14877
StatusCode::FORBIDDEN, - 14878
Json(serde_json::json!({ - 14879
"error": "plugins.network_allow is a privileged key: an untrusted \ - 14880
workspace may not grant its execution plugins network \ - 14881
egress. Set it from the Trusted (global) settings instead." - 14882
})), - 14883
) - 14884
.into_response(); - 14885
} - 14886
let path = if global { - 14887
vak_config::global_path().ok_or(StatusCode::INTERNAL_SERVER_ERROR) - 14888
} else { - 14889
Ok(vak_config::project_path(core.cwd())) - 14890
}; - 14891
let Ok(path) = path else { - 14892
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14893
}; - 14894
if vak_config::persist_plugins_network_allow(&path, Some(grant.clone())).is_err() { - 14895
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14896
} - 14897
if core.refresh_persisted_preferences().is_err() { - 14898
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14899
} - 14900
if shadowed.contains(&"plugins_network_allow") { - 14901
changes.push(format!( - 14902
"plugins_network_allow={grant:?} (persisted, shadowed by project)" - 14903
)); - 14904
} else { - 14905
changes.push(format!("plugins_network_allow={grant:?}")); - 14906
} - 14907
} - 14908
if body.inherit_mcp.is_some() - 14909
|| body.inherit_hooks.is_some() - 14910
|| body.inherit_skills.is_some() - 14911
|| body.inherit_commands.is_some() - 14912
|| body.inherit_plugins.is_some() - 14913
{ - 14914
let path = vak_config::project_path(core.cwd()); - 14915
if vak_config::persist_capability_inheritance( - 14916
&path, - 14917
body.inherit_mcp, - 14918
body.inherit_hooks, - 14919
body.inherit_skills, - 14920
body.inherit_commands, - 14921
body.inherit_plugins, - 14922
) - 14923
.is_err() - 14924
{ - 14925
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14926
} - 14927
let Ok(resolved) = vak_config::load_with_trust(core.cwd(), core.project_config_trusted()) - 14928
else { - 14929
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - 14930
}; - 14931
core.apply_persisted_capability_inheritance(resolved.capabilities); - 14932
core.apply_persisted_mcp_servers(resolved.mcp); - 14933
core.apply_persisted_hooks(resolved.hooks); - 14934
changes.push("capability_inheritance".into()); - 14935
} - 14936
if !changes.is_empty() { - 14937
vak_core::security_events::record( - 14938
&core.sessions_home(), - 14939
vak_core::security_events::EventKind::ConfigChange, - 14940
"config_patched", - 14941
&changes.join(", "), - 14942
None, - 14943
); - 14944
state - 14945
.hub - 14946
.emit_config_changed("config_patched", &changes.join(", ")); - 14947
} - 14948
( - 14949
StatusCode::OK, - 14950
Json(serde_json::json!({ - 14951
"applied": changes, - 14952
// Named, so a client can tell the operator "saved, but this - 14953
// project overrides it" instead of reporting a clean success - 14954
// that the next restart quietly undoes. - 14955
"shadowed_by_project": shadowed, - 14956
})), - 14957
) - 14958
.into_response() - 14959
} - 14960
- 14961
// ---- MCP server management -------------------------------------------------- - 14962
// - 14963
// The desktop Settings page edits the MCP table here: GET reads the - 14964
// effective table; PUT validates, persists to the project config.toml - 14965
// ([mcp.servers]) and hot-applies into the running Core so the next turn - 14966
// picks it up without a backend restart. mcp.servers is a privileged key: - 14967
// this endpoint is only reachable through the bearer-token router of a - 14968
// locally trusted surface. - 14969
- 14970
/// Project-only, deliberately not `core.effective_mcp()` (the merged - 14971
/// effective set, global layer included). `PUT /config/mcp` writes whatever - 14972
/// this reports straight into the *project* config — reporting the merged - 14973
/// set would silently fork every currently-inherited global MCP server - 14974
/// into the project file the moment any one server was added, edited, or - 14975
/// removed here, freezing that project's copy out of future changes to the - 14976
/// global definition. Unlike the hooks list (a plain `Vec` extended with no - 14977
/// dedup key), the merge for MCP servers is a name-keyed map — so this - 14978
/// couldn't duplicate or compound the way the hooks bug did, but it would - 14979
/// still quietly diverge project config from what the operator thought - 14980
/// they were changing. Mirrors `get_global_mcp_servers`, which has always - 14981
/// read its own file directly for the same reason. - 14982
async fn get_mcp_servers( - 14983
State(state): State<AppState>, - 14984
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 14985
) -> axum::response::Response { - 14986
use axum::response::IntoResponse; - 14987
let core = scoped_core!(&state, None, q.agent.as_deref()); - 14988
let path = vak_config::project_path(core.cwd()); - 14989
match read_mcp_config(&path) { - 14990
Ok(mcp) => Json(serde_json::json!({ "servers": mcp.servers })).into_response(), - 14991
Err(error) => ( - 14992
StatusCode::INTERNAL_SERVER_ERROR, - 14993
Json(serde_json::json!({ "error": error })), - 14994
) - 14995
.into_response(), - 14996
} - 14997
} - 14998
- 14999
#[derive(serde::Deserialize, Clone)] - 15000
struct HookInput { - 15001
event: String, - 15002
#[serde(default)] - 15003
matcher: Option<String>, - 15004
command: String, - 15005
#[serde(default)] - 15006
timeout_ms: Option<u64>, - 15007
#[serde(default = "default_hook_enabled")] - 15008
enabled: bool, - 15009
#[serde(default)] - 15010
failure_mode: Option<String>, - 15011
} - 15012
- 15013
fn default_hook_enabled() -> bool { - 15014
true - 15015
} - 15016
- 15017
#[derive(serde::Deserialize)] - 15018
struct HooksPutBody { - 15019
hooks: Vec<HookInput>, - 15020
#[serde(default)] - 15021
agent: Option<String>, - 15022
} - 15023
- 15024
/// Project-only, deliberately not `state.core.config().hooks` (the merged - 15025
/// effective list, global layer included — `vak_config::merge_into` extends - 15026
/// the project's hooks with the global ones on every load). `PUT - 15027
/// /config/hooks` replaces the *project* file's own `[[hooks]]` array with - 15028
/// whatever this endpoint reported; reporting the merged list would hand - 15029
/// back an inherited global hook, which the next save would then write into - 15030
/// the project file as if it were the project's own — duplicating it there, - 15031
/// and compounding on every subsequent edit as the merge re-extends over an - 15032
/// already-doubled list. Mirrors `get_global_hooks`, which has always read - 15033
/// its own file directly for the same reason. - 15034
/// Prompt layers (docs/design/45-prompt-layers.md). - 15035
/// - 15036
/// `GET /config/prompts?scope=` reports **only** the selected layer's own - 15037
/// text, never the merged view — AGENTS.md rule 21: this GET seeds a - 15038
/// same-shape PUT, and returning inherited text would write a parent layer's - 15039
/// content into the child file on the next save. The effective composition - 15040
/// is a separate endpoint on purpose. - 15041
async fn get_prompt_layer( - 15042
State(state): State<AppState>, - 15043
Query(query): Query<ScopeQuery>, - 15044
) -> axum::response::Response { - 15045
use axum::response::IntoResponse; - 15046
let core = scoped_core!(&state, None, query.agent.as_deref()); - 15047
let dir = vak_core::prompts::layer_dir(&query.scope.prompt_root(&core)); - 15048
let content = vak_core::prompts::read_layer(&dir); - 15049
Json(serde_json::json!({ - 15050
"scope": query.scope.label(), - 15051
"path": dir.display().to_string(), - 15052
"layer": content, - 15053
})) - 15054
.into_response() - 15055
} - 15056
- 15057
#[derive(serde::Deserialize)] - 15058
struct PromptBlockBody { - 15059
scope: ConfigScope, - 15060
block: String, - 15061
/// Absent or null resets the block and resumes inheritance. - 15062
#[serde(default)] - 15063
text: Option<String>, - 15064
#[serde(default)] - 15065
agent: Option<String>, - 15066
} - 15067
- 15068
async fn put_prompt_block( - 15069
State(state): State<AppState>, - 15070
Json(body): Json<PromptBlockBody>, - 15071
) -> axum::response::Response { - 15072
use axum::response::IntoResponse; - 15073
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15074
let Some(block) = vak_core::prompts::PromptBlock::parse(&body.block) else { - 15075
return ( - 15076
StatusCode::BAD_REQUEST, - 15077
Json(serde_json::json!({ - 15078
"error": format!("unknown block '{}'", body.block), - 15079
"editable": ["identity", "operating-rules", "guardrails"], - 15080
"note": "the capability contract, Surface line, and skill/MCP lists are code-owned", - 15081
})), - 15082
) - 15083
.into_response(); - 15084
}; - 15085
// A prompt is spent on every turn of every session, so an unbounded - 15086
// editor is a permanent context tax rather than a one-off mistake. - 15087
const MAX_BLOCK_BYTES: usize = 24_000; - 15088
if let Some(text) = body.text.as_deref() - 15089
&& text.len() > MAX_BLOCK_BYTES - 15090
{ - 15091
return ( - 15092
StatusCode::PAYLOAD_TOO_LARGE, - 15093
Json(serde_json::json!({ - 15094
"error": format!("{} is {}B; the cap is {MAX_BLOCK_BYTES}B", block.slug(), text.len()), - 15095
})), - 15096
) - 15097
.into_response(); - 15098
} - 15099
let dir = vak_core::prompts::layer_dir(&body.scope.prompt_root(&core)); - 15100
match vak_core::prompts::write_block(&dir, block, body.text.as_deref()) { - 15101
Ok(()) => Json(serde_json::json!({ - 15102
"ok": true, - 15103
"scope": body.scope.label(), - 15104
"block": block.slug(), - 15105
"reset": body.text.is_none(), - 15106
// Prompts freeze into the session contract, so the UI must not - 15107
// imply a running turn changed under the user. - 15108
"applies": "new sessions", - 15109
})) - 15110
.into_response(), - 15111
Err(error) => ( - 15112
StatusCode::INTERNAL_SERVER_ERROR, - 15113
Json(serde_json::json!({ "error": error.to_string() })), - 15114
) - 15115
.into_response(), - 15116
} - 15117
} - 15118
- 15119
/// The assembled prompt plus per-layer provenance — the right-hand pane. - 15120
async fn get_prompt_effective( - 15121
State(state): State<AppState>, - 15122
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15123
) -> axum::response::Response { - 15124
use axum::response::IntoResponse; - 15125
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15126
Json(prompt_effective_payload(&core)).into_response() - 15127
} - 15128
- 15129
async fn get_agents( - 15130
State(state): State<AppState>, - 15131
axum::extract::Query(query): axum::extract::Query<HashMap<String, String>>, - 15132
) -> axum::response::Response { - 15133
use axum::response::IntoResponse; - 15134
let root = match query - 15135
.get("scope") - 15136
.map(String::as_str) - 15137
.unwrap_or("workspace") - 15138
{ - 15139
"user" => vak_config::paths::default_workspace(), - 15140
"workspace" => state.active_core().cwd().clone(), - 15141
_ => { - 15142
return ( - 15143
StatusCode::BAD_REQUEST, - 15144
Json(serde_json::json!({"error": "invalid agent scope"})), - 15145
) - 15146
.into_response(); - 15147
} - 15148
}; - 15149
match agents::load(&root) { - 15150
Ok(agents) => Json(serde_json::json!({ "agents": agents })).into_response(), - 15151
Err(error) => ( - 15152
StatusCode::INTERNAL_SERVER_ERROR, - 15153
Json(serde_json::json!({ "error": error })), - 15154
) - 15155
.into_response(), - 15156
} - 15157
} - 15158
- 15159
async fn list_agent_templates() -> axum::response::Response { - 15160
use axum::response::IntoResponse; - 15161
Json(serde_json::json!({ - 15162
"templates": agents::builtin_templates() - 15163
})) - 15164
.into_response() - 15165
} - 15166
- 15167
#[derive(serde::Deserialize)] - 15168
struct InstantiateTemplateRequest { - 15169
template_id: String, - 15170
agent_id: String, - 15171
#[serde(default)] - 15172
name: Option<String>, - 15173
#[serde(default)] - 15174
scope: Option<String>, - 15175
} - 15176
- 15177
async fn instantiate_agent_template( - 15178
State(state): State<AppState>, - 15179
Json(body): Json<InstantiateTemplateRequest>, - 15180
) -> axum::response::Response { - 15181
use axum::response::IntoResponse; - 15182
let Some(template) = agents::find_template(&body.template_id) else { - 15183
return ( - 15184
StatusCode::NOT_FOUND, - 15185
Json(serde_json::json!({ "error": format!("template '{}' not found", body.template_id) })), - 15186
) - 15187
.into_response(); - 15188
}; - 15189
- 15190
let new_agent = template.to_agent_definition(&body.agent_id, body.name.as_deref()); - 15191
let root = match body.scope.as_deref().unwrap_or("workspace") { - 15192
"user" => vak_config::paths::default_workspace(), - 15193
_ => state.active_core().cwd().clone(), - 15194
}; - 15195
- 15196
let mut existing = agents::load(&root).unwrap_or_default(); - 15197
if existing.iter().any(|a| a.id == new_agent.id) { - 15198
return ( - 15199
StatusCode::CONFLICT, - 15200
Json( - 15201
serde_json::json!({ "error": format!("agent '{}' already exists", new_agent.id) }), - 15202
), - 15203
) - 15204
.into_response(); - 15205
} - 15206
existing.push(new_agent.clone()); - 15207
match agents::save( - 15208
&root, - 15209
&existing, - 15210
state.active_core().project_config_trusted(), - 15211
) { - 15212
Ok(_) => ( - 15213
StatusCode::CREATED, - 15214
Json(serde_json::json!({ "created": true, "agent": new_agent })), - 15215
) - 15216
.into_response(), - 15217
Err(err) => ( - 15218
StatusCode::BAD_REQUEST, - 15219
Json(serde_json::json!({ "error": err })), - 15220
) - 15221
.into_response(), - 15222
} - 15223
} - 15224
- 15225
#[derive(serde::Deserialize)] - 15226
struct CanvasPreviewRequest { - 15227
#[serde(default)] - 15228
title: Option<String>, - 15229
content: String, - 15230
} - 15231
- 15232
async fn canvas_preview(Json(body): Json<CanvasPreviewRequest>) -> axum::response::Response { - 15233
let title = body.title.as_deref().unwrap_or("Outcome Canvas"); - 15234
let html = vak_presentation::transcode_to_html(title, &body.content); - 15235
html_response(html) - 15236
} - 15237
- 15238
async fn put_agents( - 15239
State(state): State<AppState>, - 15240
Json(body): Json<serde_json::Value>, - 15241
) -> axum::response::Response { - 15242
use axum::response::IntoResponse; - 15243
let Some(raw) = body.get("agents") else { - 15244
return ( - 15245
StatusCode::BAD_REQUEST, - 15246
Json(serde_json::json!({ "error": "agents is required" })), - 15247
) - 15248
.into_response(); - 15249
}; - 15250
let Ok(agents) = serde_json::from_value::<Vec<agents::AgentDefinition>>(raw.clone()) else { - 15251
return ( - 15252
StatusCode::BAD_REQUEST, - 15253
Json(serde_json::json!({ "error": "invalid agents" })), - 15254
) - 15255
.into_response(); - 15256
}; - 15257
let root = match body - 15258
.get("scope") - 15259
.and_then(|v| v.as_str()) - 15260
.unwrap_or("workspace") - 15261
{ - 15262
"user" => vak_config::paths::default_workspace(), - 15263
"workspace" => state.active_core().cwd().clone(), - 15264
_ => { - 15265
return ( - 15266
StatusCode::BAD_REQUEST, - 15267
Json(serde_json::json!({"error": "invalid agent scope"})), - 15268
) - 15269
.into_response(); - 15270
} - 15271
}; - 15272
match agents::save(&root, &agents, state.active_core().project_config_trusted()) { - 15273
Ok(saved_agents) => { - 15274
Json(serde_json::json!({ "saved": true, "agents": saved_agents })).into_response() - 15275
} - 15276
Err(error) => ( - 15277
StatusCode::BAD_REQUEST, - 15278
Json(serde_json::json!({ "error": error })), - 15279
) - 15280
.into_response(), - 15281
} - 15282
} - 15283
- 15284
fn prompt_effective_payload(core: &vak_core::Core) -> serde_json::Value { - 15285
let resolution = core.resolve_prompt(&core.capability_descriptors()); - 15286
let seed_content = vak_core::prompts::seed(vak_core::APP_VERSION).content; - 15287
let seed_blocks = serde_json::json!({ - 15288
"identity": seed_content.identity, - 15289
"operating-rules": seed_content.operating_rules, - 15290
"guardrails": seed_content.guardrails.iter().map(|r| format!("- {r}")).collect::<Vec<_>>().join("\n"), - 15291
"surface-note": seed_content.surface_notes.iter().map(|r| format!("- {r}")).collect::<Vec<_>>().join("\n"), - 15292
}); - 15293
serde_json::json!({ - 15294
"text": resolution.text, - 15295
"fingerprint": resolution.fingerprint(), - 15296
"estimated_tokens": resolution.text.len() / 4, - 15297
"surface": core.surface().slug(), - 15298
"layers": resolution.descriptors, - 15299
"blocks": resolution.blocks, - 15300
"seed_blocks": seed_blocks, - 15301
}) - 15302
} - 15303
- 15304
#[derive(serde::Deserialize)] - 15305
struct PromptPreviewBody { - 15306
#[serde(default)] - 15307
surface: Option<String>, - 15308
#[serde(default)] - 15309
role: Option<String>, - 15310
#[serde(default)] - 15311
agent: Option<String>, - 15312
} - 15313
- 15314
/// Render exactly what a chosen surface and role would receive. Composition - 15315
/// across seven tiers is not guessable, so an editor without this is asking - 15316
/// the operator to simulate the resolver in their head. - 15317
async fn preview_prompt( - 15318
State(state): State<AppState>, - 15319
Json(body): Json<PromptPreviewBody>, - 15320
) -> axum::response::Response { - 15321
use axum::response::IntoResponse; - 15322
let core = scoped_core!(&state, None, body.agent.as_deref()); - 15323
let raw_surface = body.surface.as_deref().map(str::trim).unwrap_or(""); - 15324
let surface = match raw_surface.to_ascii_lowercase().as_str() { - 15325
"" | "unknown" => vak_core::Surface::Unknown, - 15326
"cli" => vak_core::Surface::Cli, - 15327
"terminal" => vak_core::Surface::Terminal, - 15328
"desktop" => vak_core::Surface::Desktop, - 15329
"server" => vak_core::Surface::Server, - 15330
"web" => vak_core::Surface::Web, - 15331
"background" => vak_core::Surface::Background, - 15332
// "subagent" is kept for admin-console requests built against the - 15333
// pre-rename surface name. - 15334
"worker" | "subagent" => vak_core::Surface::Worker, - 15335
_ => vak_core::Surface::Chat { - 15336
channel: raw_surface.to_string(), - 15337
}, - 15338
}; - 15339
if let Some(role) = body.role.as_deref() - 15340
&& !core.prompt_role_names().iter().any(|n| n == role) - 15341
{ - 15342
return ( - 15343
StatusCode::BAD_REQUEST, - 15344
Json(serde_json::json!({ "error": format!("no role '{role}'") })), - 15345
) - 15346
.into_response(); - 15347
} - 15348
let core = core.with_surface(surface).with_prompt_role(body.role); - 15349
Json(prompt_effective_payload(&core)).into_response() - 15350
} - 15351
- 15352
async fn list_prompt_roles( - 15353
State(state): State<AppState>, - 15354
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15355
) -> axum::response::Response { - 15356
use axum::response::IntoResponse; - 15357
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15358
Json(serde_json::json!({ "roles": core.prompt_role_names() })).into_response() - 15359
} - 15360
- 15361
async fn get_hooks( - 15362
State(state): State<AppState>, - 15363
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 15364
) -> axum::response::Response { - 15365
use axum::response::IntoResponse; - 15366
let core = scoped_core!(&state, None, q.agent.as_deref()); - 15367
let path = core.cwd().join(".vak/config.toml"); - 15368
let hooks = if path.is_file() { - 15369
match std::fs::read_to_string(&path) - 15370
.ok() - 15371
.and_then(|raw| toml::from_str::<vak_config::FileConfig>(&raw).ok()) - 15372
{ - 15373
Some(config) => config.hooks, - 15374
None => { - 15375
return (StatusCode::BAD_REQUEST, "workspace config is invalid").into_response(); - 15376
} - 15377
} - 15378
} else { - 15379
Vec::new() - 15380
}; - 15381
let hooks = hooks - 15382
.iter() - 15383
.map(|h| { - 15384
serde_json::json!({ - 15385
"event": h.event, - 15386
"matcher": h.matcher, - 15387
"command": h.command, - 15388
"timeout_ms": h.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS), - 15389
"enabled": h.enabled, - 15390
"failure_mode": h.failure_mode.as_deref().unwrap_or("open"), - 15391
}) - 15392
}) - 15393
.collect::<Vec<_>>(); - 15394
Json(serde_json::json!({ "hooks": hooks })).into_response() - 15395
} - 15396
- 15397
async fn get_global_hooks() -> axum::response::Response { - 15398
use axum::response::IntoResponse; - 15399
let Some(path) = vak_config::global_path() else { - 15400
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 15401
}; - 15402
let hooks = if path.is_file() { - 15403
match std::fs::read_to_string(&path) - 15404
.ok() - 15405
.and_then(|raw| toml::from_str::<vak_config::FileConfig>(&raw).ok()) - 15406
{ - 15407
Some(config) => config.hooks, - 15408
None => return (StatusCode::BAD_REQUEST, "user config is invalid").into_response(), - 15409
} - 15410
} else { - 15411
Vec::new() - 15412
}; - 15413
Json(serde_json::json!({ "scope": "global", "hooks": hooks.into_iter().map(|h| serde_json::json!({ "event": h.event, "matcher": h.matcher, "command": h.command, "timeout_ms": h.timeout_ms.unwrap_or(vak_hooks::DEFAULT_TIMEOUT_MS), "enabled": h.enabled, "failure_mode": h.failure_mode.as_deref().unwrap_or("open") })).collect::<Vec<_>>() })).into_response() - 15414
} - 15415
- 15416
async fn put_global_hooks( - 15417
State(state): State<AppState>, - 15418
Json(body): Json<HooksPutBody>, - 15419
) -> axum::response::Response { - 15420
use axum::response::IntoResponse; - 15421
let Some(path) = vak_config::global_path() else { - 15422
return (StatusCode::INTERNAL_SERVER_ERROR, "user home unavailable").into_response(); - 15423
}; - 15424
let hooks = match validated_hook_configs(&body.hooks) { - 15425
Ok(hooks) => hooks, - 15426
Err(message) => { - 15427
return ( - 15428
StatusCode::BAD_REQUEST, - 15429
Json(serde_json::json!({ "error": message })), - 15430
) - 15431
.into_response(); - 15432
} - 15433
}; - 15434
if let Err(error) = vak_config::persist_hooks(&path, &hooks) { - 15435
return ( - 15436
StatusCode::INTERNAL_SERVER_ERROR, - 15437
Json(serde_json::json!({ "error": error.to_string() })), - 15438
) - 15439
.into_response(); - 15440
} - 15441
if state.core.refresh_persisted_preferences().is_err() { - 15442
return ( - 15443
StatusCode::INTERNAL_SERVER_ERROR, - 15444
"could not apply user hooks", - 15445
) - 15446
.into_response(); - 15447
} - 15448
Json(serde_json::json!({ "saved": true, "scope": "global", "count": hooks.len() })) - 15449
.into_response() - 15450
} - 15451
- 15452
fn validated_hook_configs(hooks: &[HookInput]) -> Result<Vec<vak_config::HookConfig>, String> { - 15453
hooks - 15454
.iter() - 15455
.map(|hook| { - 15456
if !matches!( - 15457
hook.event.as_str(), - 15458
"session_start" - 15459
| "session-start" - 15460
| "pre_tool_use" - 15461
| "pre-tool-use" - 15462
| "post_tool_use" - 15463
| "post-tool-use"
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.