- 1001
- 1002
/// Insert or replace a bot row wholesale (create, rename label, or edit - 1003
/// policy/permission_mode/route/workspace). `token_env` is set - 1004
/// separately from the actual secret by the caller before this is - 1005
/// invoked, keeping the write here free of the token value itself. - 1006
pub(crate) fn bot_upsert(&self, core: &Core, bot: Bot) { - 1007
let id = bot.id.clone(); - 1008
self.bots - 1009
.lock() - 1010
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1011
.insert(id.clone(), bot); - 1012
persist_bots(core, self); - 1013
self.invalidate_bindings_for_bot(core, &id); - 1014
} - 1015
- 1016
/// Remove a bot row. Chats whose `bot_id` names it keep the id on - 1017
/// record (a dangling reference resolves as "no bot" at dispatch, - 1018
/// same as an unset `bot_id`) rather than being silently rewritten. - 1019
pub(crate) fn bot_remove(&self, core: &Core, id: &str) -> bool { - 1020
let removed = self - 1021
.bots - 1022
.lock() - 1023
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1024
.remove(id) - 1025
.is_some(); - 1026
if removed { - 1027
persist_bots(core, self); - 1028
self.invalidate_bindings_for_bot(core, id); - 1029
} - 1030
removed - 1031
} - 1032
- 1033
// ---- Allowlist store (docs/design/34-channel-onboarding.md) ----------- - 1034
- 1035
/// Snapshot of all allowlist entries, any status, sorted by key. - 1036
pub(crate) fn allowlist_snapshot(&self) -> Vec<AllowlistEntry> { - 1037
let mut entries: Vec<AllowlistEntry> = self - 1038
.allowlist - 1039
.lock() - 1040
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1041
.values() - 1042
.cloned() - 1043
.collect(); - 1044
entries.sort_by(|a, b| a.key.cmp(&b.key)); - 1045
entries - 1046
} - 1047
- 1048
pub(crate) fn allowlist_get(&self, key: &str) -> Option<AllowlistEntry> { - 1049
self.allowlist - 1050
.lock() - 1051
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1052
.get(key) - 1053
.cloned() - 1054
} - 1055
- 1056
/// Resolve an inbound key against the store: creates a pending entry on - 1057
/// first sight, never duplicates or bumps `added_at` on a repeat - 1058
/// message from an already-pending key. - 1059
pub(crate) fn allowlist_resolve_inbound( - 1060
&self, - 1061
core: &Core, - 1062
key: &str, - 1063
first_seen_text: &str, - 1064
bot_id: Option<&str>, - 1065
) -> AllowlistDecision { - 1066
let mut map = self - 1067
.allowlist - 1068
.lock() - 1069
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1070
// Distinct from `decision` below: repeat traffic from an already- - 1071
// `Allowed` key takes the same `AllowlistDecision::Allowed` value - 1072
// as the freshly-inherited case, but must not re-persist the file - 1073
// on every single inbound message — only a real map mutation - 1074
// (a fresh Pending row, or a fresh inherited-Allowed row) should. - 1075
let mut mutated = false; - 1076
let decision = match map.get(key).map(|e| e.status) { - 1077
Some(AllowlistStatus::Allowed) => AllowlistDecision::Allowed, - 1078
Some(AllowlistStatus::Denied) => AllowlistDecision::Denied, - 1079
Some(AllowlistStatus::Pending) => AllowlistDecision::StillPending, - 1080
None => { - 1081
// Multi-bot-per-channel (docs/design/34 Phase 5 follow-up): - 1082
// a bot-scoped key (`surface:chat:bot_id`) seen for the - 1083
// first time inherits an already-allowed legacy - 1084
// (`surface:chat`) entry's approval/workspace/policy when - 1085
// one exists — the same physical chat an operator already - 1086
// trusted, just now seen through a second bot. Without - 1087
// this, every existing approved chat would need a needless - 1088
// re-approval the moment its bridge started sending a - 1089
// bot id, and a `chat_allowlist` entry in config.toml - 1090
// (also just a row in this same map) would silently stop - 1091
// matching too. - 1092
if let Some(legacy_key) = legacy_key_for(key) - 1093
&& let Some(legacy) = map.get(&legacy_key).cloned() - 1094
&& legacy.status == AllowlistStatus::Allowed - 1095
{ - 1096
map.insert( - 1097
key.to_string(), - 1098
AllowlistEntry { - 1099
key: key.to_string(), - 1100
bot_id: bot_id.map(str::to_string), - 1101
added_by: format!("gateway (inherited from {legacy_key})"), - 1102
added_at: chrono::Utc::now().to_rfc3339(), - 1103
first_seen_text: None, - 1104
prompt: Default::default(), - 1105
..legacy - 1106
}, - 1107
); - 1108
// The legacy row's *allowlist entry* stays — a third - 1109
// bot arriving later needs it as the ancestor to - 1110
// inherit from too, same as this one just did. But its - 1111
// *binding/session* is now genuinely dead: every future - 1112
// message for this physical chat will always carry a - 1113
// bot id and therefore always route through a - 1114
// bot-scoped key, never this one again. Left bound, it - 1115
// would sit forever in the Chats list looking like a - 1116
// confusing duplicate of the bot-scoped row — the exact - 1117
// bug a live operator hit. `unbind` locks - 1118
// `self.bindings`, a different mutex than the `map` - 1119
// guard held here, so no deadlock. - 1120
self.unbind(core, &legacy_key); - 1121
mutated = true; - 1122
AllowlistDecision::Allowed - 1123
} else { - 1124
let truncated: String = first_seen_text - 1125
.chars() - 1126
.take(FIRST_SEEN_TEXT_MAX_CHARS) - 1127
.collect(); - 1128
let bot_agent = bot_id - 1129
.and_then(|id| self.bot_get(id)) - 1130
.and_then(|b| b.agent_id); - 1131
map.insert( - 1132
key.to_string(), - 1133
AllowlistEntry { - 1134
key: key.to_string(), - 1135
status: AllowlistStatus::Pending, - 1136
workspace: None, - 1137
agent_id: bot_agent.or_else(|| Some("vak".into())), - 1138
route: None, - 1139
voice: None, - 1140
permission_mode: None, - 1141
policy: vak_config::ChannelPolicy::default(), - 1142
added_at: chrono::Utc::now().to_rfc3339(), - 1143
added_by: "gateway".into(), - 1144
first_seen_text: Some(truncated), - 1145
prompt: Default::default(), - 1146
bot_id: bot_id.map(str::to_string), - 1147
inherit_bot_policy: true, - 1148
}, - 1149
); - 1150
mutated = true; - 1151
AllowlistDecision::NewlyPending - 1152
} - 1153
} - 1154
}; - 1155
if mutated { - 1156
drop(map); - 1157
persist_allowlist(core, self); - 1158
} - 1159
decision - 1160
} - 1161
- 1162
/// Approve a key: pending or unknown → allowed, with an explicit - 1163
/// workspace (never silently inherited) and optional route override. - 1164
#[allow(clippy::too_many_arguments)] - 1165
pub(crate) fn allowlist_approve( - 1166
&self, - 1167
core: &Core, - 1168
key: &str, - 1169
workspace: PathBuf, - 1170
agent_id: Option<String>, - 1171
route: Option<AllowlistRoute>, - 1172
permission_mode: Option<vak_config::PermissionMode>, - 1173
policy: vak_config::ChannelPolicy, - 1174
bot_id: Option<String>, - 1175
inherit_bot_policy: bool, - 1176
added_by: &str, - 1177
) -> AllowlistEntry { - 1178
let entry = { - 1179
let mut map = self - 1180
.allowlist - 1181
.lock() - 1182
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1183
let bot_agent = bot_id - 1184
.as_deref() - 1185
.and_then(|id| self.bot_get(id)) - 1186
.and_then(|b| b.agent_id); - 1187
let entry = AllowlistEntry { - 1188
key: key.to_string(), - 1189
status: AllowlistStatus::Allowed, - 1190
workspace: Some(workspace), - 1191
agent_id: agent_id.or(bot_agent).or_else(|| Some("vak".into())), - 1192
route, - 1193
voice: None, - 1194
permission_mode, - 1195
policy, - 1196
added_at: chrono::Utc::now().to_rfc3339(), - 1197
added_by: added_by.to_string(), - 1198
first_seen_text: None, - 1199
prompt: Default::default(), - 1200
bot_id, - 1201
inherit_bot_policy, - 1202
}; - 1203
map.insert(key.to_string(), entry.clone()); - 1204
entry - 1205
}; - 1206
persist_allowlist(core, self); - 1207
entry - 1208
} - 1209
- 1210
/// Deny a key: pending or unknown → denied (sticky). - 1211
pub(crate) fn allowlist_deny(&self, core: &Core, key: &str, added_by: &str) -> AllowlistEntry { - 1212
let entry = { - 1213
let mut map = self - 1214
.allowlist - 1215
.lock() - 1216
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1217
let entry = AllowlistEntry { - 1218
key: key.to_string(), - 1219
status: AllowlistStatus::Denied, - 1220
workspace: None, - 1221
agent_id: Some("vak".into()), - 1222
route: None, - 1223
voice: None, - 1224
permission_mode: None, - 1225
policy: vak_config::ChannelPolicy::default(), - 1226
added_at: chrono::Utc::now().to_rfc3339(), - 1227
added_by: added_by.to_string(), - 1228
first_seen_text: None, - 1229
prompt: Default::default(), - 1230
bot_id: None, - 1231
inherit_bot_policy: true, - 1232
}; - 1233
map.insert(key.to_string(), entry.clone()); - 1234
entry - 1235
}; - 1236
persist_allowlist(core, self); - 1237
entry - 1238
} - 1239
- 1240
/// Edit an already-`allowed` entry's `workspace`/`route` in place - 1241
/// (docs/design/34 "Editing an already-allowed entry"). Provenance - 1242
/// (`added_at`/`added_by`) is deliberately preserved — this is a - 1243
/// re-point, not a re-approval. `None` for either field clears it - 1244
/// (inherit the gateway workspace / the workspace's default route). - 1245
/// - 1246
/// The caller is expected to follow this with - 1247
/// [`GatewayState::invalidate_binding_revision`] so the change goes - 1248
/// through the same stale-session-rotation path - 1249
/// `PATCH .../bindings/{key}` already uses, rather than mutating - 1250
/// allowlist state the binding/session layer never learns about. - 1251
#[allow(clippy::too_many_arguments)] - 1252
pub(crate) fn allowlist_patch( - 1253
&self, - 1254
core: &Core, - 1255
key: &str, - 1256
workspace: Option<PathBuf>, - 1257
agent_id: Option<Option<String>>, - 1258
route: Option<AllowlistRoute>, - 1259
permission_mode: Option<vak_config::PermissionMode>, - 1260
policy: vak_config::ChannelPolicy, - 1261
bot_id: Option<Option<String>>, - 1262
inherit_bot_policy: Option<bool>, - 1263
voice: Option<Option<vak_config::VoiceConfig>>, - 1264
prompt: Option<vak_core::prompts::LayerContent>, - 1265
) -> Option<AllowlistEntry> { - 1266
let entry = { - 1267
let mut map = self - 1268
.allowlist - 1269
.lock() - 1270
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1271
let entry = map.get_mut(key)?; - 1272
if entry.status != AllowlistStatus::Allowed { - 1273
return None; - 1274
} - 1275
entry.workspace = workspace; - 1276
if let Some(agent_id) = agent_id { - 1277
entry.agent_id = agent_id.and_then(|s| { - 1278
let trimmed = s.trim(); - 1279
if trimmed.is_empty() { - 1280
None - 1281
} else { - 1282
Some(trimmed.to_string()) - 1283
} - 1284
}); - 1285
} - 1286
entry.route = route; - 1287
entry.permission_mode = permission_mode; - 1288
entry.policy = policy; - 1289
if let Some(bot_id) = bot_id { - 1290
entry.bot_id = bot_id; - 1291
} - 1292
if let Some(inherit) = inherit_bot_policy { - 1293
entry.inherit_bot_policy = inherit; - 1294
} - 1295
if let Some(voice) = voice { - 1296
entry.voice = voice; - 1297
} - 1298
if let Some(prompt) = prompt { - 1299
entry.prompt = prompt; - 1300
} - 1301
entry.clone() - 1302
}; - 1303
persist_allowlist(core, self); - 1304
Some(entry) - 1305
} - 1306
- 1307
/// Write-through for the pre-existing `PATCH .../bindings/{key}` - 1308
/// surface: when this key has an `allowed` entry, its route is the - 1309
/// source of truth, so a route change made from the binding editor - 1310
/// lands there too instead of drifting. Returns true when an entry was - 1311
/// actually updated. - 1312
pub(crate) fn allowlist_patch_route_if_allowed( - 1313
&self, - 1314
core: &Core, - 1315
key: &str, - 1316
route: Option<AllowlistRoute>, - 1317
) -> bool { - 1318
let Some(entry) = self.allowlist_get(key) else { - 1319
return false; - 1320
}; - 1321
if entry.status != AllowlistStatus::Allowed { - 1322
return false; - 1323
} - 1324
// Preserve the permission override: the binding editor only ever - 1325
// speaks about routes, so it must not silently clear a channel's - 1326
// pinned permission mode as a side effect. - 1327
self.allowlist_patch( - 1328
core, - 1329
key, - 1330
entry.workspace, - 1331
Some(entry.agent_id), - 1332
route, - 1333
entry.permission_mode, - 1334
entry.policy, - 1335
Some(entry.bot_id), - 1336
Some(entry.inherit_bot_policy), - 1337
Some(entry.voice), - 1338
// A route write must not disturb this chat's prompt tier, for - 1339
// the same reason the comment above gives about permission mode. - 1340
None, - 1341
) - 1342
.is_some() - 1343
} - 1344
- 1345
/// Auto-deny every `pending` entry older than `max_age`, stamping - 1346
/// `added_by = "expiry"` so an expired request stays visibly distinct - 1347
/// from an operator's own deny (docs/design/34 open question 1). - 1348
/// Returns the keys denied. - 1349
pub(crate) fn allowlist_expire_pending( - 1350
&self, - 1351
core: &Core, - 1352
max_age: chrono::Duration, - 1353
) -> Vec<String> { - 1354
let cutoff = chrono::Utc::now() - max_age; - 1355
let expired: Vec<String> = self - 1356
.allowlist - 1357
.lock() - 1358
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1359
.values() - 1360
.filter(|e| { - 1361
e.status == AllowlistStatus::Pending && parse_added_at_before(&e.added_at, cutoff) - 1362
}) - 1363
.map(|e| e.key.clone()) - 1364
.collect(); - 1365
for key in &expired { - 1366
self.allowlist_deny(core, key, "expiry"); - 1367
} - 1368
expired - 1369
} - 1370
- 1371
/// The single answer to "what does this channel route to": the - 1372
/// allowlist entry's pinned route when it has one (the Phase 1 admin - 1373
/// surface), otherwise the binding's own provider/model override (the - 1374
/// pre-existing `PATCH .../bindings/{key}` surface). One source of - 1375
/// truth read at dispatch, so the two admin surfaces cannot drift. - 1376
fn effective_route_override(&self, key: &str) -> Option<(String, String)> { - 1377
let entry = self - 1378
.allowlist_get(key) - 1379
.filter(|e| e.status == AllowlistStatus::Allowed); - 1380
if let Some(route) = entry.as_ref().and_then(|e| e.route.clone()) { - 1381
return Some((route.provider, route.model)); - 1382
} - 1383
// Bot tier: the chat named no route of its own, so fall through to - 1384
// its bot's route (if any, and if inheritance wasn't broken) before - 1385
// the legacy binding override / workspace default. - 1386
if let Some(route) = entry - 1387
.as_ref() - 1388
.filter(|e| e.inherit_bot_policy) - 1389
.and_then(|e| e.bot_id.as_deref()) - 1390
.and_then(|id| self.bot_get(id)) - 1391
.and_then(|b| b.route) - 1392
{ - 1393
return Some((route.provider, route.model)); - 1394
} - 1395
self.bindings - 1396
.lock() - 1397
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1398
.get(key) - 1399
.and_then(|binding| Some((binding.provider.clone()?, binding.model.clone()?))) - 1400
} - 1401
- 1402
/// The single answer to "what voice/persona does this channel speak - 1403
/// with": the chat's own override when it has one, otherwise its - 1404
/// bot's (unless inheritance was broken), otherwise `None` (no voice - 1405
/// configured — caller falls back to a built-in default). Mirrors - 1406
/// `effective_route_override` exactly. - 1407
/// Bot then chat prompt tiers, broadest first, for `Core::with_prompt_overlays`. - 1408
/// - 1409
/// Deliberately *not* shaped like `resolve_voice`, which picks one - 1410
/// winner: guardrails from both tiers must survive, so both are handed - 1411
/// to the resolver and it applies the narrowest-wins rule to identity - 1412
/// and rules while concatenating guardrails. `inherit_bot_policy` gates - 1413
/// the bot tier exactly as it gates policy, route, and voice. - 1414
pub(crate) fn resolve_prompt_overlays(&self, key: &str) -> Vec<vak_core::prompts::LayerInput> { - 1415
let Some(entry) = self - 1416
.allowlist_get(key) - 1417
.filter(|e| e.status == AllowlistStatus::Allowed) - 1418
else { - 1419
return Vec::new(); - 1420
}; - 1421
let mut out = Vec::new(); - 1422
if entry.inherit_bot_policy - 1423
&& let Some(bot) = entry.bot_id.as_deref().and_then(|id| self.bot_get(id)) - 1424
&& !bot.prompt.is_empty() - 1425
{ - 1426
out.push(vak_core::prompts::LayerInput::new( - 1427
vak_core::prompts::PromptLayer::Bot, - 1428
Some(format!("bot:{}", bot.id)), - 1429
bot.prompt, - 1430
)); - 1431
} - 1432
if !entry.prompt.is_empty() { - 1433
out.push(vak_core::prompts::LayerInput::new( - 1434
vak_core::prompts::PromptLayer::Chat, - 1435
Some(format!("chat:{key}")), - 1436
entry.prompt, - 1437
)); - 1438
} - 1439
out - 1440
} - 1441
- 1442
/// The style directive for spoken replies on this chat. - 1443
/// - 1444
/// One source of truth (docs/design/45-prompt-layers.md): the bot/chat - 1445
/// `identity` block *is* the persona. `VoiceConfig.persona` predates - 1446
/// prompt layers and said the same thing in a second place; keeping both - 1447
/// authoritative would let a bot's spoken and written selves drift apart - 1448
/// within a release. The legacy field is still honoured when no prompt - 1449
/// tier sets an identity, so existing configs keep working untouched. - 1450
/// - 1451
/// Only the *gateway tiers'* own identity text is used, never the - 1452
/// assembled prompt — the seed identity and capability contract are - 1453
/// meaningless as a text-to-speech style directive. - 1454
pub(crate) fn resolve_persona(&self, key: &str) -> Option<String> { - 1455
self.resolve_prompt_overlays(key) - 1456
.into_iter() - 1457
.rev() - 1458
.find_map(|layer| layer.content.identity) - 1459
.map(|text| text.trim().to_string()) - 1460
.filter(|text| !text.is_empty()) - 1461
.or_else(|| { - 1462
self.resolve_voice(key) - 1463
.and_then(|voice| voice.persona) - 1464
.map(|p| p.trim().to_string()) - 1465
.filter(|p| !p.is_empty()) - 1466
}) - 1467
} - 1468
- 1469
pub(crate) fn resolve_voice(&self, key: &str) -> Option<vak_config::VoiceConfig> { - 1470
let entry = self - 1471
.allowlist_get(key) - 1472
.filter(|e| e.status == AllowlistStatus::Allowed); - 1473
let parent = entry - 1474
.as_ref() - 1475
.filter(|e| e.inherit_bot_policy) - 1476
.and_then(|e| e.bot_id.as_deref()) - 1477
.and_then(|id| self.bot_get(id)) - 1478
.and_then(|b| b.voice); - 1479
entry - 1480
.as_ref() - 1481
.and_then(|e| e.voice.as_ref()) - 1482
.map(|v| vak_config::VoiceConfig::overlay(parent.as_ref(), v)) - 1483
.or(parent) - 1484
} - 1485
- 1486
/// Drop the cached route revision for `key` without dropping the - 1487
/// session id — exactly what `set_route_override` does — so the next - 1488
/// inbound message re-derives the effective route and rotates the - 1489
/// frozen session if (and only if) it actually changed. - 1490
pub(crate) fn invalidate_binding_revision(&self, core: &Core, key: &str) { - 1491
let touched = { - 1492
let mut bindings = self - 1493
.bindings - 1494
.lock() - 1495
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1496
match bindings.get_mut(key) { - 1497
Some(binding) => { - 1498
binding.route_revision = None; - 1499
true - 1500
} - 1501
None => false, - 1502
} - 1503
}; - 1504
if touched { - 1505
persist_bindings(core, self); - 1506
} - 1507
} - 1508
- 1509
pub(crate) fn invalidate_bindings_for_bot(&self, core: &Core, bot_id: &str) { - 1510
let keys: Vec<String> = { - 1511
let allowlist = self - 1512
.allowlist - 1513
.lock() - 1514
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1515
allowlist - 1516
.values() - 1517
.filter(|entry| entry.bot_id.as_deref() == Some(bot_id)) - 1518
.map(|entry| entry.key.clone()) - 1519
.collect() - 1520
}; - 1521
let touched = { - 1522
let mut bindings = self - 1523
.bindings - 1524
.lock() - 1525
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1526
let mut changed = false; - 1527
for key in keys { - 1528
if let Some(binding) = bindings.get_mut(&key) { - 1529
binding.route_revision = None; - 1530
changed = true; - 1531
} - 1532
} - 1533
changed - 1534
}; - 1535
if touched { - 1536
persist_bindings(core, self); - 1537
} - 1538
} - 1539
- 1540
/// Test seam: plant a bound session with a frozen route revision, the - 1541
/// state dispatch leaves behind, without running a whole turn. - 1542
#[cfg(test)] - 1543
pub(crate) fn bind_for_test(&self, key: &str, session_id: &str, revision: &str) { - 1544
let mut bindings = self - 1545
.bindings - 1546
.lock() - 1547
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1548
let binding = bindings.entry(key.to_string()).or_default(); - 1549
binding.session_id = Some(session_id.to_string()); - 1550
binding.route_revision = Some(revision.to_string()); - 1551
} - 1552
- 1553
#[cfg(test)] - 1554
pub(crate) fn route_revision_for_test(&self, key: &str) -> Option<String> { - 1555
self.bindings - 1556
.lock() - 1557
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1558
.get(key) - 1559
.and_then(|b| b.route_revision.clone()) - 1560
} - 1561
- 1562
#[cfg(test)] - 1563
pub(crate) fn session_id_for_test(&self, key: &str) -> Option<String> { - 1564
self.bindings - 1565
.lock() - 1566
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1567
.get(key) - 1568
.and_then(|b| b.session_id.clone()) - 1569
} - 1570
- 1571
/// Revoke an allowed entry: removes it from the store entirely (a - 1572
/// future message from that key starts a fresh pending review, not a - 1573
/// stale "denied" record masquerading as an audit trail). - 1574
pub(crate) fn allowlist_revoke(&self, core: &Core, key: &str) -> bool { - 1575
if self.allowlist_get(key).map(|e| e.status) != Some(AllowlistStatus::Allowed) { - 1576
return false; - 1577
} - 1578
let removed = self - 1579
.allowlist - 1580
.lock() - 1581
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1582
.remove(key) - 1583
.is_some(); - 1584
if removed { - 1585
persist_allowlist(core, self); - 1586
} - 1587
removed - 1588
} - 1589
} - 1590
- 1591
/// What a channel's permission mode actually resolves to, for display and - 1592
/// for the approve/patch audit trail. - 1593
pub(crate) struct ResolvedPermission { - 1594
/// The target workspace's own configured mode — the outermost ceiling. - 1595
pub workspace_mode: vak_config::PermissionMode, - 1596
/// The bot tier's pin, when the chat inherits from a bot that has one. - 1597
/// `None` means no bot tier applies, not "the bot allows everything". - 1598
pub bot_mode: Option<vak_config::PermissionMode>, - 1599
/// What the entry asked for, if anything. - 1600
pub requested: Option<vak_config::PermissionMode>, - 1601
/// What the channel actually gets, folded the same way `core_for_entry` - 1602
/// folds it: chat pin capped by bot pin, then capped by the workspace. - 1603
pub effective: vak_config::PermissionMode, - 1604
} - 1605
- 1606
impl ResolvedPermission { - 1607
/// True when a pin asked for more than the layers above it allow and - 1608
/// was reduced. This is the condition worth an audit-log entry. - 1609
/// - 1610
/// It covers the bot tier too: a bot pinned narrower than its chat - 1611
/// reduces that chat just as surely as the workspace does, and reporting - 1612
/// only the workspace cap is what let the console show a chat as wider - 1613
/// than it actually ran. - 1614
pub fn was_capped(&self) -> bool { - 1615
matches!(self.requested, Some(r) if r != self.effective) - 1616
|| matches!( - 1617
(self.bot_mode, self.requested), - 1618
(Some(bot), None) if bot != self.effective - 1619
) - 1620
} - 1621
} - 1622
- 1623
/// Read-only mirror of the cap dispatch enforces, for the admin surface. - 1624
/// Shares `PermissionMode::capped_by` with the pool so the number the - 1625
/// console shows is derived the same way as the one dispatch pins. - 1626
/// - 1627
/// It must fold the SAME three tiers `GatewayState::core_for_entry` folds: - 1628
/// chat pin capped by bot pin, then capped by the workspace. Leaving the - 1629
/// bot tier out — as this did — meant a bot pinned to `read-only` under a - 1630
/// chat pinned to `workspace-write` ran read-only and was reported as - 1631
/// workspace-write, and a chat with no pin under a bot that had one was - 1632
/// reported as the workspace's mode instead of the bot's. Showing a channel - 1633
/// as wider than it runs is the one direction of error that matters here. - 1634
/// - 1635
/// A workspace whose config fails to load falls back to the compiled - 1636
/// default (`WorkspaceWrite`), matching `vak_config`'s own layering; the - 1637
/// pool remains the authority at dispatch either way. - 1638
pub(crate) fn resolve_channel_permission( - 1639
workspace: &std::path::Path, - 1640
requested: Option<vak_config::PermissionMode>, - 1641
bot_mode: Option<vak_config::PermissionMode>, - 1642
) -> ResolvedPermission { - 1643
// Same trust the pool will use at dispatch. Reading with `true` here - 1644
// while the pool read the marker store would put the console back to - 1645
// reporting a mode no run would get. - 1646
let workspace_mode = - 1647
vak_config::load_with_trust(workspace, vak_core::trust::is_trusted(workspace)) - 1648
.map(|c| c.permission_mode) - 1649
.unwrap_or_default(); - 1650
// Exactly `core_for_entry`'s fold, then the pool's workspace cap. - 1651
let pinned = match (requested, bot_mode) { - 1652
(Some(chat), Some(bot)) => Some(chat.capped_by(bot)), - 1653
(Some(chat), None) => Some(chat), - 1654
(None, bot) => bot, - 1655
}; - 1656
let effective = match pinned { - 1657
Some(mode) => mode.capped_by(workspace_mode), - 1658
None => workspace_mode, - 1659
}; - 1660
ResolvedPermission { - 1661
workspace_mode, - 1662
bot_mode, - 1663
requested, - 1664
effective, - 1665
} - 1666
} - 1667
- 1668
/// True when `added_at` parses as an RFC3339 stamp strictly older than - 1669
/// `cutoff`. An unparseable stamp is never treated as expired: a corrupt - 1670
/// timestamp must not silently auto-deny a live channel. - 1671
fn parse_added_at_before(added_at: &str, cutoff: chrono::DateTime<chrono::Utc>) -> bool { - 1672
chrono::DateTime::parse_from_rfc3339(added_at) - 1673
.map(|ts| ts.with_timezone(&chrono::Utc) < cutoff) - 1674
.unwrap_or(false) - 1675
} - 1676
- 1677
fn persist_bindings(core: &Core, gw: &GatewayState) { - 1678
let bindings = gw - 1679
.bindings - 1680
.lock() - 1681
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1682
.clone(); - 1683
let path = bindings_path(&core.shared_data_home()); - 1684
if let Some(parent) = path.parent() { - 1685
let _ = std::fs::create_dir_all(parent); - 1686
} - 1687
let file = BindingsFile { - 1688
version: 2, - 1689
bindings, - 1690
}; - 1691
if let Ok(json) = serde_json::to_string_pretty(&file) { - 1692
let temp = path.with_extension(format!("json.{}.tmp", std::process::id())); - 1693
if std::fs::write(&temp, json).is_ok() { - 1694
let _ = std::fs::rename(temp, path); - 1695
} - 1696
} - 1697
} - 1698
- 1699
fn persist_allowlist(core: &Core, gw: &GatewayState) { - 1700
let entries = gw - 1701
.allowlist - 1702
.lock() - 1703
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1704
.clone(); - 1705
let path = allowlist_path(&core.shared_data_home()); - 1706
write_allowlist_file(&path, &entries); - 1707
} - 1708
- 1709
fn persist_bots(core: &Core, gw: &GatewayState) { - 1710
let bots = gw - 1711
.bots - 1712
.lock() - 1713
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1714
.clone(); - 1715
persist_bots_map(&bots_path(&core.shared_data_home()), &bots); - 1716
} - 1717
- 1718
/// Atomic temp-file+rename write, same pattern as `write_allowlist_file`. - 1719
/// Free function (not a `GatewayState` method) so the one-time migration in - 1720
/// `GatewayState::load` can call it before a `GatewayState` exists. - 1721
fn persist_bots_map(path: &std::path::Path, bots: &HashMap<String, Bot>) { - 1722
if let Some(parent) = path.parent() { - 1723
let _ = std::fs::create_dir_all(parent); - 1724
} - 1725
let mut sorted: Vec<Bot> = bots.values().cloned().collect(); - 1726
sorted.sort_by(|a, b| a.id.cmp(&b.id)); - 1727
let file = BotsFile { - 1728
schema: 1, - 1729
bots: sorted, - 1730
}; - 1731
if let Ok(json) = serde_json::to_string_pretty(&file) { - 1732
let temp = path.with_extension(format!("json.{}.tmp", std::process::id())); - 1733
if std::fs::write(&temp, json).is_ok() { - 1734
let _ = std::fs::rename(temp, path); - 1735
} - 1736
} - 1737
} - 1738
- 1739
/// Atomic temp-file+rename write, same pattern as `persist_bindings`. - 1740
fn write_allowlist_file(path: &std::path::Path, entries: &HashMap<String, AllowlistEntry>) { - 1741
if let Some(parent) = path.parent() { - 1742
let _ = std::fs::create_dir_all(parent); - 1743
} - 1744
let mut sorted: Vec<AllowlistEntry> = entries.values().cloned().collect(); - 1745
sorted.sort_by(|a, b| a.key.cmp(&b.key)); - 1746
let file = AllowlistFile { - 1747
schema: 1, - 1748
entries: sorted, - 1749
}; - 1750
if let Ok(json) = serde_json::to_string_pretty(&file) { - 1751
let temp = path.with_extension(format!("json.{}.tmp", std::process::id())); - 1752
if std::fs::write(&temp, json).is_ok() { - 1753
let _ = std::fs::rename(temp, path); - 1754
} - 1755
} - 1756
} - 1757
- 1758
pub fn routes() -> Router<AppState> { - 1759
Router::new() - 1760
.route( - 1761
"/gateway/inbound", - 1762
axum::routing::post(gateway_inbound) - 1763
.layer(axum::extract::DefaultBodyLimit::max(INBOUND_BODY_MAX_BYTES)), - 1764
) - 1765
.route("/gateway/status", axum::routing::get(gateway_status)) - 1766
.route( - 1767
"/gateway/bindings/{key}", - 1768
axum::routing::delete(gateway_unbind), - 1769
) - 1770
} - 1771
- 1772
// ---- Inbound ---------------------------------------------------------------- - 1773
- 1774
#[derive(serde::Deserialize)] - 1775
struct InboundBody { - 1776
surface: String, - 1777
chat: String, - 1778
/// Who sent this, when the adapter knows (a Telegram @user, a webhook - 1779
/// identity). Typed since G1 groundwork: it is recorded on deliveries - 1780
/// and attributed on queued turns instead of being silently dropped. - 1781
#[serde(default)] - 1782
sender: Option<String>, - 1783
text: String, - 1784
#[serde(default)] - 1785
wait: bool, - 1786
/// Base64 images appended to the prompt as vision content. - 1787
#[serde(default)] - 1788
attachments: Vec<InboundAttachment>, - 1789
#[serde(default)] - 1790
capabilities: Option<crate::delivery::RequestedCapabilities>, - 1791
/// See [`InboundRequest::bot_id`]. - 1792
#[serde(default)] - 1793
bot_id: Option<String>, - 1794
/// Caller-provided idempotency key. Repeating it returns the original - 1795
/// admission without dispatching a second model turn. - 1796
#[serde(default)] - 1797
request_id: Option<String>, - 1798
} - 1799
- 1800
#[derive(serde::Deserialize)] - 1801
struct InboundAttachment { - 1802
/// MIME type; defaults to image/png for Telegram-style senders. - 1803
#[serde(default = "default_image_mime")] - 1804
mime: String, - 1805
data: String, - 1806
/// Original filename, when the channel knows it (documents only). - 1807
#[serde(default)] - 1808
filename: Option<String>, - 1809
/// "image" (default, vision content) or "document": small text is - 1810
/// inlined, anything else is saved to the workspace inbox and named. - 1811
#[serde(default = "default_attachment_kind")] - 1812
kind: String, - 1813
#[serde(default)] - 1814
error: Option<String>, - 1815
} - 1816
- 1817
fn default_image_mime() -> String { - 1818
"image/png".into() - 1819
} - 1820
- 1821
fn default_attachment_kind() -> String { - 1822
"image".into() - 1823
} - 1824
- 1825
/// The largest document a channel may hand the gateway, shared by every - 1826
/// bridge so a file is never downloaded by one side and dropped by the - 1827
/// other. The gateway's JSON body limit (2 MiB, base64 inflates by a third) - 1828
/// bounds it. - 1829
pub(crate) const INBOUND_DOCUMENT_MAX_BYTES: usize = 20 * 1024 * 1024; - 1830
- 1831
/// `/gateway/inbound`'s body limit: a document at the limit, base64-encoded, - 1832
/// with room for the rest of the request. - 1833
const INBOUND_BODY_MAX_BYTES: usize = INBOUND_DOCUMENT_MAX_BYTES / 3 * 4 + 1024 * 1024; - 1834
- 1835
/// Text documents at or under this size are inlined in the prompt; larger - 1836
/// text, and every non-text file, is saved to the workspace inbox instead. - 1837
const DOCUMENT_INLINE_MAX_BYTES: usize = 64 * 1024; - 1838
- 1839
/// What a channel turn answers: its text, and each workspace file the turn - 1840
/// drafted with `office_apply`, for the channel to get back. - 1841
pub(crate) struct ChatReply { - 1842
pub(crate) text: String, - 1843
pub(crate) drafts: Vec<TurnDraft>, - 1844
} - 1845
- 1846
/// The latest draft this turn made of one workspace file. - 1847
pub(crate) struct TurnDraft { - 1848
/// Workspace-relative, as the call named it. - 1849
path: String, - 1850
draft: std::path::PathBuf, - 1851
} - 1852
- 1853
/// Each workspace file this turn's successful `office_apply` calls drafted, - 1854
/// with the last draft of it: an `office_apply` call's draft is at - 1855
/// `.vak/scratch/<agent>/<call id>/<path>`, the convention Review's lineage - 1856
/// relies on too. - 1857
fn turn_drafts(log: &vak_session::SessionLog, workspace: &std::path::Path) -> Vec<TurnDraft> { - 1858
let Some(directive) = log.latest_directive_entry_id() else { - 1859
return Vec::new(); - 1860
}; - 1861
let agent = log - 1862
.header() - 1863
.and_then(|header| header.agent.as_ref().map(|agent| agent.id.clone())) - 1864
.unwrap_or_else(|| "vak".into()); - 1865
let mut in_turn = false; - 1866
let mut calls: Vec<(String, String)> = Vec::new(); - 1867
let mut succeeded = std::collections::HashSet::new(); - 1868
for (entry_id, message) in log.message_chain() { - 1869
in_turn |= entry_id == directive; - 1870
if !in_turn { - 1871
continue; - 1872
} - 1873
for block in &message.content { - 1874
match block { - 1875
vak_llm::ContentBlock::ToolUse { id, name, input } if name == "office_apply" => { - 1876
if let Some(path) = input.get("path").and_then(serde_json::Value::as_str) { - 1877
calls.push((id.clone(), path.trim().to_string())); - 1878
} - 1879
} - 1880
vak_llm::ContentBlock::ToolResult { - 1881
tool_use_id, - 1882
is_error: false, - 1883
.. - 1884
} => { - 1885
succeeded.insert(tool_use_id.clone()); - 1886
} - 1887
_ => {} - 1888
} - 1889
} - 1890
} - 1891
let mut drafts: Vec<TurnDraft> = Vec::new(); - 1892
for (id, path) in calls.into_iter().filter(|(id, _)| succeeded.contains(id)) { - 1893
let draft = workspace - 1894
.join(".vak") - 1895
.join("scratch") - 1896
.join(&agent) - 1897
.join(&id) - 1898
.join(&path); - 1899
if !draft.is_file() { - 1900
continue; - 1901
} - 1902
drafts.retain(|earlier| earlier.path != path); - 1903
drafts.push(TurnDraft { path, draft }); - 1904
} - 1905
drafts - 1906
} - 1907
- 1908
/// The largest draft sent back on a channel; Telegram's bots may send up to - 1909
/// 50 MB, and a document this large is better opened in Vakyartha. - 1910
const RETURN_FILE_MAX_BYTES: u64 = 20 * 1024 * 1024; - 1911
- 1912
/// Each draft the turn made, for a channel that takes files: its bytes and a - 1913
/// caption saying what changed (from the worker's semantic diff), under the - 1914
/// name the person knows it by. A draft that carries a sensitivity label is - 1915
/// not sent (labels only narrow where a file goes); a channel that takes no - 1916
/// files, or a draft too large, gets a line saying where the file is. - 1917
async fn return_drafts( - 1918
core: &Core, - 1919
mut text: String, - 1920
drafts: &[TurnDraft], - 1921
accepts_files: bool, - 1922
) -> (String, Vec<serde_json::Value>) { - 1923
use base64::Engine as _; - 1924
let worker = core.tool_worker_exe(); - 1925
let mut files = Vec::new(); - 1926
let mut notes = Vec::new(); - 1927
for draft in drafts { - 1928
let name = inbox::display_name( - 1929
std::path::Path::new(&draft.path) - 1930
.file_name() - 1931
.and_then(|name| name.to_str()) - 1932
.unwrap_or(&draft.path), - 1933
); - 1934
let facts = vak_tools::broker::office_project( - 1935
&worker, - 1936
&draft.draft, - 1937
vak_tools::broker::OfficeView::Facts, - 1938
) - 1939
.await; - 1940
let labels: Vec<String> = facts - 1941
.as_ref() - 1942
.ok() - 1943
.and_then(|facts| facts.get("sensitivity_labels")) - 1944
.and_then(|labels| serde_json::from_value(labels.clone()).ok()) - 1945
.unwrap_or_default(); - 1946
if !labels.is_empty() { - 1947
notes.push(format!( - 1948
"{name} carries the sensitivity label {}, so it is not sent on this channel; review the draft in Vakyartha.", - 1949
labels.join(", ") - 1950
)); - 1951
continue; - 1952
} - 1953
let size = std::fs::metadata(&draft.draft) - 1954
.map(|metadata| metadata.len()) - 1955
.unwrap_or(u64::MAX); - 1956
if !accepts_files || size > RETURN_FILE_MAX_BYTES { - 1957
notes.push(format!( - 1958
"The updated {name} is ready in Vakyartha for review; this channel does not receive it." - 1959
)); - 1960
continue; - 1961
} - 1962
let current = core.cwd().join(&draft.path); - 1963
let review = vak_tools::broker::office_review( - 1964
&worker, - 1965
current.is_file().then_some(current.as_path()), - 1966
&draft.draft, - 1967
None, - 1968
) - 1969
.await; - 1970
let summary = review - 1971
.as_ref() - 1972
.ok() - 1973
.and_then(|review| review.get("summary")) - 1974
.and_then(|summary| serde_json::from_value::<Vec<String>>(summary.clone()).ok()) - 1975
.filter(|summary| !summary.is_empty()) - 1976
.map(|summary| summary.join("; ")) - 1977
.unwrap_or_else(|| "no visible change".into()); - 1978
let Ok(bytes) = std::fs::read(&draft.draft) else { - 1979
notes.push(format!("The updated {name} could not be read to send it.")); - 1980
continue; - 1981
}; - 1982
files.push(serde_json::json!({ - 1983
"name": name, - 1984
"mime": office_mime(&name), - 1985
"data": base64::engine::general_purpose::STANDARD.encode(bytes), - 1986
"caption": format!("Updated {name}: {summary}"), - 1987
})); - 1988
} - 1989
if !notes.is_empty() { - 1990
text = format!("{}\n\n{}", text.trim_end(), notes.join("\n")); - 1991
} - 1992
(text, files) - 1993
} - 1994
- 1995
fn office_mime(name: &str) -> &'static str { - 1996
match name - 1997
.rsplit('.') - 1998
.next() - 1999
.map(str::to_ascii_lowercase) - 2000
.as_deref()
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.