- 1
//! Multi-tenant `Core` pool (docs/design/34-channel-onboarding.md Phase 2). - 2
//! - 3
//! `GatewayState` used to run one `Core`, fixed at process start, for every - 4
//! channel regardless of which workspace an allowlist entry pointed at — a - 5
//! channel's `workspace` field only picked provider/model, never the - 6
//! sandbox, permission mode, or session ledger that workspace's own `Core` - 7
//! would resolve. `CorePool` makes that real: a canonical-workspace-path -> - 8
//! lazily-started `Core` map, with the gateway's own default workspace - 9
//! pinned permanently and every other entry idle-evicted / capacity-bounded. - 10
//! - 11
//! Security note: `resolve_at` always goes through `Core::new_with_trust`, - 12
//! the exact same trust/permission/sandbox resolution a local `vak` run in - 13
//! that workspace gets — pooling must never grant a channel more access - 14
//! than a local session already has. The allowlist approval step (Phase 1) - 15
//! is what gates a channel reaching a workspace at all; this module does - 16
//! not weaken that boundary. - 17
//! - 18
//! Per-channel permission overrides (docs/design/34 "Per-channel - 19
//! permission mode") make the pool key a *pair*: `(workspace, override)`. - 20
//! Two channels sharing a workspace but wanting different trust levels get - 21
//! two distinct `Core` instances, because a `Core`'s permission mode is a - 22
//! single piece of shared mutable state — handing one `Core` to two - 23
//! channels with different intended modes would let whichever channel - 24
//! resolved first dictate the other's permissions. The override itself is - 25
//! capped in `apply_permission_override` and can only ever reduce, never - 26
//! raise, what the workspace's own config already grants. - 27
- 28
use std::collections::HashMap; - 29
use std::path::{Path, PathBuf}; - 30
use std::sync::Mutex; - 31
use std::time::{Duration, Instant}; - 32
- 33
use vak_config::PermissionMode; - 34
use vak_core::Core; - 35
- 36
/// Pool identity. `None` in the second slot is "inherit this workspace's - 37
/// own configured mode" — today's behavior and the key the gateway's own - 38
/// default entry always uses, so an un-overridden channel keeps sharing - 39
/// the exact `Core` it shares now. - 40
type PoolKey = (PathBuf, Option<PermissionMode>, String); - 41
- 42
struct PooledEntry { - 43
core: Core, - 44
last_active: Instant, - 45
} - 46
- 47
/// One workspace's warm/cold state, for the admin UI's live indicator. - 48
pub struct PoolStatusEntry { - 49
pub workspace: PathBuf, - 50
pub is_default: bool, - 51
pub idle_secs: u64, - 52
/// The permission override this pooled instance was keyed by, if any, - 53
/// so the panel can tell two same-workspace instances apart. - 54
pub permission_override: Option<PermissionMode>, - 55
/// What this instance actually resolved to after capping. - 56
pub effective_permission_mode: PermissionMode, - 57
} - 58
- 59
/// Clamp a requested per-channel override to what the workspace's own - 60
/// resolved configuration already grants, and pin the result onto `core`. - 61
/// - 62
/// `core` must be freshly constructed by `Core::new_with_trust`, so its - 63
/// `effective_permission_mode()` is precisely the workspace's own - 64
/// configured mode — the same value a local `vak` run in that workspace - 65
/// would get. That value is the ceiling. `capped_by` is a `min`, so the - 66
/// result is provably never more permissive than the ceiling; there is no - 67
/// code path here that pins a mode above it. - 68
/// - 69
/// Returns `Some((requested, capped))` when the request had to be reduced, - 70
/// so the caller can record it in the audit log. - 71
fn apply_permission_override( - 72
core: &Core, - 73
requested: PermissionMode, - 74
) -> Option<(PermissionMode, PermissionMode)> { - 75
let workspace_ceiling = core.effective_permission_mode(); - 76
let capped = requested.capped_by(workspace_ceiling); - 77
core.set_permission_mode(capped); - 78
(capped != requested).then_some((requested, workspace_ceiling)) - 79
} - 80
- 81
/// Canonicalize the same way `checkpoints.rs` already does: best effort, - 82
/// falling back to the given path unchanged when the filesystem can't - 83
/// resolve it (a workspace that doesn't exist yet, or a test tempdir that - 84
/// races cleanup) rather than failing pool lookups outright. - 85
fn canonical(path: &Path) -> PathBuf { - 86
path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) - 87
} - 88
- 89
pub struct CorePool { - 90
default_workspace: PathBuf, - 91
agent_network: vak_core::agent_network::AgentNetworkBroker, - 92
entries: Mutex<HashMap<PoolKey, PooledEntry>>, - 93
max: usize, - 94
idle: Duration, - 95
} - 96
- 97
impl CorePool { - 98
/// `default_core` seeds the pool's permanent entry for the gateway's - 99
/// own workspace — this replaces the old bare `state.core` field. - 100
pub fn new(default_core: Core, max: usize, idle: Duration) -> Self { - 101
let default_workspace = canonical(default_core.cwd()); - 102
let agent_network = default_core.agent_network_broker(); - 103
let mut entries = HashMap::new(); - 104
entries.insert( - 105
(default_workspace.clone(), None, String::new()), - 106
PooledEntry { - 107
core: default_core, - 108
last_active: Instant::now(), - 109
}, - 110
); - 111
CorePool { - 112
default_workspace, - 113
agent_network, - 114
entries: Mutex::new(entries), - 115
max: max.max(1), - 116
idle, - 117
} - 118
} - 119
- 120
/// The gateway's own workspace — the pool's permanent, never-evicted - 121
/// entry. Exposed for tests and any future caller that needs to tell - 122
/// "the default" apart from "an approved channel's own workspace" - 123
/// without re-deriving it from a `Core`. - 124
#[allow(dead_code)] - 125
pub fn default_workspace(&self) -> &Path { - 126
&self.default_workspace - 127
} - 128
- 129
/// Resolve (lazily starting) the `Core` for `workspace`, evicting idle - 130
/// entries and enforcing the cap along the way. `now` is threaded - 131
/// explicitly rather than read from `Instant::now()` internally so - 132
/// tests can drive eviction deterministically without sleeping. - 133
/// - 134
/// `permission_override` is the channel's requested permission mode - 135
/// (`None` = inherit the workspace's own). It is part of the pool key, - 136
/// so a channel asking for a different mode never shares an instance - 137
/// with one asking for another, and it is capped to the workspace's - 138
/// own resolved mode before being pinned onto the fresh `Core`. - 139
#[allow(dead_code)] - 140
pub fn resolve_at( - 141
&self, - 142
workspace: &Path, - 143
permission_override: Option<PermissionMode>, - 144
now: Instant, - 145
) -> Result<Core, String> { - 146
self.resolve_at_with_policy( - 147
workspace, - 148
permission_override, - 149
vak_config::ChannelPolicy::default(), - 150
now, - 151
) - 152
} - 153
- 154
pub fn resolve_at_with_policy( - 155
&self, - 156
workspace: &Path, - 157
permission_override: Option<PermissionMode>, - 158
policy: vak_config::ChannelPolicy, - 159
now: Instant, - 160
) -> Result<Core, String> { - 161
let policy_key = if policy == vak_config::ChannelPolicy::default() { - 162
String::new() - 163
} else { - 164
serde_json::to_string(&policy).map_err(|e| e.to_string())? - 165
}; - 166
let key: PoolKey = (canonical(workspace), permission_override, policy_key); - 167
{ - 168
let mut entries = self.entries.lock().unwrap_or_else(|p| p.into_inner()); - 169
self.evict_idle_locked(&mut entries, now); - 170
if let Some(entry) = entries.get_mut(&key) { - 171
// A warm entry may outlive a persisted workspace downgrade. - 172
// Recheck only the security ceiling on cache hits: full - 173
// preference refresh would take unrelated capability locks - 174
// while an active turn is running. - 175
if let Err(error) = entry.core.enforce_persisted_permission_ceiling() { - 176
return Err(format!( - 177
"could not recheck workspace permission ceiling: {error}" - 178
)); - 179
} - 180
entry.last_active = now; - 181
return Ok(entry.core.clone()); - 182
} - 183
} - 184
// Trust is read from the one marker store, not assumed. This used to - 185
// pass `true` unconditionally, so a workspace whose trust prompt an - 186
// operator had declined in a terminal still had its hooks, MCP - 187
// servers, `permission_mode` and secret scope applied the moment a chat - 188
// routed a turn into it — two answers to "is this workspace - 189
// trusted?", which is exactly what `vak_core::trust` exists to end. - 190
// - 191
// Approving a channel's workspace in the admin console records - 192
// trust (see `admin::note_workspace_trust`), so the operator-driven - 193
// path this replaced still resolves to `true` — it now does so - 194
// because someone decided, not because the code assumed. - 195
let trusted = vak_core::trust::is_trusted(&key.0); - 196
// Start outside the lock: `Core::new_with_trust` does filesystem IO - 197
// (config load) and must not hold up every other pool lookup. - 198
let core = Core::new_with_trust(key.0.clone(), trusted) - 199
.map(|c| c.with_surface(vak_core::Surface::Server)) - 200
.map_err(|e| e.to_string())?; - 201
core.set_agent_network_broker(self.agent_network.clone()); - 202
if policy != vak_config::ChannelPolicy::default() { - 203
core.apply_channel_policy(policy); - 204
} - 205
// Cap and pin before the instance is ever published to the map, so - 206
// no other request can observe it at the un-capped default. - 207
if let Some(requested) = permission_override - 208
&& let Some((requested, ceiling)) = apply_permission_override(&core, requested) - 209
{ - 210
vak_core::security_events::record( - 211
&core.sessions_home(), - 212
vak_core::security_events::EventKind::PermissionCapped, - 213
"permission_capped", - 214
&format!( - 215
"workspace={} requested={} capped_to={}", - 216
key.0.display(), - 217
requested.as_str(), - 218
ceiling.as_str() - 219
), - 220
None, - 221
); - 222
} - 223
let mut entries = self.entries.lock().unwrap_or_else(|p| p.into_inner()); - 224
// Someone else may have started the same workspace while we didn't - 225
// hold the lock; keep whichever is already resident to avoid a - 226
// second live Core silently replacing the one other requests hold. - 227
if let Some(entry) = entries.get_mut(&key) { - 228
entry.last_active = now; - 229
return Ok(entry.core.clone()); - 230
} - 231
if entries.len() >= self.max { - 232
self.evict_oldest_idle_locked(&mut entries); - 233
} - 234
entries.insert( - 235
key, - 236
PooledEntry { - 237
core: core.clone(), - 238
last_active: now, - 239
}, - 240
); - 241
Ok(core) - 242
} - 243
- 244
fn evict_idle_locked(&self, entries: &mut HashMap<PoolKey, PooledEntry>, now: Instant) { - 245
let default = self.default_key(); - 246
let idle = self.idle; - 247
entries.retain(|key, entry| { - 248
*key == default || now.saturating_duration_since(entry.last_active) < idle - 249
}); - 250
} - 251
- 252
/// The permanent entry's key: the gateway's own workspace with no - 253
/// permission override. A same-workspace *override* entry is an - 254
/// ordinary evictable entry — it is not the gateway's own Core. - 255
fn default_key(&self) -> PoolKey { - 256
(self.default_workspace.clone(), None, String::new()) - 257
} - 258
- 259
/// Drop every pooled instance except the gateway's own, so the next - 260
/// inbound message on each channel rebuilds against current config. - 261
/// - 262
/// `apply_permission_override` reads a workspace's ceiling exactly once, - 263
/// when the entry is constructed. Persisting a narrower mode therefore - 264
/// took effect for the console immediately and for every warm channel - 265
/// only after its idle window expired — up to half an hour of chats - 266
/// still running under a ceiling the operator had already revoked. The - 267
/// answer is not to re-derive each entry's cap in place (that would be a - 268
/// second copy of the capping rule) but to discard the entries, so the - 269
/// one place that computes a ceiling runs again. - 270
/// - 271
/// Returns how many were dropped, for the audit line. - 272
pub fn invalidate_pooled(&self) -> usize { - 273
let mut entries = self.entries.lock().unwrap_or_else(|p| p.into_inner()); - 274
let default = self.default_key(); - 275
let before = entries.len(); - 276
entries.retain(|key, _| *key == default); - 277
before - entries.len() - 278
} - 279
- 280
/// Cap enforcement: drop the least-recently-active non-default entry. - 281
/// The default workspace is never evicted, matching `GatewayState`'s - 282
/// old single-`Core` behavior for the gateway's own cwd. - 283
fn evict_oldest_idle_locked(&self, entries: &mut HashMap<PoolKey, PooledEntry>) { - 284
let default = self.default_key(); - 285
if let Some(oldest) = entries - 286
.iter() - 287
.filter(|(key, _)| **key != default) - 288
.min_by_key(|(_, entry)| entry.last_active) - 289
.map(|(key, _)| key.clone()) - 290
{ - 291
entries.remove(&oldest); - 292
} - 293
} - 294
- 295
/// Snapshot of every currently-warm workspace, for the admin UI's - 296
/// pool-status panel. `now` is explicit for the same testability reason - 297
/// as `resolve_at`. - 298
pub fn snapshot_at(&self, now: Instant) -> Vec<PoolStatusEntry> { - 299
let entries = self.entries.lock().unwrap_or_else(|p| p.into_inner()); - 300
let default = self.default_key(); - 301
let mut out: Vec<PoolStatusEntry> = entries - 302
.iter() - 303
.map(|(key, entry)| PoolStatusEntry { - 304
workspace: key.0.clone(), - 305
is_default: *key == default, - 306
idle_secs: now.saturating_duration_since(entry.last_active).as_secs(), - 307
permission_override: key.1, - 308
effective_permission_mode: entry.core.effective_permission_mode(), - 309
}) - 310
.collect(); - 311
out.sort_by(|a, b| { - 312
a.workspace.cmp(&b.workspace).then_with(|| { - 313
a.permission_override - 314
.map(|m| m.rank()) - 315
.cmp(&b.permission_override.map(|m| m.rank())) - 316
}) - 317
}); - 318
out - 319
} - 320
- 321
#[cfg(test)] - 322
pub(crate) fn len(&self) -> usize { - 323
self.entries.lock().unwrap_or_else(|p| p.into_inner()).len() - 324
} - 325
} - 326
- 327
#[cfg(test)] - 328
#[allow(clippy::unwrap_used, clippy::expect_used)] - 329
mod tests { - 330
use super::*; - 331
use std::time::Duration; - 332
- 333
fn test_core(dir: &std::path::Path) -> Core { - 334
vak_config::paths::isolate_home_for_tests(); - 335
Core::new_with_trust(dir.to_path_buf(), true).expect("core") - 336
} - 337
- 338
/// Write a workspace config that fixes the workspace's own permission - 339
/// mode — the ceiling every channel override is capped against — and - 340
/// record the operator's trust decision for it. - 341
/// - 342
/// The trust marker is not incidental setup. `permission_mode` is a - 343
/// privileged key: `load_with_trust` strips it from an untrusted - 344
/// project, and the pool now reads the real marker store rather than - 345
/// assuming trust. A test that skipped this would be asserting the cap - 346
/// against a ceiling no run would ever see. - 347
fn workspace_with_mode(dir: &std::path::Path, mode: &str) { - 348
let vak = dir.join(".vak"); - 349
std::fs::create_dir_all(&vak).expect("mkdir .vak"); - 350
std::fs::write( - 351
vak.join("config.toml"), - 352
format!("permission_mode = \"{mode}\"\n"), - 353
) - 354
.expect("write config"); - 355
vak_config::paths::isolate_home_for_tests(); - 356
vak_core::trust::record(dir).expect("record trust"); - 357
} - 358
- 359
/// A narrowed mode used to reach warm channels only when their idle - 360
/// window expired — up to half an hour of chats running under a ceiling - 361
/// the operator had already revoked. - 362
#[test] - 363
fn invalidating_the_pool_drops_channel_instances_and_keeps_the_default() { - 364
let default_dir = tempfile::tempdir().unwrap(); - 365
let a = tempfile::tempdir().unwrap(); - 366
let b = tempfile::tempdir().unwrap(); - 367
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 368
pool.resolve_at(a.path(), None, Instant::now()).unwrap(); - 369
pool.resolve_at(b.path(), None, Instant::now()).unwrap(); - 370
assert_eq!(pool.len(), 3); - 371
- 372
assert_eq!(pool.invalidate_pooled(), 2); - 373
assert_eq!(pool.len(), 1, "the gateway's own Core is never dropped"); - 374
assert!( - 375
pool.resolve_at(a.path(), None, Instant::now()).is_ok(), - 376
"and the next message rebuilds against current config" - 377
); - 378
} - 379
- 380
/// The pool reads the one trust marker store rather than assuming trust. - 381
/// A workspace nobody has vouched for gets its privileged keys stripped - 382
/// here exactly as it would in a terminal. - 383
#[test] - 384
fn an_untrusted_workspace_does_not_get_its_privileged_keys() { - 385
let default_dir = tempfile::tempdir().unwrap(); - 386
let ws = tempfile::tempdir().unwrap(); - 387
vak_config::paths::isolate_home_for_tests(); - 388
std::fs::create_dir_all(ws.path().join(".vak")).unwrap(); - 389
std::fs::write( - 390
ws.path().join(".vak/config.toml"), - 391
"permission_mode = \"full-access\"\n", - 392
) - 393
.unwrap(); - 394
// Deliberately NOT recording trust. - 395
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 396
- 397
// Compared against what the SAME workspace resolves to with its - 398
// project file ignored, rather than against a hardcoded default: the - 399
// global layer is shared by this whole test binary, so the baseline - 400
// is whatever it happens to be. The claim under test is only that - 401
// the untrusted project's own `full-access` is not applied. - 402
let baseline = Core::new_with_trust(ws.path().to_path_buf(), false) - 403
.unwrap() - 404
.effective_permission_mode(); - 405
let core = pool.resolve_at(ws.path(), None, Instant::now()).unwrap(); - 406
assert_eq!( - 407
core.effective_permission_mode(), - 408
baseline, - 409
"an unvouched project must not configure itself into full access" - 410
); - 411
- 412
// And the marker is what makes the difference: vouch for it and the - 413
// project's own mode applies. - 414
vak_core::trust::record(ws.path()).unwrap(); - 415
let trusted = Core::new_with_trust(ws.path().to_path_buf(), true).unwrap(); - 416
assert_eq!( - 417
trusted.effective_permission_mode(), - 418
PermissionMode::FullAccess - 419
); - 420
} - 421
- 422
#[test] - 423
fn no_override_inherits_the_workspace_mode_unchanged() { - 424
let default_dir = tempfile::tempdir().unwrap(); - 425
let ws = tempfile::tempdir().unwrap(); - 426
workspace_with_mode(ws.path(), "full-access"); - 427
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 428
- 429
let core = pool.resolve_at(ws.path(), None, Instant::now()).unwrap(); - 430
// Today's behavior, untouched: the workspace's own config wins. - 431
assert_eq!(core.effective_permission_mode(), PermissionMode::FullAccess); - 432
assert!(!core.permission_mode_runtime_pinned()); - 433
} - 434
- 435
#[test] - 436
fn override_at_or_below_the_workspace_mode_is_applied_exactly() { - 437
let default_dir = tempfile::tempdir().unwrap(); - 438
let ws = tempfile::tempdir().unwrap(); - 439
workspace_with_mode(ws.path(), "full-access"); - 440
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 441
- 442
for requested in [PermissionMode::ReadOnly, PermissionMode::WorkspaceWrite] { - 443
let core = pool - 444
.resolve_at(ws.path(), Some(requested), Instant::now()) - 445
.unwrap(); - 446
assert_eq!( - 447
core.effective_permission_mode(), - 448
requested, - 449
"a reduction to {requested:?} under full-access must apply verbatim" - 450
); - 451
} - 452
// And an override that exactly equals the ceiling is a no-op match. - 453
let core = pool - 454
.resolve_at(ws.path(), Some(PermissionMode::FullAccess), Instant::now()) - 455
.unwrap(); - 456
assert_eq!(core.effective_permission_mode(), PermissionMode::FullAccess); - 457
} - 458
- 459
/// The security property: an override asking for MORE than the - 460
/// workspace's own config grants is clamped to the workspace's mode, - 461
/// and the reduction is written to the audit log. - 462
#[test] - 463
fn override_above_the_workspace_mode_is_capped_and_audited() { - 464
let default_dir = tempfile::tempdir().unwrap(); - 465
let ws = tempfile::tempdir().unwrap(); - 466
workspace_with_mode(ws.path(), "read-only"); - 467
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 468
- 469
let core = pool - 470
.resolve_at(ws.path(), Some(PermissionMode::FullAccess), Instant::now()) - 471
.unwrap(); - 472
assert_eq!( - 473
core.effective_permission_mode(), - 474
PermissionMode::ReadOnly, - 475
"a full-access override on a read-only workspace must NOT escalate" - 476
); - 477
- 478
// `sessions_home` is process-wide here (these pool tests build - 479
// real `Core`s), so scope the assertion to this test's own unique - 480
// tempdir workspace rather than to the whole event log. - 481
let marker = format!("workspace={}", canonical(ws.path()).display()); - 482
let events = vak_core::security_events::list(&core.sessions_home(), 500); - 483
let capped: Vec<_> = events - 484
.iter() - 485
.filter(|e| { - 486
e.kind == vak_core::security_events::EventKind::PermissionCapped - 487
&& e.detail.contains(&marker) - 488
}) - 489
.collect(); - 490
assert_eq!(capped.len(), 1, "the silent reduction must be auditable"); - 491
assert!(capped[0].detail.contains("requested=full-access")); - 492
assert!(capped[0].detail.contains("capped_to=read-only")); - 493
- 494
// A workspace-write request on the same read-only workspace is - 495
// capped too — the ceiling is the config, not merely "not full". - 496
let core2 = pool - 497
.resolve_at( - 498
ws.path(), - 499
Some(PermissionMode::WorkspaceWrite), - 500
Instant::now(), - 501
) - 502
.unwrap(); - 503
assert_eq!(core2.effective_permission_mode(), PermissionMode::ReadOnly); - 504
} - 505
- 506
/// Two channels, one workspace, different overrides: they must never - 507
/// share a `Core`, because a `Core` carries exactly one permission - 508
/// mode and sharing would let the looser channel's mode leak into the - 509
/// tighter one (or vice versa, depending on who resolved first). - 510
#[test] - 511
fn same_workspace_different_overrides_get_distinct_cores() { - 512
let default_dir = tempfile::tempdir().unwrap(); - 513
let ws = tempfile::tempdir().unwrap(); - 514
workspace_with_mode(ws.path(), "full-access"); - 515
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 516
let t0 = Instant::now(); - 517
- 518
let tight = pool - 519
.resolve_at(ws.path(), Some(PermissionMode::ReadOnly), t0) - 520
.unwrap(); - 521
let loose = pool - 522
.resolve_at(ws.path(), Some(PermissionMode::FullAccess), t0) - 523
.unwrap(); - 524
let inherited = pool.resolve_at(ws.path(), None, t0).unwrap(); - 525
- 526
// Three distinct pooled instances for one workspace path. - 527
assert_eq!(pool.len(), 4); // default + the three above - 528
assert_eq!(tight.cwd(), loose.cwd()); - 529
- 530
// Neither leaks into the other, in either direction. - 531
assert_eq!(tight.effective_permission_mode(), PermissionMode::ReadOnly); - 532
assert_eq!( - 533
loose.effective_permission_mode(), - 534
PermissionMode::FullAccess - 535
); - 536
assert_eq!( - 537
inherited.effective_permission_mode(), - 538
PermissionMode::FullAccess - 539
); - 540
- 541
// Re-resolving the tight channel still yields the tight instance — - 542
// the looser resolution did not overwrite the cached entry. - 543
let tight_again = pool - 544
.resolve_at(ws.path(), Some(PermissionMode::ReadOnly), t0) - 545
.unwrap(); - 546
assert_eq!( - 547
tight_again.effective_permission_mode(), - 548
PermissionMode::ReadOnly - 549
); - 550
- 551
// The pool status panel can tell the instances apart. - 552
let snapshot = pool.snapshot_at(t0); - 553
let mut overrides: Vec<_> = snapshot - 554
.iter() - 555
.filter(|e| e.workspace == canonical(ws.path())) - 556
.map(|e| (e.permission_override, e.effective_permission_mode)) - 557
.collect(); - 558
overrides.sort_by_key(|(o, _)| o.map(|m| m.rank())); - 559
assert_eq!(overrides.len(), 3); - 560
} - 561
- 562
#[test] - 563
fn override_entries_are_evictable_but_the_default_key_is_not() { - 564
let default_dir = tempfile::tempdir().unwrap(); - 565
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(60)); - 566
let t0 = Instant::now(); - 567
// An override entry on the *default* workspace is an ordinary - 568
// evictable entry, not the permanent gateway Core. - 569
pool.resolve_at(default_dir.path(), Some(PermissionMode::ReadOnly), t0) - 570
.unwrap(); - 571
assert_eq!(pool.len(), 2); - 572
- 573
let other = tempfile::tempdir().unwrap(); - 574
pool.resolve_at(other.path(), None, t0 + Duration::from_secs(120)) - 575
.unwrap(); - 576
let snapshot = pool.snapshot_at(t0 + Duration::from_secs(120)); - 577
assert!(snapshot.iter().any(|e| e.is_default)); - 578
assert!( - 579
!snapshot - 580
.iter() - 581
.any(|e| e.permission_override == Some(PermissionMode::ReadOnly)) - 582
); - 583
} - 584
- 585
#[test] - 586
fn lazy_start_and_cache_hit() { - 587
let default_dir = tempfile::tempdir().unwrap(); - 588
let other_dir = tempfile::tempdir().unwrap(); - 589
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 590
let t0 = Instant::now(); - 591
assert_eq!(pool.len(), 1); // default only - 592
- 593
let first = pool.resolve_at(other_dir.path(), None, t0).expect("start"); - 594
assert_eq!(pool.len(), 2); - 595
- 596
let second = pool - 597
.resolve_at(other_dir.path(), None, t0 + Duration::from_secs(1)) - 598
.expect("cache hit"); - 599
// Same canonical workspace resolves to the same pooled Core - 600
// instance (Arc-backed clone), not a freshly started one. - 601
assert_eq!(first.cwd(), second.cwd()); - 602
assert_eq!(pool.len(), 2); - 603
} - 604
- 605
/// A warm pool entry is reused, but its persisted permission ceiling is - 606
/// rechecked on every cache hit. This security-only check avoids taking - 607
/// unrelated capability locks while an active turn is running and cancels - 608
/// the old Core permission lease before narrowing the resident mode. - 609
#[test] - 610
fn warm_pool_entry_rechecks_a_permission_mode_change_written_after_it_started() { - 611
let default_dir = tempfile::tempdir().unwrap(); - 612
let ws = tempfile::tempdir().unwrap(); - 613
workspace_with_mode(ws.path(), "full-access"); - 614
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 615
let t0 = Instant::now(); - 616
- 617
let warm = pool.resolve_at(ws.path(), None, t0).unwrap(); - 618
assert_eq!(warm.effective_permission_mode(), PermissionMode::FullAccess); - 619
- 620
// An operator downgrades the workspace to read-only — e.g. from the - 621
// admin console's Settings page — well within the 30-minute idle - 622
// window, on an otherwise-active channel that never goes idle. - 623
workspace_with_mode(ws.path(), "read-only"); - 624
- 625
let still_warm = pool - 626
.resolve_at(ws.path(), None, t0 + Duration::from_secs(5)) - 627
.unwrap(); - 628
assert_eq!( - 629
still_warm.effective_permission_mode(), - 630
PermissionMode::ReadOnly, - 631
"a warm channel must observe the persisted security ceiling" - 632
); - 633
} - 634
- 635
#[test] - 636
fn idle_eviction_after_configured_duration() { - 637
let default_dir = tempfile::tempdir().unwrap(); - 638
let other_dir = tempfile::tempdir().unwrap(); - 639
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(60)); - 640
let t0 = Instant::now(); - 641
pool.resolve_at(other_dir.path(), None, t0).unwrap(); - 642
assert_eq!(pool.len(), 2); - 643
- 644
// Still within the idle window: a lookup elsewhere must not evict it. - 645
let unrelated_dir = tempfile::tempdir().unwrap(); - 646
pool.resolve_at(unrelated_dir.path(), None, t0 + Duration::from_secs(30)) - 647
.unwrap(); - 648
assert_eq!(pool.len(), 3); - 649
- 650
// Past the idle window: the next lookup sweeps stale entries first. - 651
pool.resolve_at(unrelated_dir.path(), None, t0 + Duration::from_secs(120)) - 652
.unwrap(); - 653
let snapshot = pool.snapshot_at(t0 + Duration::from_secs(120)); - 654
let paths: Vec<_> = snapshot.iter().map(|e| e.workspace.clone()).collect(); - 655
assert!(paths.contains(&pool.default_workspace().to_path_buf())); - 656
assert!(!paths.contains(&canonical(other_dir.path()))); - 657
} - 658
- 659
#[test] - 660
fn default_workspace_never_evicted_by_idle_sweep() { - 661
let default_dir = tempfile::tempdir().unwrap(); - 662
let other_dir = tempfile::tempdir().unwrap(); - 663
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(5)); - 664
let t0 = Instant::now(); - 665
pool.resolve_at(other_dir.path(), None, t0).unwrap(); - 666
// Way past idle for everything, including the default. - 667
let far_future = t0 + Duration::from_secs(10_000); - 668
pool.resolve_at(other_dir.path(), None, far_future) - 669
.unwrap_or_else(|_| test_core(other_dir.path())); - 670
let snapshot = pool.snapshot_at(far_future); - 671
assert!( - 672
snapshot - 673
.iter() - 674
.any(|e| e.is_default && e.workspace == canonical(default_dir.path())) - 675
); - 676
} - 677
- 678
#[test] - 679
fn cap_enforcement_evicts_oldest_idle_not_default() { - 680
let default_dir = tempfile::tempdir().unwrap(); - 681
let pool = CorePool::new(test_core(default_dir.path()), 2, Duration::from_secs(1800)); - 682
let t0 = Instant::now(); - 683
- 684
let dir_a = tempfile::tempdir().unwrap(); - 685
let dir_b = tempfile::tempdir().unwrap(); - 686
// Cap is 2: default + one more fits; a second non-default entry - 687
// must evict the oldest-idle non-default one (dir_a), not default. - 688
pool.resolve_at(dir_a.path(), None, t0).unwrap(); - 689
assert_eq!(pool.len(), 2); - 690
pool.resolve_at(dir_b.path(), None, t0 + Duration::from_secs(10)) - 691
.unwrap(); - 692
assert_eq!(pool.len(), 2); - 693
- 694
let snapshot = pool.snapshot_at(t0 + Duration::from_secs(10)); - 695
let paths: Vec<_> = snapshot.iter().map(|e| e.workspace.clone()).collect(); - 696
assert!(paths.contains(&pool.default_workspace().to_path_buf())); - 697
assert!(paths.contains(&canonical(dir_b.path()))); - 698
assert!(!paths.contains(&canonical(dir_a.path()))); - 699
} - 700
- 701
#[test] - 702
fn distinct_channel_policies_get_distinct_pool_entries() { - 703
let default_dir = tempfile::tempdir().unwrap(); - 704
let pool = CorePool::new(test_core(default_dir.path()), 8, Duration::from_secs(1800)); - 705
let allow_tavily = vak_config::ChannelPolicy { - 706
mcp_allow: Some(vec!["tavily/*".into()]), - 707
..Default::default() - 708
}; - 709
let deny_mcp = vak_config::ChannelPolicy { - 710
mcp_allow: Some(Vec::new()), - 711
..Default::default() - 712
}; - 713
pool.resolve_at_with_policy(default_dir.path(), None, allow_tavily, Instant::now()) - 714
.unwrap(); - 715
pool.resolve_at_with_policy( - 716
default_dir.path(), - 717
None, - 718
deny_mcp, - 719
Instant::now() + Duration::from_secs(1), - 720
) - 721
.unwrap(); - 722
assert_eq!(pool.len(), 3); - 723
} - 724
} - 725
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.