- 1001
} - 1002
let Ok(handle) = std::fs::File::open(&path) else { - 1003
continue; - 1004
}; - 1005
let mut first = String::new(); - 1006
if std::io::BufReader::new(handle) - 1007
.read_line(&mut first) - 1008
.is_err() - 1009
{ - 1010
continue; - 1011
} - 1012
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&first) - 1013
&& let Some(cwd) = v["cwd"].as_str() - 1014
{ - 1015
push(cwd.to_string()); - 1016
break; - 1017
} - 1018
} - 1019
} - 1020
}; - 1021
scan_sessions_root(state.core.sessions_home().join("sessions")); - 1022
if let Ok(agents) = std::fs::read_dir(shared.join("agents")) { - 1023
for agent in agents.flatten() { - 1024
scan_sessions_root(agent.path().join("sessions")); - 1025
} - 1026
} - 1027
seen - 1028
} - 1029
- 1030
pub(crate) fn workspace_catalog(state: &AppState) -> Vec<serde_json::Value> { - 1031
let names = read_workspace_names(state); - 1032
known_workspaces(state) - 1033
.into_iter() - 1034
.map(|path| { - 1035
let fallback = std::path::Path::new(&path) - 1036
.file_name() - 1037
.and_then(|value| value.to_str()) - 1038
.unwrap_or(&path); - 1039
serde_json::json!({ - 1040
"path": path, - 1041
"name": names.get(&path).cloned().unwrap_or_else(|| fallback.to_string()), - 1042
}) - 1043
}) - 1044
.collect() - 1045
} - 1046
- 1047
/// True for a path that's almost certainly test/build scratch rather than - 1048
/// a real project — OS temp dirs and the `tempfile` crate's `.tmpXXXXXX` - 1049
/// directory naming convention (used throughout this workspace's own test - 1050
/// suite, which is exactly what was polluting `known_workspaces` with - 1051
/// dozens of one-shot `cargo test` tempdirs on a dev machine). A path - 1052
/// under a real project that happens to be named `tmp` is not excluded by - 1053
/// this — only OS scratch roots and the tempfile-style random suffix are. - 1054
fn is_scratch_workspace(path: &str) -> bool { - 1055
if path == "/tmp" - 1056
|| path.starts_with("/tmp/") - 1057
|| path == "/private/tmp" - 1058
|| path.starts_with("/private/tmp/") - 1059
|| path.starts_with("/var/folders/") - 1060
|| path.starts_with("/private/var/folders/") - 1061
{ - 1062
return true; - 1063
} - 1064
// A user-created Agent's isolated workspace is nested under its base - 1065
// workspace at `.vak/agents/<id>/workspace` (see - 1066
// `vak_config::paths::agent_workspace`) — an implementation detail, not - 1067
// a project a person would recognize or want to switch into. - 1068
let components: Vec<_> = std::path::Path::new(path) - 1069
.components() - 1070
.filter_map(|c| c.as_os_str().to_str()) - 1071
.collect(); - 1072
if components.windows(2).any(|pair| pair == [".vak", "agents"]) { - 1073
return true; - 1074
} - 1075
// Windows temp dirs: %TEMP%, %TMP%, C:\Windows\Temp, C:\Temp. - 1076
// `std::env::temp_dir()` returns the OS canonical temp on every platform, - 1077
// so checking it catches redirected/user-specific temp roots that a - 1078
// literal path match would miss (e.g. on a managed Windows account). - 1079
#[cfg(windows)] - 1080
{ - 1081
let temp = std::env::temp_dir(); - 1082
if let Ok(temp_str) = temp.into_os_string().into_string() { - 1083
if path == temp_str || path.starts_with(&format!("{}\\", temp_str)) { - 1084
return true; - 1085
} - 1086
} - 1087
if path.eq_ignore_ascii_case(r"C:\Windows\Temp") - 1088
|| path.starts_with(r"C:\Windows\Temp\") - 1089
|| path.eq_ignore_ascii_case(r"C:\Temp") - 1090
|| path.starts_with(r"C:\Temp\") - 1091
{ - 1092
return true; - 1093
} - 1094
} - 1095
std::path::Path::new(path) - 1096
.file_name() - 1097
.and_then(|n| n.to_str()) - 1098
.is_some_and(|name| name.starts_with(".tmp")) - 1099
} - 1100
- 1101
#[derive(serde::Deserialize)] - 1102
pub(crate) struct GatewayRoutePatch { - 1103
provider: Option<String>, - 1104
model: Option<String>, - 1105
} - 1106
- 1107
pub(crate) async fn patch_gateway_binding( - 1108
State(state): State<AppState>, - 1109
Path(key): Path<String>, - 1110
Json(body): Json<GatewayRoutePatch>, - 1111
) -> StatusCode { - 1112
if key.trim().is_empty() || !key.contains(':') { - 1113
return StatusCode::BAD_REQUEST; - 1114
} - 1115
let route = match (body.provider, body.model) { - 1116
(None, None) => None, - 1117
(Some(provider), Some(model)) - 1118
if !provider.trim().is_empty() && !model.trim().is_empty() => - 1119
{ - 1120
Some((provider.trim().to_string(), model.trim().to_string())) - 1121
} - 1122
_ => return StatusCode::BAD_REQUEST, - 1123
}; - 1124
if route.as_ref().is_some_and(|(provider, _)| { - 1125
!state - 1126
.core - 1127
.provider_names() - 1128
.iter() - 1129
.any(|name| name == provider) - 1130
}) { - 1131
return StatusCode::BAD_REQUEST; - 1132
} - 1133
// One source of truth for "what does this channel route to" - 1134
// (docs/design/34 "Editing an already-allowed entry"): when an - 1135
// `allowed` allowlist entry exists for this key, the route lives there - 1136
// and this pre-existing surface writes through to it instead of - 1137
// parking a second, divergent value on the binding. - 1138
let mirrored = state.gateway.allowlist_patch_route_if_allowed( - 1139
&state.core, - 1140
&key, - 1141
route - 1142
.clone() - 1143
.map(|(provider, model)| crate::gateway::AllowlistRoute { provider, model }), - 1144
); - 1145
state - 1146
.gateway - 1147
.set_route_override(&state.core, key.clone(), route); - 1148
if mirrored { - 1149
state - 1150
.hub - 1151
.emit_config_changed("gateway_allowlist_patched", &key); - 1152
} - 1153
state.hub.emit_config_changed("gateway_binding_route", &key); - 1154
StatusCode::OK - 1155
} - 1156
- 1157
pub(crate) async fn rotate_gateway_binding( - 1158
State(state): State<AppState>, - 1159
Path(key): Path<String>, - 1160
) -> StatusCode { - 1161
if state.gateway.rotate(&state.core, &key) { - 1162
state - 1163
.hub - 1164
.emit_config_changed("gateway_binding_rotated", &key); - 1165
StatusCode::OK - 1166
} else { - 1167
StatusCode::NOT_FOUND - 1168
} - 1169
} - 1170
- 1171
pub(crate) async fn delete_gateway_binding_admin( - 1172
State(state): State<AppState>, - 1173
Path(key): Path<String>, - 1174
) -> StatusCode { - 1175
if state.gateway.unbind(&state.core, &key) { - 1176
state - 1177
.hub - 1178
.emit_config_changed("gateway_binding_deleted", &key); - 1179
StatusCode::OK - 1180
} else { - 1181
StatusCode::NOT_FOUND - 1182
} - 1183
} - 1184
- 1185
// ---- Allowlist (docs/design/34-channel-onboarding.md) --------------------- - 1186
- 1187
/// Resolve a chat's permission exactly as `core_for_entry` does, including - 1188
/// the tiers the entry does not carry itself. - 1189
/// - 1190
/// Two things were missing and both made the console read WIDER than - 1191
/// dispatch. The bot pin was never consulted, so a bot narrowing its chats - 1192
/// was invisible. And the whole resolution was skipped whenever the entry - 1193
/// had no workspace of its own — the common case, since a chat inherits the - 1194
/// gateway's workspace unless an operator pins one — which serialized - 1195
/// `effective_permission_mode` as `null` and left the console's mode picker - 1196
/// falling back to a hardcoded guess with no ceiling to clamp against. - 1197
fn resolve_entry_permission( - 1198
state: &AppState, - 1199
e: &crate::gateway::AllowlistEntry, - 1200
) -> crate::gateway::ResolvedPermission { - 1201
let bot = e - 1202
.inherit_bot_policy - 1203
.then_some(e.bot_id.as_deref()) - 1204
.flatten() - 1205
.and_then(|id| state.gateway.bot_get(id)); - 1206
// Same precedence `core_for_entry` uses: the chat's own workspace, else - 1207
// its bot's, else the gateway's default. - 1208
let workspace = e - 1209
.workspace - 1210
.clone() - 1211
.or_else(|| bot.as_ref().and_then(|b| b.workspace.clone())) - 1212
.unwrap_or_else(|| state.core.cwd().clone()); - 1213
crate::gateway::resolve_channel_permission( - 1214
&workspace, - 1215
e.permission_mode, - 1216
bot.and_then(|b| b.permission_mode), - 1217
) - 1218
} - 1219
- 1220
fn allowlist_entry_json(state: &AppState, e: &crate::gateway::AllowlistEntry) -> serde_json::Value { - 1221
// Resolve the effective permission mode the same way "Effective route" - 1222
// is surfaced: the console must show what the channel actually gets, - 1223
// not just what was requested, so a capped override is visible rather - 1224
// than mistaken for a live grant. - 1225
let resolved = resolve_entry_permission(state, e); - 1226
let bot = if e.inherit_bot_policy { - 1227
e.bot_id.as_deref().and_then(|id| state.gateway.bot_get(id)) - 1228
} else { - 1229
None - 1230
}; - 1231
let effective_agent_id = if let Some(ref aid) = e.agent_id { - 1232
if aid != "vak" || !e.inherit_bot_policy { - 1233
aid.clone() - 1234
} else { - 1235
bot.as_ref() - 1236
.and_then(|b| b.agent_id.clone()) - 1237
.unwrap_or_else(|| "vak".into()) - 1238
} - 1239
} else { - 1240
bot.as_ref() - 1241
.and_then(|b| b.agent_id.clone()) - 1242
.unwrap_or_else(|| "vak".into()) - 1243
}; - 1244
serde_json::json!({ - 1245
"key": e.key, - 1246
"status": e.status, - 1247
"workspace": e.workspace, - 1248
"agent_id": e.agent_id, - 1249
"effective_agent_id": effective_agent_id, - 1250
"route": e.route, - 1251
"permission_mode": e.permission_mode, - 1252
"workspace_permission_mode": resolved.workspace_mode, - 1253
// The bot tier, named, so a reader can tell "the project caps this" - 1254
// from "the bot caps this" instead of only seeing the result. - 1255
"bot_permission_mode": resolved.bot_mode, - 1256
"effective_permission_mode": resolved.effective, - 1257
"permission_capped": resolved.was_capped(), - 1258
"policy": e.policy, - 1259
"bot_id": e.bot_id, - 1260
"inherit_bot_policy": e.inherit_bot_policy, - 1261
"voice": e.voice, - 1262
"added_at": e.added_at, - 1263
"added_by": e.added_by, - 1264
"first_seen_text": e.first_seen_text, - 1265
}) - 1266
} - 1267
- 1268
/// Parse an optional `permission_mode` field from an approve/PATCH body. - 1269
/// `Ok(None)` = inherit (field absent, null, or empty string); `Err(())` = - 1270
/// present but unparseable, which is a 400 rather than a silent inherit — - 1271
/// a typo'd mode must never quietly widen or narrow a channel's access. - 1272
fn parse_permission_mode_field( - 1273
raw: Option<&str>, - 1274
) -> Result<Option<vak_config::PermissionMode>, ()> { - 1275
match raw.map(str::trim) { - 1276
None | Some("") => Ok(None), - 1277
Some(s) => crate::parse_mode(s).map(Some).ok_or(()), - 1278
} - 1279
} - 1280
- 1281
pub(crate) async fn list_gateway_allowlist( - 1282
State(state): State<AppState>, - 1283
) -> Json<serde_json::Value> { - 1284
let entries: Vec<serde_json::Value> = state - 1285
.gateway - 1286
.allowlist_snapshot() - 1287
.iter() - 1288
.map(|entry| allowlist_entry_json(&state, entry)) - 1289
.collect(); - 1290
Json(serde_json::json!({ "entries": entries })) - 1291
} - 1292
- 1293
#[derive(serde::Deserialize, Default)] - 1294
pub(crate) struct AllowlistApproveBody { - 1295
#[serde(default)] - 1296
workspace: Option<String>, - 1297
/// Optional Agent slug. Omitting it binds the endpoint to built-in Vak. - 1298
#[serde(default)] - 1299
agent_id: Option<String>, - 1300
#[serde(default)] - 1301
route: Option<GatewayRoutePatch>, - 1302
/// Optional per-channel permission mode, riding along in the same - 1303
/// request as `route` rather than on an endpoint of its own. - 1304
#[serde(default)] - 1305
permission_mode: Option<String>, - 1306
#[serde(default)] - 1307
policy: Option<vak_config::ChannelPolicy>, - 1308
/// Bind this chat to a bot identity (multi-bot-per-channel). `null`/ - 1309
/// absent/`""` unbinds it — the chat then resolves purely against the - 1310
/// workspace, same as before bots existed. - 1311
#[serde(default)] - 1312
bot_id: Option<String>, - 1313
/// Whether to inherit the bound bot's policy/permission_mode/route as a - 1314
/// tier below this chat's own. Defaults to `true`; ignored when no - 1315
/// `bot_id` is set. - 1316
#[serde(default = "crate::gateway::default_true")] - 1317
inherit_bot_policy: bool, - 1318
} - 1319
- 1320
/// Audit an override that the workspace's own boundary will cap down, at - 1321
/// the moment the operator sets it — so the reduction is visible in the - 1322
/// security log immediately, not only when the channel's `Core` is first - 1323
/// started at dispatch (where `CorePool` records the enforcement itself). - 1324
fn record_permission_cap(state: &AppState, key: &str, entry: &crate::gateway::AllowlistEntry) { - 1325
let resolved = resolve_entry_permission(state, entry); - 1326
if !resolved.was_capped() { - 1327
return; - 1328
} - 1329
// Name the ceiling that actually did the capping. "Reduced to read-only" - 1330
// is not actionable on its own — an operator needs to know whether to - 1331
// widen the project's config or the bot's pin. - 1332
let ceiling = match resolved.bot_mode { - 1333
Some(bot) if bot.capped_by(resolved.workspace_mode) == resolved.effective => "bot", - 1334
_ => "workspace", - 1335
}; - 1336
vak_core::security_events::record( - 1337
&state.core.sessions_home(), - 1338
vak_core::security_events::EventKind::PermissionCapped, - 1339
"permission_capped", - 1340
&format!( - 1341
"key={key} workspace={} requested={} capped_to={} by={ceiling}", - 1342
entry - 1343
.workspace - 1344
.as_ref() - 1345
.unwrap_or(state.core.cwd()) - 1346
.display(), - 1347
resolved - 1348
.requested - 1349
.map(|m| m.as_str()) - 1350
.unwrap_or("(inherit)"), - 1351
resolved.effective.as_str() - 1352
), - 1353
None, - 1354
); - 1355
} - 1356
- 1357
/// Record the operator's trust decision for a workspace they just pointed a - 1358
/// channel at through the console. - 1359
/// - 1360
/// Pinning a workspace for a channel IS the decision `vak_core::trust` - 1361
/// records: the operator is saying "run turns here, with this project's own - 1362
/// configuration". Writing the marker keeps that decision in the one store - 1363
/// every surface reads, so the terminal and the server agree — instead of - 1364
/// the server assuming trust, which is what it used to do. - 1365
/// - 1366
/// Best-effort: an unwritable data home means the workspace loads - 1367
/// untrusted, which is the safe direction. It is never the reason an - 1368
/// approval fails. - 1369
fn note_workspace_trust(state: &AppState, workspace: Option<&std::path::Path>) { - 1370
let Some(workspace) = workspace else { return }; - 1371
if vak_core::trust::is_trusted(workspace) { - 1372
return; - 1373
} - 1374
match vak_core::trust::record(workspace) { - 1375
Ok(()) => { - 1376
vak_core::security_events::record( - 1377
&state.core.sessions_home(), - 1378
vak_core::security_events::EventKind::ConfigChange, - 1379
"workspace_trusted", - 1380
&format!("workspace={} by=admin", workspace.display()), - 1381
None, - 1382
); - 1383
} - 1384
Err(error) => eprintln!( - 1385
"[admin] could not record trust for {}: {error}", - 1386
workspace.display() - 1387
), - 1388
} - 1389
} - 1390
- 1391
pub(crate) async fn approve_gateway_allowlist( - 1392
State(state): State<AppState>, - 1393
Path(key): Path<String>, - 1394
body: axum::body::Bytes, - 1395
) -> Response { - 1396
if key.trim().is_empty() || !key.contains(':') { - 1397
return StatusCode::BAD_REQUEST.into_response(); - 1398
} - 1399
let body: AllowlistApproveBody = if body.is_empty() { - 1400
AllowlistApproveBody::default() - 1401
} else { - 1402
match serde_json::from_slice(&body) { - 1403
Ok(b) => b, - 1404
Err(_) => return StatusCode::BAD_REQUEST.into_response(), - 1405
} - 1406
}; - 1407
let workspace = match body.workspace { - 1408
Some(w) if !w.trim().is_empty() => PathBuf::from(w.trim()), - 1409
_ => state.core.cwd().clone(), - 1410
}; - 1411
let route = match body.route { - 1412
Some(GatewayRoutePatch { - 1413
provider: Some(provider), - 1414
model: Some(model), - 1415
}) if !provider.trim().is_empty() && !model.trim().is_empty() => { - 1416
Some(crate::gateway::AllowlistRoute { - 1417
provider: provider.trim().to_string(), - 1418
model: model.trim().to_string(), - 1419
}) - 1420
} - 1421
Some(_) => return StatusCode::BAD_REQUEST.into_response(), - 1422
None => None, - 1423
}; - 1424
let permission_mode = match parse_permission_mode_field(body.permission_mode.as_deref()) { - 1425
Ok(m) => m, - 1426
Err(()) => return StatusCode::BAD_REQUEST.into_response(), - 1427
}; - 1428
let bot_id = body - 1429
.bot_id - 1430
.as_deref() - 1431
.map(str::trim) - 1432
.filter(|s| !s.is_empty()) - 1433
.map(str::to_string); - 1434
let entry = state.gateway.allowlist_approve( - 1435
&state.core, - 1436
&key, - 1437
workspace, - 1438
body.agent_id - 1439
.as_deref() - 1440
.map(str::trim) - 1441
.filter(|id| !id.is_empty()) - 1442
.map(str::to_owned), - 1443
route, - 1444
permission_mode, - 1445
body.policy.unwrap_or_default(), - 1446
bot_id, - 1447
body.inherit_bot_policy, - 1448
"admin", - 1449
); - 1450
vak_core::security_events::record( - 1451
&state.core.sessions_home(), - 1452
vak_core::security_events::EventKind::ChatApproved, - 1453
"chat_approved", - 1454
&format!("key={key}"), - 1455
None, - 1456
); - 1457
note_workspace_trust(&state, entry.workspace.as_deref()); - 1458
record_permission_cap(&state, &key, &entry); - 1459
state - 1460
.hub - 1461
.emit_config_changed("gateway_allowlist_approved", &key); - 1462
(StatusCode::OK, Json(allowlist_entry_json(&state, &entry))).into_response() - 1463
} - 1464
- 1465
pub(crate) async fn deny_gateway_allowlist( - 1466
State(state): State<AppState>, - 1467
Path(key): Path<String>, - 1468
) -> Response { - 1469
if key.trim().is_empty() || !key.contains(':') { - 1470
return StatusCode::BAD_REQUEST.into_response(); - 1471
} - 1472
let entry = state.gateway.allowlist_deny(&state.core, &key, "admin"); - 1473
vak_core::security_events::record( - 1474
&state.core.sessions_home(), - 1475
vak_core::security_events::EventKind::ChatDenied, - 1476
"chat_denied", - 1477
&format!("key={key}"), - 1478
None, - 1479
); - 1480
state - 1481
.hub - 1482
.emit_config_changed("gateway_allowlist_denied", &key); - 1483
(StatusCode::OK, Json(allowlist_entry_json(&state, &entry))).into_response() - 1484
} - 1485
- 1486
#[derive(serde::Deserialize, Default)] - 1487
pub(crate) struct AllowlistPatchBody { - 1488
#[serde(default)] - 1489
workspace: Option<String>, - 1490
/// Optional agent binding. Send null/empty to reset to built-in vak. - 1491
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 1492
pub(crate) agent_id: Option<Option<String>>, - 1493
#[serde(default)] - 1494
route: Option<GatewayRoutePatch>, - 1495
/// Absent / null / `""` clears the pin (inherit the workspace default), - 1496
/// mirroring how an empty `route` object clears a pinned route. - 1497
#[serde(default)] - 1498
permission_mode: Option<String>, - 1499
#[serde(default)] - 1500
policy: Option<vak_config::ChannelPolicy>, - 1501
/// See `AllowlistApproveBody::bot_id`. `None` here (field simply - 1502
/// absent) means "leave whatever bot binding is already set" — unlike - 1503
/// approve, patch is an edit-in-place and must not silently unbind a - 1504
/// chat just because a caller's PATCH body didn't mention bots at all. - 1505
/// Send an explicit `null` to unbind. See - 1506
/// `crate::gateway::deserialize_present` for why the plain - 1507
/// `Option<Option<T>>` shape alone can't tell "absent" from "present - 1508
/// as null" apart — without it this `null` would silently do nothing. - 1509
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 1510
bot_id: Option<Option<String>>, - 1511
#[serde(default)] - 1512
inherit_bot_policy: Option<bool>, - 1513
/// Absent leaves this chat's voice alone; explicit `null` clears it - 1514
/// back to inherit (bot tier, then no voice); a `VoiceConfig` object - 1515
/// pins this chat's own override. Same `deserialize_present` shape as - 1516
/// `bot_id` above, for the same reason. - 1517
#[serde(default, deserialize_with = "crate::gateway::deserialize_present")] - 1518
voice: Option<Option<vak_config::VoiceConfig>>, - 1519
/// This chat's prompt tier (docs/design/45-prompt-layers.md). Absent - 1520
/// leaves it alone; an object replaces it. Restrictive by construction: - 1521
/// its guardrails add to the chain and its identity can only lose to a - 1522
/// narrower layer, never reach the code-owned blocks. - 1523
#[serde(default)] - 1524
prompt: Option<vak_core::prompts::LayerContent>, - 1525
} - 1526
- 1527
/// `PATCH /admin/api/gateway/allowlist/{key}` (docs/design/34 "Editing an - 1528
/// already-allowed entry"): re-point an `allowed` channel's workspace - 1529
/// and/or pinned route without revoking and re-approving it, which would - 1530
/// lose `added_at`/`added_by` provenance and momentarily 403 the channel. - 1531
/// - 1532
/// Only `allowed` entries are editable — pending/denied ones move through - 1533
/// approve/deny, not here (404 otherwise, same as an unknown key). - 1534
/// - 1535
/// The edit is *not* a second way to change a channel's effective route: - 1536
/// the entry is the one source of truth `binding_route` already reads, and - 1537
/// the binding's cached route revision is invalidated here so the change - 1538
/// takes effect through the exact stale-session-rotation path - 1539
/// `PATCH .../bindings/{key}` uses — the next inbound message rotates to a - 1540
/// fresh frozen session, preserving the old append-only ledger. - 1541
pub(crate) async fn patch_gateway_allowlist( - 1542
State(state): State<AppState>, - 1543
Path(key): Path<String>, - 1544
body: axum::body::Bytes, - 1545
) -> Response { - 1546
if key.trim().is_empty() || !key.contains(':') { - 1547
return StatusCode::BAD_REQUEST.into_response(); - 1548
} - 1549
let body: AllowlistPatchBody = if body.is_empty() { - 1550
AllowlistPatchBody::default() - 1551
} else { - 1552
match serde_json::from_slice(&body) { - 1553
Ok(b) => b, - 1554
Err(_) => return StatusCode::BAD_REQUEST.into_response(), - 1555
} - 1556
}; - 1557
let workspace = body - 1558
.workspace - 1559
.as_deref() - 1560
.map(str::trim) - 1561
.filter(|w| !w.is_empty()) - 1562
.map(PathBuf::from); - 1563
let route = match body.route { - 1564
Some(GatewayRoutePatch { - 1565
provider: Some(provider), - 1566
model: Some(model), - 1567
}) if !provider.trim().is_empty() && !model.trim().is_empty() => { - 1568
if !state - 1569
.core - 1570
.provider_names() - 1571
.iter() - 1572
.any(|name| name == provider.trim()) - 1573
{ - 1574
return StatusCode::BAD_REQUEST.into_response(); - 1575
} - 1576
Some(crate::gateway::AllowlistRoute { - 1577
provider: provider.trim().to_string(), - 1578
model: model.trim().to_string(), - 1579
}) - 1580
} - 1581
// An explicitly empty route object clears the pin (inherit the - 1582
// workspace default) — the same shape `PATCH .../bindings` uses. - 1583
Some(GatewayRoutePatch { - 1584
provider: None, - 1585
model: None, - 1586
}) - 1587
| None => None, - 1588
Some(_) => return StatusCode::BAD_REQUEST.into_response(), - 1589
}; - 1590
let permission_mode = match parse_permission_mode_field(body.permission_mode.as_deref()) { - 1591
Ok(m) => m, - 1592
Err(()) => return StatusCode::BAD_REQUEST.into_response(), - 1593
}; - 1594
let existing = state.gateway.allowlist_get(&key); - 1595
let agent_id = body.agent_id.map(|inner| { - 1596
inner - 1597
.as_deref() - 1598
.map(str::trim) - 1599
.filter(|s| !s.is_empty()) - 1600
.map(str::to_string) - 1601
}); - 1602
let bot_id = body.bot_id.map(|inner| { - 1603
inner - 1604
.as_deref() - 1605
.map(str::trim) - 1606
.filter(|s| !s.is_empty()) - 1607
.map(str::to_string) - 1608
}); - 1609
if let Some(Some(Err(error))) = body - 1610
.voice - 1611
.as_ref() - 1612
.map(|voice| voice.as_ref().map(crate::voice::check_tier)) - 1613
{ - 1614
return ( - 1615
StatusCode::BAD_REQUEST, - 1616
Json(serde_json::json!({ "error": error })), - 1617
) - 1618
.into_response(); - 1619
} - 1620
let Some(entry) = state.gateway.allowlist_patch( - 1621
&state.core, - 1622
&key, - 1623
workspace, - 1624
agent_id, - 1625
route, - 1626
permission_mode, - 1627
body.policy - 1628
.or_else(|| existing.as_ref().map(|e| e.policy.clone())) - 1629
.unwrap_or_default(), - 1630
bot_id, - 1631
body.inherit_bot_policy, - 1632
body.voice, - 1633
body.prompt, - 1634
) else { - 1635
return StatusCode::NOT_FOUND.into_response(); - 1636
}; - 1637
record_permission_cap(&state, &key, &entry); - 1638
// Same stale-detection seam as the binding route editor: drop the - 1639
// cached revision (never the ledger) so the next message re-derives - 1640
// the effective route and rotates only if it really changed. - 1641
state.gateway.invalidate_binding_revision(&state.core, &key); - 1642
vak_core::security_events::record( - 1643
&state.core.sessions_home(), - 1644
vak_core::security_events::EventKind::ConfigChange, - 1645
"chat_edited", - 1646
&format!( - 1647
"key={key} workspace={} permission_mode={}", - 1648
entry - 1649
.workspace - 1650
.as_ref() - 1651
.map(|w| w.display().to_string()) - 1652
.unwrap_or_else(|| "(inherit)".into()), - 1653
entry - 1654
.permission_mode - 1655
.map(|m| m.as_str()) - 1656
.unwrap_or("(inherit)") - 1657
), - 1658
None, - 1659
); - 1660
note_workspace_trust(&state, entry.workspace.as_deref()); - 1661
state - 1662
.hub - 1663
.emit_config_changed("gateway_allowlist_patched", &key); - 1664
(StatusCode::OK, Json(allowlist_entry_json(&state, &entry))).into_response() - 1665
} - 1666
- 1667
pub(crate) async fn revoke_gateway_allowlist( - 1668
State(state): State<AppState>, - 1669
Path(key): Path<String>, - 1670
) -> StatusCode { - 1671
if state.gateway.allowlist_revoke(&state.core, &key) { - 1672
vak_core::security_events::record( - 1673
&state.core.sessions_home(), - 1674
vak_core::security_events::EventKind::ChatRevoked, - 1675
"chat_revoked", - 1676
&format!("key={key}"), - 1677
None, - 1678
); - 1679
state - 1680
.hub - 1681
.emit_config_changed("gateway_allowlist_revoked", &key); - 1682
StatusCode::OK - 1683
} else { - 1684
StatusCode::NOT_FOUND - 1685
} - 1686
} - 1687
- 1688
// ---- Admin route mounter -------------------------------------------------- - 1689
- 1690
pub(crate) fn routes() -> axum::Router<AppState> { - 1691
use axum::routing::{get, patch, post}; - 1692
axum::Router::new() - 1693
.route("/admin/api/sessions", get(list_sessions_admin)) - 1694
.route( - 1695
"/admin/api/sessions/{id}/transcript", - 1696
get(session_transcript_admin), - 1697
) - 1698
.route("/admin/api/approvals", get(list_pending_approvals)) - 1699
.route("/admin/api/bestofn", get(list_bestofn)) - 1700
.route("/admin/api/search", get(search_admin)) - 1701
.route("/admin/api/events", get(admin_events_sse)) - 1702
.route("/admin/api/security", get(list_security_events)) - 1703
// Mutations are POST: crawlers/prefetchers only ever issue GETs. - 1704
.route("/admin/api/store/rebuild", post(rebuild_store)) - 1705
.route( - 1706
"/admin/api/store/import/{session_id}", - 1707
post(import_session_store), - 1708
) - 1709
.route("/admin/api/config", get(get_config_admin)) - 1710
.route("/admin/api/gateway/status", get(gateway_status_admin)) - 1711
.route( - 1712
"/admin/api/gateway/workspace", - 1713
axum::routing::patch(patch_gateway_workspace), - 1714
) - 1715
.route("/admin/api/workspaces/name", patch(patch_workspace_name)) - 1716
.route( - 1717
"/admin/api/gateway/bindings/{key}", - 1718
patch(patch_gateway_binding).delete(delete_gateway_binding_admin), - 1719
) - 1720
.route( - 1721
"/admin/api/gateway/bindings/{key}/rotate", - 1722
post(rotate_gateway_binding), - 1723
) - 1724
.route("/admin/api/gateway/allowlist", get(list_gateway_allowlist)) - 1725
.route( - 1726
"/admin/api/gateway/allowlist/{key}/approve", - 1727
post(approve_gateway_allowlist), - 1728
) - 1729
.route( - 1730
"/admin/api/gateway/allowlist/{key}/deny", - 1731
post(deny_gateway_allowlist), - 1732
) - 1733
.route( - 1734
"/admin/api/gateway/allowlist/{key}", - 1735
patch(patch_gateway_allowlist).delete(revoke_gateway_allowlist), - 1736
) - 1737
} - 1738
- 1739
// ---- Tests ---------------------------------------------------------------- - 1740
- 1741
#[cfg(test)] - 1742
mod tests { - 1743
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1744
- 1745
use crate::AppState; - 1746
use axum::body::Body; - 1747
use axum::http::{Request, StatusCode}; - 1748
use tower::ServiceExt; - 1749
- 1750
fn test_state() -> AppState { - 1751
// Isolates the trust-marker store as well as the session ledger: - 1752
// approving a channel records a trust decision, and that write must - 1753
// not reach the developer's real data home. - 1754
vak_config::paths::isolate_home_for_tests(); - 1755
let dir = tempfile::tempdir().unwrap(); - 1756
let cwd = dir.path().to_path_buf(); - 1757
let core = vak_core::Core::new(cwd).unwrap(); - 1758
// Without this, `sessions_home()` falls back to the developer's - 1759
// real $XDG_DATA_HOME/vak — any test that persists something - 1760
// (gateway bindings, the allowlist store) would leak state across - 1761
// test runs and across the machine. Every other test module in - 1762
// this crate isolates it the same way. - 1763
core.set_sessions_home(dir.path().join("home")); - 1764
AppState::new(core) - 1765
} - 1766
- 1767
fn authed_app(state: &AppState) -> axum::Router { - 1768
crate::router_with_state(state.clone()).layer(axum::middleware::from_fn_with_state( - 1769
crate::AuthPolicy { - 1770
token: (*state.auth_token).clone(), - 1771
home: state.core.sessions_home(), - 1772
trusted_hosts: Vec::new(), - 1773
}, - 1774
crate::require_bearer, - 1775
)) - 1776
} - 1777
- 1778
async fn body_json(resp: axum::response::Response) -> serde_json::Value { - 1779
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - 1780
.await - 1781
.unwrap(); - 1782
serde_json::from_slice(&bytes).unwrap() - 1783
} - 1784
- 1785
#[tokio::test] - 1786
async fn list_sessions_returns_ok() { - 1787
let state = test_state(); - 1788
let token = (*state.auth_token).clone(); - 1789
let app = authed_app(&state); - 1790
let req = Request::builder() - 1791
.uri("/admin/api/sessions") - 1792
.header("authorization", format!("Bearer {token}")) - 1793
.body(Body::empty()) - 1794
.unwrap(); - 1795
let resp = app.oneshot(req).await.unwrap(); - 1796
assert_eq!(resp.status(), StatusCode::OK); - 1797
let json = body_json(resp).await; - 1798
assert!(json["sessions"].is_array()); - 1799
} - 1800
- 1801
#[tokio::test] - 1802
async fn search_returns_ok() { - 1803
let state = test_state(); - 1804
let token = (*state.auth_token).clone(); - 1805
let app = authed_app(&state); - 1806
let req = Request::builder() - 1807
.uri("/admin/api/search?q=hello") - 1808
.header("authorization", format!("Bearer {token}")) - 1809
.body(Body::empty()) - 1810
.unwrap(); - 1811
let resp = app.oneshot(req).await.unwrap(); - 1812
assert_eq!(resp.status(), StatusCode::OK); - 1813
let json = body_json(resp).await; - 1814
assert!(json.get("total").is_some()); - 1815
} - 1816
- 1817
#[tokio::test] - 1818
async fn security_events_returns_ok() { - 1819
let state = test_state(); - 1820
let token = (*state.auth_token).clone(); - 1821
let app = authed_app(&state); - 1822
let req = Request::builder() - 1823
.uri("/admin/api/security") - 1824
.header("authorization", format!("Bearer {token}")) - 1825
.body(Body::empty()) - 1826
.unwrap(); - 1827
let resp = app.oneshot(req).await.unwrap(); - 1828
assert_eq!(resp.status(), StatusCode::OK); - 1829
} - 1830
- 1831
#[tokio::test] - 1832
async fn store_rebuild_returns_ok() { - 1833
let state = test_state(); - 1834
let token = (*state.auth_token).clone(); - 1835
let app = authed_app(&state); - 1836
let req = Request::builder() - 1837
.method("POST") - 1838
.uri("/admin/api/store/rebuild") - 1839
.header("authorization", format!("Bearer {token}")) - 1840
.body(Body::empty()) - 1841
.unwrap(); - 1842
let resp = app.oneshot(req).await.unwrap(); - 1843
assert_eq!(resp.status(), StatusCode::OK); - 1844
let json = body_json(resp).await; - 1845
assert!(json["ok"].as_bool().unwrap_or(false)); - 1846
} - 1847
- 1848
#[tokio::test] - 1849
async fn rebuild_via_get_is_rejected() { - 1850
let state = test_state(); - 1851
let token = (*state.auth_token).clone(); - 1852
let app = authed_app(&state); - 1853
let req = Request::builder() - 1854
.uri("/admin/api/store/rebuild") - 1855
.header("authorization", format!("Bearer {token}")) - 1856
.body(Body::empty()) - 1857
.unwrap(); - 1858
let resp = app.oneshot(req).await.unwrap(); - 1859
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); - 1860
} - 1861
- 1862
/// One login for every browser surface: the operations console and - 1863
/// the workspace client share `/auth/login` and one cookie. - 1864
/// `/admin/login` was REMOVED rather than kept alongside it — two - 1865
/// endpoints against one cookie is two contracts that must agree - 1866
/// forever (invariant 30). - 1867
#[tokio::test] - 1868
async fn login_sets_cookie_and_it_authenticates() { - 1869
use axum::http::header; - 1870
let state = test_state(); - 1871
let token = (*state.auth_token).clone(); - 1872
- 1873
// Login itself is auth-exempt; plain router is fine for it. - 1874
let plain = crate::router_with_state(state.clone()); - 1875
- 1876
// Wrong token → 401. - 1877
let req = Request::builder() - 1878
.method("POST") - 1879
.uri("/auth/login") - 1880
.header(header::CONTENT_TYPE, "application/json") - 1881
.body(Body::from(r#"{"token":"wrong"}"#)) - 1882
.unwrap(); - 1883
let resp = plain.clone().oneshot(req).await.unwrap(); - 1884
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - 1885
- 1886
// Correct token → 200 + cookie. - 1887
let req = Request::builder() - 1888
.method("POST") - 1889
.uri("/auth/login") - 1890
.header(header::CONTENT_TYPE, "application/json") - 1891
.body(Body::from(format!(r#"{{"token":"{token}"}}"#))) - 1892
.unwrap(); - 1893
let resp = plain.clone().oneshot(req).await.unwrap(); - 1894
assert_eq!(resp.status(), StatusCode::OK); - 1895
let set_cookie = resp - 1896
.headers() - 1897
.get(header::SET_COOKIE) - 1898
.and_then(|v| v.to_str().ok()) - 1899
.unwrap_or_default() - 1900
.to_string(); - 1901
assert!(set_cookie.starts_with("vak_session="), "cookie must be set"); - 1902
assert!(set_cookie.contains("HttpOnly")); - 1903
- 1904
// Cookie authenticates a protected admin endpoint with no header. - 1905
let cookie_pair = &set_cookie[..set_cookie.find(';').unwrap_or(set_cookie.len())]; - 1906
let authed = authed_app(&state); - 1907
let req = Request::builder() - 1908
.uri("/admin/api/sessions") - 1909
.header(header::COOKIE, cookie_pair) - 1910
.body(Body::empty()) - 1911
.unwrap(); - 1912
let resp = authed.oneshot(req).await.unwrap(); - 1913
assert_eq!(resp.status(), StatusCode::OK); - 1914
} - 1915
- 1916
#[tokio::test] - 1917
async fn unauthenticated_admin_api_is_401() { - 1918
let state = test_state(); - 1919
let app = authed_app(&state); - 1920
let req = Request::builder() - 1921
.uri("/admin/api/sessions") - 1922
.body(Body::empty()) - 1923
.unwrap(); - 1924
let resp = app.oneshot(req).await.unwrap(); - 1925
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - 1926
} - 1927
- 1928
/// `EventSource` cannot set request headers, and the desktop app - 1929
/// never performs the `/auth/login` cookie exchange -- that is the - 1930
/// browser console's flow. `?token=` is the only channel its SSE - 1931
/// streams can authenticate on, and `openEventStream` / - 1932
/// `openSideStream` have always used it. The middleware accepted - 1933
/// only the header and the cookie, so every desktop event stream - 1934
/// was rejected 401 and the app received not one agent event: runs - 1935
/// completed and were durably logged while the UI showed no reply, - 1936
/// "Working" forever, and `0 in / 0 out`. Nothing surfaced in the - 1937
/// console either, because a 401 on an EventSource is just a bare - 1938
/// `onerror`. - 1939
/// - 1940
/// Found by opening the real SSE endpoint with curl and getting - 1941
/// zero bytes back, after three unrelated "fixes" shipped on - 1942
/// theory without ever testing this path. - 1943
#[tokio::test] - 1944
async fn sse_authenticates_with_the_token_query_parameter() { - 1945
let state = test_state(); - 1946
let token = (*state.auth_token).clone(); - 1947
let app = authed_app(&state); - 1948
let req = Request::builder() - 1949
.uri(format!("/sessions/does-not-exist/events?token={token}")) - 1950
.body(Body::empty()) - 1951
.unwrap(); - 1952
let resp = app.oneshot(req).await.unwrap(); - 1953
// The session id is bogus, so any status is acceptable EXCEPT - 1954
// 401: this asserts the request got past authentication, which - 1955
// is the thing that was broken. Asserting 200 would instead - 1956
// pin unrelated session-lookup behaviour. - 1957
assert_ne!( - 1958
resp.status(), - 1959
StatusCode::UNAUTHORIZED, - 1960
"?token= must authenticate -- it is the only channel EventSource has" - 1961
); - 1962
} - 1963
- 1964
#[tokio::test] - 1965
async fn a_wrong_token_query_parameter_is_still_rejected() { - 1966
let state = test_state(); - 1967
let app = authed_app(&state); - 1968
let req = Request::builder() - 1969
.uri("/sessions/does-not-exist/events?token=vk_not-the-real-token") - 1970
.body(Body::empty()) - 1971
.unwrap(); - 1972
let resp = app.oneshot(req).await.unwrap(); - 1973
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - 1974
} - 1975
- 1976
#[tokio::test] - 1977
async fn pending_approvals_lists_empty_without_gates() { - 1978
let state = test_state(); - 1979
let token = (*state.auth_token).clone(); - 1980
- 1981
// Create a live session so the aggregation path has something to walk. - 1982
{ - 1983
let core = state.core.clone(); - 1984
let s = core.start_session().await.unwrap(); - 1985
let id = s.header().map(|h| h.session_id.clone()).unwrap_or_default(); - 1986
crate::register_handle(&state, id, s, state.core.cwd().clone(), state.core.clone()); - 1987
} - 1988
- 1989
let app = - 1990
crate::router_with_state(state.clone()).layer(axum::middleware::from_fn_with_state( - 1991
crate::AuthPolicy { - 1992
token: (*state.auth_token).clone(), - 1993
home: state.core.sessions_home(), - 1994
trusted_hosts: Vec::new(), - 1995
}, - 1996
crate::require_bearer, - 1997
)); - 1998
let req = Request::builder() - 1999
.uri("/admin/api/approvals") - 2000
.header("authorization", format!("Bearer {token}"))
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.