- 13799
struct CreateBotBody { - 13800
id: String, - 13801
surface: String, - 13802
label: String, - 13803
#[serde(default)] - 13804
agent_id: Option<String>, - 13805
} - 13806
- 13807
async fn create_bot( - 13808
State(state): State<AppState>, - 13809
Json(body): Json<CreateBotBody>, - 13810
) -> axum::response::Response { - 13811
let id = body.id.trim(); - 13812
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - 13813
return ( - 13814
StatusCode::BAD_REQUEST, - 13815
Json(serde_json::json!({ "error": "bot id must be non-empty alphanumeric/hyphen" })), - 13816
) - 13817
.into_response(); - 13818
} - 13819
if !vak_core::Core::is_surface(&body.surface) { - 13820
return ( - 13821
StatusCode::BAD_REQUEST, - 13822
Json( - 13823
serde_json::json!({ "error": format!("unknown chat surface '{}'", body.surface) }), - 13824
), - 13825
) - 13826
.into_response(); - 13827
} - 13828
if state.gateway.bot_get(id).is_some() { - 13829
return ( - 13830
StatusCode::CONFLICT, - 13831
Json(serde_json::json!({ "error": format!("bot '{id}' already exists") })), - 13832
) - 13833
.into_response(); - 13834
} - 13835
let bot = crate::gateway::Bot { - 13836
id: id.to_string(), - 13837
surface: body.surface.clone(), - 13838
label: if body.label.trim().is_empty() { - 13839
id.to_string() - 13840
} else { - 13841
body.label.trim().to_string() - 13842
}, - 13843
token_env: bot_env_var(id), - 13844
agent_id: body.agent_id.and_then(|id| { - 13845
let trimmed = id.trim(); - 13846
if trimmed.is_empty() || trimmed == "vak" { - 13847
None - 13848
} else { - 13849
Some(trimmed.to_string()) - 13850
} - 13851
}), - 13852
..Default::default() - 13853
}; - 13854
state.gateway.bot_upsert(&state.core, bot.clone()); - 13855
state.hub.emit_config_changed("bot_created", id); - 13856
// Creating a bot writes `bots.json`. It does not register an OS - 13857
// service: activation is a separate, explicit act (see - 13858
// `service_control`), so a routine edit never mutates the machine's - 13859
// service manager behind the operator's back. - 13860
Json(serde_json::json!({ "bot": bot, "activation_required": true })).into_response() - 13861
} - 13862
- 13863
#[derive(serde::Deserialize, Default)] - 13864
struct UpdateBotBody { - 13865
#[serde(default)] - 13866
label: Option<String>, - 13867
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13868
agent_id: Option<Option<String>>, - 13869
#[serde(default)] - 13870
policy: Option<vak_config::ChannelPolicy>, - 13871
/// Absent (field simply not sent) leaves the current mode alone; - 13872
/// explicit `null` clears it back to "follows the project"; a string - 13873
/// sets it. See `gateway::deserialize_present` for why the plain - 13874
/// `Option<Option<T>>` shape needs the custom deserializer to make - 13875
/// that distinction actually work. - 13876
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13877
permission_mode: Option<Option<String>>, - 13878
/// See `permission_mode` above. - 13879
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13880
route: Option<Option<crate::gateway::AllowlistRoute>>, - 13881
/// See `permission_mode` above. - 13882
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13883
workspace: Option<Option<String>>, - 13884
/// See `permission_mode` above: absent leaves the bot's voice alone, - 13885
/// explicit `null` clears it back to inherit, a `VoiceConfig` object - 13886
/// sets it. - 13887
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 13888
voice: Option<Option<vak_config::VoiceConfig>>, - 13889
/// This bot's prompt tier (docs/design/45-prompt-layers.md). Absent - 13890
/// leaves it alone; an object replaces it. Its `identity` block is also - 13891
/// what drives this bot's spoken persona, so the two cannot drift. - 13892
#[serde(default)] - 13893
prompt: Option<vak_core::prompts::LayerContent>, - 13894
} - 13895
- 13896
async fn update_bot( - 13897
State(state): State<AppState>, - 13898
axum::extract::Path(id): axum::extract::Path<String>, - 13899
Json(body): Json<UpdateBotBody>, - 13900
) -> axum::response::Response { - 13901
let Some(mut bot) = state.gateway.bot_get(&id) else { - 13902
return ( - 13903
StatusCode::NOT_FOUND, - 13904
Json(serde_json::json!({ "error": format!("no bot '{id}'") })), - 13905
) - 13906
.into_response(); - 13907
}; - 13908
if let Some(label) = body.label { - 13909
bot.label = label; - 13910
} - 13911
if let Some(agent_id) = body.agent_id { - 13912
bot.agent_id = match agent_id { - 13913
None => None, - 13914
Some(raw) if raw.trim().is_empty() || raw.trim() == "vak" => None, - 13915
Some(raw) => Some(raw.trim().to_string()), - 13916
}; - 13917
} - 13918
if let Some(policy) = body.policy { - 13919
bot.policy = policy; - 13920
} - 13921
if let Some(raw) = body.permission_mode { - 13922
match raw { - 13923
None => bot.permission_mode = None, - 13924
Some(raw) if raw.trim().is_empty() => bot.permission_mode = None, - 13925
Some(raw) => match crate::parse_mode(raw.trim()) { - 13926
Some(mode) => bot.permission_mode = Some(mode), - 13927
None => { - 13928
return ( - 13929
StatusCode::BAD_REQUEST, - 13930
Json( - 13931
serde_json::json!({ "error": format!("unknown permission_mode '{raw}'") }), - 13932
), - 13933
) - 13934
.into_response(); - 13935
} - 13936
}, - 13937
} - 13938
} - 13939
if let Some(route) = body.route { - 13940
bot.route = route; - 13941
} - 13942
if let Some(ws) = body.workspace { - 13943
bot.workspace = match ws { - 13944
None => None, - 13945
Some(ws) if ws.trim().is_empty() => None, - 13946
Some(ws) => Some(PathBuf::from(ws.trim())), - 13947
}; - 13948
} - 13949
if let Some(voice) = body.voice { - 13950
if let Some(Err(error)) = voice.as_ref().map(voice::check_tier) { - 13951
return ( - 13952
StatusCode::BAD_REQUEST, - 13953
Json(serde_json::json!({ "error": error })), - 13954
) - 13955
.into_response(); - 13956
} - 13957
bot.voice = voice; - 13958
} - 13959
if let Some(prompt) = body.prompt { - 13960
bot.prompt = prompt; - 13961
} - 13962
state.gateway.bot_upsert(&state.core, bot.clone()); - 13963
state.hub.emit_config_changed("bot_updated", &id); - 13964
Json(serde_json::json!({ "bot": bot })).into_response() - 13965
} - 13966
- 13967
async fn delete_bot( - 13968
State(state): State<AppState>, - 13969
axum::extract::Path(id): axum::extract::Path<String>, - 13970
) -> StatusCode { - 13971
if state.gateway.bot_remove(&state.core, &id) { - 13972
// No service-manager call here. The bridge watches its own - 13973
// credential (`surfaces::CredentialWatch`) and stops when it goes - 13974
// away, so removal takes effect without a handler orchestrating - 13975
// anything. Its unit is cleaned up at the next activation. - 13976
state.hub.emit_config_changed("bot_deleted", &id); - 13977
StatusCode::OK - 13978
} else { - 13979
StatusCode::NOT_FOUND - 13980
} - 13981
} - 13982
- 13983
async fn put_bot_id_token( - 13984
State(state): State<AppState>, - 13985
axum::extract::Path(id): axum::extract::Path<String>, - 13986
Json(body): Json<TelegramTokenBody>, - 13987
) -> axum::response::Response { - 13988
let Some(bot) = state.gateway.bot_get(&id) else { - 13989
return ( - 13990
StatusCode::NOT_FOUND, - 13991
Json(serde_json::json!({ "error": format!("no bot '{id}'") })), - 13992
) - 13993
.into_response(); - 13994
}; - 13995
match state.core.set_bot_token(&bot.token_env, &body.token) { - 13996
Ok(env_var) => { - 13997
vak_core::security_events::record( - 13998
&state.core.sessions_home(), - 13999
vak_core::security_events::EventKind::ProviderKeyChange, - 14000
"bot_token_set", - 14001
&id, - 14002
None, - 14003
); - 14004
state.hub.emit_config_changed("bot_token_set", &id); - 14005
// Storing a credential is configuration, not activation: it - 14006
// registers no unit and restarts nothing. The operator - 14007
// activates when they mean to, and `activation_required` is - 14008
// how a surface knows to say so. - 14009
Json(serde_json::json!({ - 14010
"id": id, - 14011
"env_var": env_var, - 14012
"configured": true, - 14013
"activation_required": true, - 14014
})) - 14015
.into_response() - 14016
} - 14017
Err(e) => ( - 14018
StatusCode::BAD_REQUEST, - 14019
Json(serde_json::json!({ "error": e.to_string() })), - 14020
) - 14021
.into_response(), - 14022
} - 14023
} - 14024
- 14025
async fn delete_bot_id_token( - 14026
State(state): State<AppState>, - 14027
axum::extract::Path(id): axum::extract::Path<String>, - 14028
) -> axum::response::Response { - 14029
let Some(bot) = state.gateway.bot_get(&id) else { - 14030
return ( - 14031
StatusCode::NOT_FOUND, - 14032
Json(serde_json::json!({ "error": format!("no bot '{id}'") })), - 14033
) - 14034
.into_response(); - 14035
}; - 14036
match state.core.remove_bot_token(&bot.token_env) { - 14037
Ok(removed) => { - 14038
vak_core::security_events::record( - 14039
&state.core.sessions_home(), - 14040
vak_core::security_events::EventKind::ProviderKeyChange, - 14041
"bot_token_removed", - 14042
&format!("id={id} shadowed={}", removed.shadowed_by_env), - 14043
None, - 14044
); - 14045
state.hub.emit_config_changed("bot_token_removed", &id); - 14046
// Nothing to orchestrate: the bridge re-reads its credential - 14047
// each poll cycle and stops using a cleared one on its own - 14048
// (`surfaces::CredentialWatch`). - 14049
Json(serde_json::json!({ - 14050
"id": id, - 14051
"env_var": removed.env_var, - 14052
"configured": removed.shadowed_by_env, - 14053
"shadowed_by_env": removed.shadowed_by_env, - 14054
})) - 14055
.into_response() - 14056
} - 14057
Err(e) => ( - 14058
StatusCode::BAD_REQUEST, - 14059
Json(serde_json::json!({ "error": e.to_string() })), - 14060
) - 14061
.into_response(), - 14062
} - 14063
} - 14064
- 14065
async fn get_config( - 14066
State(state): State<AppState>, - 14067
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 14068
) -> axum::response::Response { - 14069
use axum::response::IntoResponse; - 14070
let core = scoped_core!(&state, None, q.agent.as_deref()); - 14071
refresh_control_plane(&state); - 14072
let cfg = core.config(); - 14073
let work = core.effective_work(); - 14074
let route = core.effective_route(); - 14075
let permission_rules = core.effective_permission_rules(); - 14076
let project_path = vak_config::project_path(core.cwd()); - 14077
Json(serde_json::json!({ - 14078
"provider": route.provider, - 14079
"model": route.model, - 14080
"provider_source": route.provider_source, - 14081
"model_source": route.model_source, - 14082
"route_revision": route.revision, - 14083
"max_tokens": cfg.max_tokens, - 14084
"max_turns": core.effective_max_turns(), - 14085
"intent_evidence_max_age_secs": cfg.intent.evidence_max_age_secs, - 14086
"permission_mode": format!("{:?}", core.effective_permission_mode()), - 14087
// How an `Ask` gets resolved, and what the rules say — both were - 14088
// absent here, which is why the desktop app could set the permission - 14089
// mode but had no way to show or change the approval behaviour, and - 14090
// no way to show a rule at all. - 14091
"approval_mode": core.effective_approval_mode().as_str(), - 14092
"sandbox": core.effective_sandbox_name(), - 14093
"permissions": { - 14094
"allow": permission_rules.0, - 14095
"ask": permission_rules.1, - 14096
"deny": permission_rules.2, - 14097
}, - 14098
"workers": core.effective_workers(), - 14099
"max_retries": cfg.max_retries, - 14100
"retry_base_backoff_ms": cfg.retry_base_backoff_ms, - 14101
"request_timeout_secs": cfg.request_timeout_secs, - 14102
"run_retry_attempts": cfg.run_retry_attempts, - 14103
"run_retry_base_backoff_ms": cfg.run_retry_base_backoff_ms, - 14104
"circuit_breaker_threshold": cfg.circuit_breaker_threshold, - 14105
"circuit_breaker_cooldown_secs": cfg.circuit_breaker_cooldown_secs, - 14106
"context_window": cfg.context_window, - 14107
"theme": core.effective_theme(), - 14108
"voice": core.effective_voice(), - 14109
"memory": { - 14110
"search_enabled": core.effective_memory_search_enabled(), - 14111
"write_enabled": core.effective_memory_write_enabled(), - 14112
"reflection": core.effective_memory_reflection(), - 14113
"skill_proposals": core.effective_memory_skill_proposals(), - 14114
}, - 14115
"bell": cfg.ui.bell, - 14116
"stop_policy": { - 14117
"enabled": cfg.stop_policy.enabled, - 14118
"marker_gate": cfg.stop_policy.marker_gate, - 14119
"verify_gate": cfg.stop_policy.verify_gate, - 14120
"max_blocks": cfg.stop_policy.max_blocks, - 14121
}, - 14122
"work": { - 14123
"enabled": work.enabled, - 14124
"default_mode": work.default_mode, - 14125
"max_items": work.max_items, - 14126
"max_revisions": work.max_revisions, - 14127
"max_parallel": work.max_parallel, - 14128
"confirmation": work.confirmation, - 14129
}, - 14130
"route": { - 14131
"objective": cfg.route.objective, - 14132
"fallback_models": cfg.route.fallback_models, - 14133
"max_fallbacks": cfg.route.max_fallbacks, - 14134
"quality_hints": cfg.route.quality_hints, - 14135
}, - 14136
"integrations": { - 14137
"mcp_servers": cfg.mcp.servers.keys().collect::<Vec<_>>(), - 14138
"hooks": cfg.hooks.len(), - 14139
"skills": core.skills().iter().map(|skill| skill.name.clone()).collect::<Vec<_>>(), - 14140
}, - 14141
"capability_inheritance": { - 14142
"mcp": core.effective_capability_inheritance().inherit_mcp, - 14143
"hooks": core.effective_capability_inheritance().inherit_hooks, - 14144
"skills": core.effective_capability_inheritance().inherit_skills, - 14145
"commands": core.effective_capability_inheritance().inherit_commands, - 14146
"plugins": core.effective_capability_inheritance().inherit_plugins, - 14147
}, - 14148
// A surface is a transport, not a credential slot (invariant 23): - 14149
// credentials belong to bots, and `GET /gateway/bots` reports them. - 14150
"surfaces": vak_core::Core::SURFACES, - 14151
"paths": { - 14152
"project_config": project_path, - 14153
"global_config": vak_config::global_path(), - 14154
"sessions_home": core.sessions_home(), - 14155
"cwd": core.cwd(), - 14156
}, - 14157
"warnings": cfg.warnings, - 14158
})) - 14159
.into_response() - 14160
} - 14161
- 14162
fn read_config_layer(path: &std::path::Path) -> Result<vak_config::FileConfig, String> { - 14163
if !path.is_file() { - 14164
return Ok(vak_config::FileConfig::default()); - 14165
} - 14166
let raw = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - 14167
toml::from_str(&raw).map_err(|error| error.to_string()) - 14168
} - 14169
- 14170
fn config_layer_response( - 14171
core: &vak_core::Core, - 14172
scope: ConfigScope, - 14173
) -> Result<serde_json::Value, String> { - 14174
let path = scope.config_path(core)?; - 14175
let layer = read_config_layer(&path)?; - 14176
Ok(serde_json::json!({ - 14177
"scope": scope.label(), - 14178
"path": path, - 14179
"provider": layer.provider, - 14180
"model": layer.model, - 14181
"max_tokens": layer.max_tokens, - 14182
"max_turns": layer.max_turns, - 14183
"permission_mode": layer.permission_mode.map(|mode| format!("{mode:?}")), - 14184
"approval_mode": layer.approval_mode.map(|mode| mode.as_str()), - 14185
"profile": layer.profile, - 14186
"workers": layer.workers, - 14187
"theme": layer.ui.theme, - 14188
"permissions": { - 14189
"allow": layer.allow, - 14190
"ask": layer.ask, - 14191
"deny": layer.deny, - 14192
}, - 14193
"memory": { - 14194
"search_enabled": layer.memory.search_enabled, - 14195
"write_enabled": layer.memory.write_enabled, - 14196
"reflection": layer.memory.reflection, - 14197
"skill_proposals": layer.memory.skill_proposals, - 14198
}, - 14199
"work": { - 14200
"enabled": layer.work.enabled, - 14201
"default_mode": layer.work.default_mode, - 14202
"max_items": layer.work.max_items, - 14203
"max_revisions": layer.work.max_revisions, - 14204
"max_parallel": layer.work.max_parallel, - 14205
"confirmation": layer.work.confirmation, - 14206
}, - 14207
"counts": { - 14208
"mcp": layer.mcp.servers.len(), - 14209
"hooks": layer.hooks.len(), - 14210
}, - 14211
"capabilities": { - 14212
"inherit_mcp": layer.capabilities.inherit_mcp, - 14213
"inherit_hooks": layer.capabilities.inherit_hooks, - 14214
"inherit_skills": layer.capabilities.inherit_skills, - 14215
"inherit_commands": layer.capabilities.inherit_commands, - 14216
"inherit_plugins": layer.capabilities.inherit_plugins, - 14217
}, - 14218
})) - 14219
} - 14220
- 14221
async fn get_global_config_layer( - 14222
State(state): State<AppState>, - 14223
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 14224
) -> axum::response::Response { - 14225
use axum::response::IntoResponse; - 14226
let core = scoped_core!(&state, None, q.agent.as_deref()); - 14227
match config_layer_response(&core, ConfigScope::User) { - 14228
Ok(layer) => Json(layer).into_response(), - 14229
Err(error) => ( - 14230
StatusCode::INTERNAL_SERVER_ERROR, - 14231
Json(serde_json::json!({ "error": error })), - 14232
) - 14233
.into_response(), - 14234
} - 14235
} - 14236
- 14237
async fn get_workspace_config_layer( - 14238
State(state): State<AppState>, - 14239
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 14240
) -> axum::response::Response { - 14241
use axum::response::IntoResponse; - 14242
let core = scoped_core!(&state, None, q.agent.as_deref()); - 14243
match config_layer_response(&core, ConfigScope::Workspace) { - 14244
Ok(layer) => Json(layer).into_response(), - 14245
Err(error) => ( - 14246
StatusCode::INTERNAL_SERVER_ERROR, - 14247
Json(serde_json::json!({ "error": error })), - 14248
) - 14249
.into_response(), - 14250
} - 14251
} - 14252
- 14253
#[derive(Debug, serde::Deserialize)] - 14254
struct AgentNetworkCapabilityBody { - 14255
workspace: String, - 14256
enabled: bool, - 14257
#[serde(default)] - 14258
allowed_peers: Vec<String>, - 14259
max_message_bytes: Option<usize>, - 14260
} - 14261
- 14262
async fn agent_network_capability( - 14263
State(state): State<AppState>, - 14264
Json(body): Json<AgentNetworkCapabilityBody>, - 14265
) -> impl IntoResponse { - 14266
let workspace = match std::path::Path::new(&body.workspace).canonicalize() { - 14267
Ok(path) if path.is_dir() => path.display().to_string(), - 14268
_ => { - 14269
return ( - 14270
StatusCode::BAD_REQUEST, - 14271
Json(serde_json::json!({"error": "workspace must be an existing directory"})), - 14272
); - 14273
} - 14274
}; - 14275
let peers = match body - 14276
.allowed_peers - 14277
.into_iter() - 14278
.map(|peer| { - 14279
std::path::Path::new(&peer) - 14280
.canonicalize() - 14281
.ok() - 14282
.filter(|path| path.is_dir()) - 14283
.map(|path| path.display().to_string()) - 14284
}) - 14285
.collect::<Option<Vec<_>>>() - 14286
{ - 14287
Some(peers) => peers.into_iter().collect(), - 14288
None => { - 14289
return ( - 14290
StatusCode::BAD_REQUEST, - 14291
Json( - 14292
serde_json::json!({"error": "every allowed peer must be an existing directory"}), - 14293
), - 14294
); - 14295
} - 14296
}; - 14297
let policy = vak_core::agent_network::WorkspaceNetworkPolicy { - 14298
enabled: body.enabled, - 14299
allowed_peers: peers, - 14300
max_message_bytes: body - 14301
.max_message_bytes - 14302
.unwrap_or(256 * 1024) - 14303
.clamp(1, 256 * 1024), - 14304
}; - 14305
let broker = state.core.agent_network_broker(); - 14306
let capability = broker.register(workspace.clone(), policy); - 14307
if let Err(error) = broker.save_policies( - 14308
&state - 14309
.core - 14310
.sessions_home() - 14311
.join("agent-network/policies.json"), - 14312
) { - 14313
let _ = broker.revoke(&capability); - 14314
return ( - 14315
StatusCode::INTERNAL_SERVER_ERROR, - 14316
Json(serde_json::json!({"error": error})), - 14317
); - 14318
} - 14319
( - 14320
StatusCode::OK, - 14321
Json(serde_json::json!({ - 14322
"workspace": workspace, - 14323
"capability": capability.token(), - 14324
"expires_in_seconds": 900 - 14325
})), - 14326
) - 14327
} - 14328
- 14329
#[derive(Debug, serde::Deserialize)] - 14330
struct AgentNetworkSendBody { - 14331
sender_workspace: String, - 14332
capability: String, - 14333
destination_workspace: String, - 14334
body: String, - 14335
} - 14336
- 14337
async fn agent_network_send( - 14338
State(state): State<AppState>, - 14339
Json(body): Json<AgentNetworkSendBody>, - 14340
) -> impl IntoResponse { - 14341
use base64::Engine as _; - 14342
let Ok(payload) = base64::engine::general_purpose::STANDARD.decode(body.body.trim()) else { - 14343
return ( - 14344
StatusCode::BAD_REQUEST, - 14345
Json(serde_json::json!({"error": "body must be standard base64"})), - 14346
); - 14347
}; - 14348
let sender = vak_core::agent_network::BrokerCapability::from_parts( - 14349
body.sender_workspace, - 14350
body.capability, - 14351
); - 14352
match state - 14353
.core - 14354
.agent_network_broker() - 14355
.send_to(&sender, &body.destination_workspace, payload) - 14356
{ - 14357
Ok(()) => ( - 14358
StatusCode::ACCEPTED, - 14359
Json(serde_json::json!({"accepted": true})), - 14360
), - 14361
Err(error) => ( - 14362
StatusCode::FORBIDDEN, - 14363
Json(serde_json::json!({"error": error})), - 14364
), - 14365
} - 14366
} - 14367
- 14368
#[derive(Debug, serde::Deserialize)] - 14369
struct AgentNetworkReceiveQuery { - 14370
workspace: String, - 14371
capability: String, - 14372
} - 14373
- 14374
async fn agent_network_receive( - 14375
State(state): State<AppState>, - 14376
axum::extract::Query(query): axum::extract::Query<AgentNetworkReceiveQuery>, - 14377
) -> impl IntoResponse { - 14378
use base64::Engine as _; - 14379
let receiver = - 14380
vak_core::agent_network::BrokerCapability::from_parts(query.workspace, query.capability); - 14381
match state.core.agent_network_broker().receive(&receiver) { - 14382
Ok(Some(message)) => ( - 14383
StatusCode::OK, - 14384
Json(serde_json::json!({ - 14385
"from_workspace": message.from_workspace, - 14386
"to_workspace": message.to_workspace, - 14387
"body": base64::engine::general_purpose::STANDARD.encode(message.body) - 14388
})), - 14389
), - 14390
Ok(None) => ( - 14391
StatusCode::NO_CONTENT, - 14392
Json(serde_json::json!({"message": null})), - 14393
), - 14394
Err(error) => ( - 14395
StatusCode::FORBIDDEN, - 14396
Json(serde_json::json!({"error": error})), - 14397
), - 14398
} - 14399
} - 14400
- 14401
#[derive(serde::Deserialize, Default)] - 14402
struct ConfigPatch { - 14403
provider: Option<String>, - 14404
model: Option<String>, - 14405
max_turns: Option<usize>, - 14406
permission_mode: Option<String>, - 14407
approval_mode: Option<String>, - 14408
theme: Option<String>, - 14409
#[serde(default)] - 14410
voice_enabled: Option<bool>, - 14411
#[serde(default)] - 14412
voice_max_session_secs: Option<u64>, - 14413
#[serde(default)] - 14414
voice_max_concurrent: Option<usize>, - 14415
#[serde(default)] - 14416
voice_max_audio_bytes: Option<u64>, - 14417
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 14418
voice_provider: Option<Option<String>>, - 14419
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 14420
voice_transcription_model: Option<Option<String>>, - 14421
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 14422
voice_synthesis_model: Option<Option<String>>, - 14423
/// Whether worker delegation (the `task` tool) is available. Absent - 14424
/// means "leave alone", same convention every field here uses. The - 14425
/// `subagents` alias keeps a client sending the pre-rename field name - 14426
/// (a saved script, an unrefreshed admin tab) from silently no-opping. - 14427
#[serde(default, alias = "subagents")] - 14428
workers: Option<bool>, - 14429
/// `[memory]` toggles (docs/design/23-memory.md). Absent means "leave - 14430
/// alone" — same convention every other field here already uses. - 14431
#[serde(default)] - 14432
memory_search_enabled: Option<bool>, - 14433
#[serde(default)] - 14434
memory_write_enabled: Option<bool>, - 14435
#[serde(default)] - 14436
memory_reflection: Option<bool>, - 14437
#[serde(default)] - 14438
memory_skill_proposals: Option<bool>, - 14439
#[serde(default)] - 14440
work_enabled: Option<bool>, - 14441
#[serde(default)] - 14442
work_default_mode: Option<String>, - 14443
#[serde(default)] - 14444
work_max_items: Option<usize>, - 14445
#[serde(default)] - 14446
work_max_revisions: Option<u32>, - 14447
#[serde(default)] - 14448
work_max_parallel: Option<usize>, - 14449
#[serde(default)] - 14450
work_confirmation: Option<String>, - 14451
#[serde(default)] - 14452
inherit_mcp: Option<bool>, - 14453
#[serde(default)] - 14454
inherit_hooks: Option<bool>, - 14455
#[serde(default)] - 14456
inherit_skills: Option<bool>, - 14457
#[serde(default)] - 14458
inherit_commands: Option<bool>, - 14459
#[serde(default)] - 14460
inherit_plugins: Option<bool>, - 14461
/// `[plugins] network_allow` grants for the selected layer (docs/design/ - 14462
/// 39-plugin-ecosystem.md). `Some(names)` grants egress to those plugins; - 14463
/// `Some(vec![])` clears the key (deny-by-default); absent leaves alone. - 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,
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.