- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
use axum::{ - 3
Router, - 4
body::Body, - 5
http::{Request, StatusCode}, - 6
}; - 7
use http_body_util::BodyExt; - 8
use serde_json::{Value, json}; - 9
use std::sync::{Arc, Mutex}; - 10
use tokio_util::sync::CancellationToken; - 11
use tower::ServiceExt; - 12
use vak_llm::{ - 13
EventStream, LlmError, Provider, stream, - 14
types::{AssistantMessage, ChatRequest, ContentBlock, StopReason, Usage}, - 15
}; - 16
use vak_session::{Entry, EntryPayload, SessionPath}; - 17
- 18
#[derive(Default)] - 19
struct Capture(Mutex<Vec<ChatRequest>>); - 20
#[async_trait::async_trait] - 21
impl Provider for Capture { - 22
fn name(&self) -> &str { - 23
"capture" - 24
} - 25
async fn stream( - 26
&self, - 27
request: ChatRequest, - 28
_: CancellationToken, - 29
) -> Result<EventStream, LlmError> { - 30
self.0.lock().unwrap().push(request); - 31
let (mut sink, rx) = stream::channel(8); - 32
let message = AssistantMessage { - 33
content: vec![ContentBlock::text("Your request is complete.")], - 34
stop_reason: StopReason::EndTurn, - 35
usage: Usage::default(), - 36
model: "test-model".into(), - 37
response_id: None, - 38
}; - 39
sink.push(stream::StreamEvent::Start { - 40
partial: message.clone(), - 41
}); - 42
sink.close_message(message).await; - 43
Ok(rx) - 44
} - 45
} - 46
- 47
async fn call(app: &Router, method: &str, path: &str, body: Value) -> (StatusCode, Value) { - 48
let response = app - 49
.clone() - 50
.oneshot( - 51
Request::builder() - 52
.method(method) - 53
.uri(path) - 54
.header("content-type", "application/json") - 55
.body(Body::from(body.to_string())) - 56
.unwrap(), - 57
) - 58
.await - 59
.unwrap(); - 60
let status = response.status(); - 61
let bytes = response.into_body().collect().await.unwrap().to_bytes(); - 62
( - 63
status, - 64
serde_json::from_slice(&bytes).unwrap_or(Value::Null), - 65
) - 66
} - 67
- 68
#[tokio::test] - 69
async fn explicit_new_agent_conversation_gets_a_distinct_durable_identity() { - 70
let temp = tempfile::tempdir().unwrap(); - 71
let cwd = temp.path().join("workspace"); - 72
std::fs::create_dir_all(&cwd).unwrap(); - 73
let core = vak_core::Core::new_with_trust(cwd.clone(), true).unwrap(); - 74
core.set_sessions_home(temp.path().join("sessions-home")); - 75
let app = vak_server::router(core.clone()); - 76
let (status, existing) = call(&app, "POST", "/agents/vak/open", json!({})).await; - 77
assert_eq!(status, StatusCode::OK); - 78
let (status, created) = call( - 79
&app, - 80
"POST", - 81
"/agents/vak/open", - 82
json!({"create_new": true}), - 83
) - 84
.await; - 85
assert_eq!(status, StatusCode::OK); - 86
assert_ne!(created["session_id"], existing["session_id"]); - 87
let created_id = created["session_id"].as_str().unwrap(); - 88
let ledger = SessionPath::new_session_file(&core.sessions_home(), &cwd, created_id); - 89
let first = std::fs::read_to_string(ledger).unwrap(); - 90
let first: Entry = serde_json::from_str(first.lines().next().unwrap()).unwrap(); - 91
let EntryPayload::Header(header) = first.payload else { - 92
panic!("new conversation must begin with a header"); - 93
}; - 94
assert!( - 95
header - 96
.conversation - 97
.as_ref() - 98
.is_some_and(|context| context.conversation_id.starts_with("agent:vak:local:")) - 99
); - 100
let (status, resumed) = call(&app, "POST", "/agents/vak/open", json!({})).await; - 101
assert_eq!(status, StatusCode::OK); - 102
assert_eq!(resumed["session_id"], existing["session_id"]); - 103
} - 104
- 105
/// Finding 5: `/agents/{id}/open`'s directory scan used to read every - 106
/// `.jsonl` sibling's header unconditionally and fail the WHOLE request - 107
/// with 500 the moment any one of them could not be parsed — even a file - 108
/// that would never have matched this conversation's own filter. It must - 109
/// now skip an unreadable ledger and keep going. - 110
#[tokio::test] - 111
async fn agent_open_skips_a_corrupt_sibling_ledger() { - 112
let temp = tempfile::tempdir().unwrap(); - 113
let cwd = temp.path().join("workspace"); - 114
std::fs::create_dir_all(&cwd).unwrap(); - 115
let core = vak_core::Core::new_with_trust(cwd.clone(), true).unwrap(); - 116
let sessions_home = temp.path().join("sessions-home"); - 117
core.set_sessions_home(sessions_home.clone()); - 118
let app = vak_server::router(core.clone()); - 119
- 120
let dir = SessionPath::sessions_dir(&sessions_home, &cwd); - 121
std::fs::create_dir_all(&dir).unwrap(); - 122
std::fs::write(dir.join("corrupt.jsonl"), "not valid json at all\n").unwrap(); - 123
- 124
let (status, body) = call(&app, "POST", "/agents/vak/open", json!({})).await; - 125
assert_eq!( - 126
status, - 127
StatusCode::OK, - 128
"a corrupt sibling ledger must not fail the open: {body}" - 129
); - 130
assert!(body["session_id"].as_str().is_some_and(|s| !s.is_empty())); - 131
- 132
// Reopening still resolves to the same conversation with the corrupt - 133
// file still present. - 134
let (status2, body2) = call(&app, "POST", "/agents/vak/open", json!({})).await; - 135
assert_eq!(status2, StatusCode::OK); - 136
assert_eq!(body2["session_id"], body["session_id"]); - 137
} - 138
- 139
fn profile(id: &str, name: &str) -> Value { - 140
json!({"id":id,"revision":1,"name":name,"character":"vak","personality":"Use the phrase identity-marker.","behaviour":"Answer concisely.","responsibilities":"Research news", "animation":"off","voice":"default"}) - 141
} - 142
- 143
async fn run(app: &Router, sid: &str, prompt: &str) { - 144
let (status, body) = call( - 145
app, - 146
"POST", - 147
&format!("/sessions/{sid}/run"), - 148
json!({"prompt":prompt,"request_id":uuid::Uuid::now_v7().to_string()}), - 149
) - 150
.await; - 151
assert_eq!(status, StatusCode::ACCEPTED, "{body}"); - 152
for _ in 0..200 { - 153
let (_, sessions) = call(app, "GET", "/sessions", json!({})).await; - 154
if sessions["sessions"] - 155
.as_array() - 156
.unwrap() - 157
.iter() - 158
.any(|s| s["session_id"] == sid && s["running"] == false) - 159
{ - 160
return; - 161
} - 162
tokio::time::sleep(std::time::Duration::from_millis(25)).await; - 163
} - 164
panic!("agent did not settle"); - 165
} - 166
- 167
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 168
async fn agent_identity_survives_clients_restart_and_followups_without_cross_talk() { - 169
vak_config::paths::isolate_home_for_tests(); - 170
let temp = tempfile::tempdir().unwrap(); - 171
let cwd = temp.path().join("workspace"); - 172
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 173
std::fs::write( - 174
cwd.join(".vak/config.toml"), - 175
"[memory]\nreflection = false\n", - 176
) - 177
.unwrap(); - 178
let core = vak_core::Core::new_with_trust(cwd.clone(), true).unwrap(); - 179
core.set_sessions_home(temp.path().join("sessions-home")); - 180
let capture = Arc::new(Capture::default()); - 181
core.set_provider_instance(capture.clone()); - 182
let app = vak_server::router(core.clone()); - 183
let profiles = - 184
json!({"agents":[profile("newsy","Newsy"),profile("other","Other")],"scope":"workspace"}); - 185
assert_eq!( - 186
call(&app, "PUT", "/config/agents", profiles).await.0, - 187
StatusCode::OK - 188
); - 189
assert_eq!( - 190
call(&app, "POST", "/agents/missing/open", json!({})) - 191
.await - 192
.0, - 193
StatusCode::NOT_FOUND - 194
); - 195
assert_eq!( - 196
call(&app, "POST", "/agents/vak/open", json!({})).await.0, - 197
StatusCode::OK - 198
); - 199
let (_, a) = call(&app, "POST", "/agents/newsy/open", json!({})).await; - 200
let sid = a["session_id"].as_str().unwrap().to_owned(); - 201
// Newsy, a user-created agent, is resolved through a freshly-constructed - 202
// Core rooted at its own workspace (see agent_chats::open), but that - 203
// Core is repointed at the same sessions/data-home root the test's Vak - 204
// Core already uses (`set_sessions_home` above), just under its own - 205
// agent-scoped subdirectory — every agent's data lives under one root. - 206
let newsy_home = vak_config::paths::agent_home_at(&core.shared_data_home(), "newsy"); - 207
let newsy_cwd = vak_config::paths::agent_workspace(&cwd, "newsy"); - 208
assert_ne!( - 209
newsy_cwd, cwd, - 210
"a user-created agent must not share Vak's project workspace" - 211
); - 212
assert_eq!( - 213
std::fs::canonicalize(a["cwd"].as_str().unwrap()).unwrap(), - 214
std::fs::canonicalize(&newsy_cwd).unwrap() - 215
); - 216
let resolved_newsy_cwd = std::path::PathBuf::from(a["cwd"].as_str().unwrap()); - 217
let ledger = SessionPath::new_session_file(&newsy_home, &resolved_newsy_cwd, &sid); - 218
assert!( - 219
!SessionPath::new_session_file(&core.sessions_home(), &cwd, &sid).exists(), - 220
"Newsy session must not be in Vak workspace" - 221
); - 222
let first = std::fs::read_to_string(ledger) - 223
.unwrap() - 224
.lines() - 225
.next() - 226
.map(|line| serde_json::from_str::<Entry>(line).unwrap()) - 227
.unwrap(); - 228
let EntryPayload::Header(header) = first.payload else { - 229
panic!("agent admission ledger must begin with a header"); - 230
}; - 231
let context = header - 232
.conversation - 233
.expect("Agent admission must stamp conversation context"); - 234
assert_eq!(context.conversation_id, "agent:newsy:local"); - 235
assert_eq!(context.audience_id, "local"); - 236
assert_eq!( - 237
context - 238
.origin - 239
.as_ref() - 240
.map(|origin| origin.surface.as_str()), - 241
Some("desktop") - 242
); - 243
let (_, again) = call(&app, "POST", "/agents/newsy/open", json!({})).await; - 244
assert_eq!( - 245
again["session_id"], sid, - 246
"even header-only conversations must reopen" - 247
); - 248
let (_, sessions_list) = call(&app, "GET", "/sessions", json!({})).await; - 249
let found = sessions_list["sessions"] - 250
.as_array() - 251
.unwrap() - 252
.iter() - 253
.any(|s| s["session_id"] == sid); - 254
assert!( - 255
found, - 256
"newly opened active agent session must be present in list_sessions even if header-only" - 257
); - 258
let (_, b) = call(&app, "POST", "/agents/other/open", json!({})).await; - 259
assert_ne!(b["session_id"], sid); - 260
run(&app, &sid, "Remember private-newsy-marker").await; - 261
run( - 262
&app, - 263
b["session_id"].as_str().unwrap(), - 264
"A different request", - 265
) - 266
.await; - 267
{ - 268
let requests = capture.0.lock().unwrap(); - 269
assert!( - 270
requests - 271
.iter() - 272
.any(|r| r.system.as_deref().unwrap_or("").contains("You are Newsy.")) - 273
); - 274
let other = requests - 275
.iter() - 276
.find(|r| r.system.as_deref().unwrap_or("").contains("You are Other.")) - 277
.unwrap(); - 278
assert!( - 279
!other - 280
.messages - 281
.iter() - 282
.any(|m| m.text_content().contains("private-newsy-marker")) - 283
); - 284
} - 285
drop(app); - 286
let app = vak_server::router(core.clone()); - 287
let (_, reopened) = call(&app, "POST", "/agents/newsy/open", json!({})).await; - 288
assert_eq!(reopened["session_id"], sid); - 289
run(&app, &sid, "What did I ask you to remember?").await; - 290
{ - 291
let requests = capture.0.lock().unwrap(); - 292
let last = requests.last().unwrap(); - 293
assert!( - 294
last.system - 295
.as_deref() - 296
.unwrap_or("") - 297
.contains("identity-marker") - 298
); - 299
assert!( - 300
last.messages - 301
.iter() - 302
.any(|m| m.text_content().contains("private-newsy-marker")) - 303
); - 304
assert!( - 305
!last - 306
.messages - 307
.iter() - 308
.any(|m| m.text_content().contains("Use my Agent")) - 309
); - 310
} - 311
let (_, transcript) = call( - 312
&app, - 313
"GET", - 314
&format!("/sessions/{sid}/transcript"), - 315
json!({}), - 316
) - 317
.await; - 318
assert!(transcript.to_string().contains("private-newsy-marker")); - 319
// Editing the catalogue never rewrites an admitted conversation's identity. - 320
call( - 321
&app, - 322
"PUT", - 323
"/config/agents", - 324
json!({"agents":[profile("newsy","Renamed"),profile("other","Other")]}), - 325
) - 326
.await; - 327
let (_, frozen) = call(&app, "POST", "/agents/newsy/open", json!({})).await; - 328
assert_eq!(frozen["agent"]["name"], "Newsy"); - 329
assert_eq!(frozen["agent"]["revision"], 1); - 330
// Shared writes are not copied into the project layer; effective reads merge. - 331
call( - 332
&app, - 333
"PUT", - 334
"/config/agents", - 335
json!({"scope":"user","agents":[profile("shared-helper","Shared helper")]}), - 336
) - 337
.await; - 338
let (_, project_layer) = call(&app, "GET", "/config/agents?scope=workspace", json!({})).await; - 339
assert!( - 340
project_layer - 341
.get("agents") - 342
.and_then(|v| v.as_array()) - 343
.is_some() - 344
); - 345
assert!(!project_layer.to_string().contains("shared-helper")); - 346
let (_, effective) = call(&app, "GET", "/agents", json!({})).await; - 347
assert!(effective.get("agents").and_then(|v| v.as_array()).is_some()); - 348
assert!(effective.to_string().contains("shared-helper")); - 349
// Same identity in another workspace has a separate ledger. - 350
let other_dir = temp.path().join("other-workspace"); - 351
std::fs::create_dir_all(&other_dir).unwrap(); - 352
let other_core = vak_core::Core::new_with_trust(other_dir, true).unwrap(); - 353
other_core.set_sessions_home(core.shared_data_home()); - 354
other_core.set_provider_instance(capture); - 355
let other_app = vak_server::router(other_core); - 356
call( - 357
&other_app, - 358
"PUT", - 359
"/config/agents", - 360
json!({"agents":[profile("newsy","Newsy")]}), - 361
) - 362
.await; - 363
let (_, isolated) = call(&other_app, "POST", "/agents/newsy/open", json!({})).await; - 364
assert_ne!(isolated["session_id"], sid); - 365
- 366
// Concurrent process simulation: a second server instance sharing the same workspace - 367
// and sessions home (e.g. gateway service when desktop application holds the writer lock). - 368
let concurrent_core = vak_core::Core::new_with_trust(cwd.clone(), true).unwrap(); - 369
concurrent_core.set_sessions_home(core.shared_data_home()); - 370
concurrent_core.set_provider_instance(Arc::new(Capture::default())); - 371
let concurrent_app = vak_server::router(concurrent_core); - 372
- 373
let (status, opened) = call(&concurrent_app, "POST", "/agents/newsy/open", json!({})).await; - 374
assert_eq!( - 375
status, - 376
StatusCode::OK, - 377
"concurrent open must succeed read-only when locked by desktop" - 378
); - 379
assert_eq!( - 380
opened["session_id"], sid, - 381
"must match the canonical session id" - 382
); - 383
- 384
let (transcript_status, transcript) = call( - 385
&concurrent_app, - 386
"GET", - 387
&format!("/sessions/{sid}/transcript"), - 388
json!({}), - 389
) - 390
.await; - 391
assert_eq!( - 392
transcript_status, - 393
StatusCode::OK, - 394
"transcript must be readable when session is locked" - 395
); - 396
assert!(transcript["messages"].as_array().is_some()); - 397
} - 398
- 399
/// Memory notes, learning proposals, and skill proposals are Agent-scoped - 400
/// (docs/design/23-memory.md: "Per-agent data home: sessions, memory, and - 401
/// agent-specific config") — a note or proposal written for one Agent must - 402
/// never appear for, or be mutated by, a different Agent's `?agent=` view. - 403
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 404
async fn memory_and_proposals_are_scoped_per_agent() { - 405
vak_config::paths::isolate_home_for_tests(); - 406
let temp = tempfile::tempdir().unwrap(); - 407
let cwd = temp.path().join("workspace"); - 408
std::fs::create_dir_all(&cwd).unwrap(); - 409
let core = vak_core::Core::new_with_trust(cwd, true).unwrap(); - 410
core.set_sessions_home(temp.path().join("sessions-home")); - 411
let app = vak_server::router(core); - 412
assert_eq!( - 413
call( - 414
&app, - 415
"PUT", - 416
"/config/agents", - 417
json!({"agents":[profile("newsy","Newsy"),profile("other","Other")],"scope":"workspace"}), - 418
) - 419
.await - 420
.0, - 421
StatusCode::OK - 422
); - 423
- 424
// Each agent writes a note only it should ever see. - 425
for (agent, marker) in [ - 426
("newsy", "newsy-private-note"), - 427
("other", "other-private-note"), - 428
] { - 429
let (status, _) = call( - 430
&app, - 431
"POST", - 432
"/memory", - 433
json!({"text": marker, "agent": agent}), - 434
) - 435
.await; - 436
assert_eq!(status, StatusCode::CREATED, "append for {agent}"); - 437
} - 438
- 439
let (_, newsy_notes) = call(&app, "GET", "/memory?agent=newsy", json!({})).await; - 440
let newsy_text = newsy_notes.to_string(); - 441
assert!(newsy_text.contains("newsy-private-note")); - 442
assert!(!newsy_text.contains("other-private-note")); - 443
- 444
let (_, other_notes) = call(&app, "GET", "/memory?agent=other", json!({})).await; - 445
let other_text = other_notes.to_string(); - 446
assert!(other_text.contains("other-private-note")); - 447
assert!(!other_text.contains("newsy-private-note")); - 448
- 449
// The default (built-in) agent, and an unscoped call, see neither. - 450
let (_, default_notes) = call(&app, "GET", "/memory", json!({})).await; - 451
let default_text = default_notes.to_string(); - 452
assert!(!default_text.contains("newsy-private-note")); - 453
assert!(!default_text.contains("other-private-note")); - 454
- 455
// Forgetting newsy's note must not touch other's. - 456
let newsy_note_id = newsy_notes["notes"] - 457
.as_array() - 458
.unwrap() - 459
.iter() - 460
.find(|n| n["text"] == "newsy-private-note") - 461
.and_then(|n| n["id"].as_str()) - 462
.unwrap() - 463
.to_string(); - 464
let (status, _) = call( - 465
&app, - 466
"DELETE", - 467
&format!("/memory/{newsy_note_id}?agent=newsy"), - 468
json!({}), - 469
) - 470
.await; - 471
assert_eq!(status, StatusCode::OK); - 472
let (_, other_notes_after) = call(&app, "GET", "/memory?agent=other", json!({})).await; - 473
assert!( - 474
other_notes_after.to_string().contains("other-private-note"), - 475
"deleting newsy's note must not affect other's" - 476
); - 477
} - 478
- 479
/// Set up two custom Agents (mirroring `memory_and_proposals_are_scoped_per_agent`) - 480
/// and return `(app, core)`. - 481
async fn two_agent_app() -> (Router, vak_core::Core, tempfile::TempDir) { - 482
vak_config::paths::isolate_home_for_tests(); - 483
let temp = tempfile::tempdir().unwrap(); - 484
let cwd = temp.path().join("workspace"); - 485
std::fs::create_dir_all(&cwd).unwrap(); - 486
let core = vak_core::Core::new_with_trust(cwd, true).unwrap(); - 487
core.set_sessions_home(temp.path().join("sessions-home")); - 488
let app = vak_server::router(core.clone()); - 489
assert_eq!( - 490
call( - 491
&app, - 492
"PUT", - 493
"/config/agents", - 494
json!({"agents":[profile("newsy","Newsy"),profile("other","Other")],"scope":"workspace"}), - 495
) - 496
.await - 497
.0, - 498
StatusCode::OK - 499
); - 500
// The caller must hold onto the returned `TempDir` for the rest of the - 501
// test — dropping it here (as a purely local variable would be, once - 502
// this function returns) deletes the on-disk workspace out from under - 503
// every subsequent request, which is exactly what produced the - 504
// spurious "This agent is no longer available" 404s while this helper - 505
// was being written. - 506
(app, core, temp) - 507
} - 508
- 509
/// `GET /config/prompts/effective` assembles the merged prompt off whichever - 510
/// `Core` the request resolves to — it must resolve per-`?agent=` exactly - 511
/// like its sibling `GET /config/prompts` layer endpoint, not fall back to - 512
/// the default "vak" Agent regardless of the query (the gap this test - 513
/// closes: `get_prompt_effective` was left reading `state.core` directly - 514
/// when the rest of the prompt endpoints were generalized to - 515
/// `resolve_scoped_core`). - 516
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 517
async fn prompt_effective_is_scoped_per_agent() { - 518
let (app, _core, _temp) = two_agent_app().await; - 519
- 520
let (status, _) = call( - 521
&app, - 522
"PUT", - 523
"/config/prompts", - 524
json!({ - 525
"scope": "workspace", - 526
"block": "guardrails", - 527
"text": "newsy-only-guardrail-line", - 528
"agent": "newsy", - 529
}), - 530
) - 531
.await; - 532
assert_eq!(status, StatusCode::OK); - 533
- 534
let (_, newsy_effective) = call( - 535
&app, - 536
"GET", - 537
"/config/prompts/effective?agent=newsy", - 538
json!({}), - 539
) - 540
.await; - 541
assert!( - 542
newsy_effective["text"] - 543
.as_str() - 544
.unwrap_or_default() - 545
.contains("newsy-only-guardrail-line"), - 546
"newsy's own effective prompt must include its guardrails override: {newsy_effective}" - 547
); - 548
- 549
let (_, default_effective) = call(&app, "GET", "/config/prompts/effective", json!({})).await; - 550
assert!( - 551
!default_effective["text"] - 552
.as_str() - 553
.unwrap_or_default() - 554
.contains("newsy-only-guardrail-line"), - 555
"the default agent must not see newsy's effective prompt: {default_effective}" - 556
); - 557
} - 558
- 559
/// `PUT /config/permissions` writes one Agent's own workspace rule layer — - 560
/// same isolation contract as hooks/MCP, checked here for permission rules. - 561
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 562
async fn permission_rules_are_scoped_per_agent() { - 563
let (app, _core, _temp) = two_agent_app().await; - 564
- 565
let (status, _) = call( - 566
&app, - 567
"PUT", - 568
"/config/permissions", - 569
json!({ - 570
"scope": "workspace", - 571
"deny": ["bash(rm -rf /)"], - 572
"agent": "newsy", - 573
}), - 574
) - 575
.await; - 576
assert_eq!(status, StatusCode::OK); - 577
- 578
let (_, newsy_rules) = call(&app, "GET", "/config/permissions?agent=newsy", json!({})).await; - 579
assert!( - 580
newsy_rules["layer"]["deny"] - 581
.as_array() - 582
.unwrap() - 583
.iter() - 584
.any(|v| v == "bash(rm -rf /)"), - 585
"newsy's own rule layer must include its deny rule: {newsy_rules}" - 586
); - 587
- 588
let (_, default_rules) = call(&app, "GET", "/config/permissions", json!({})).await; - 589
assert!( - 590
!default_rules["layer"]["deny"] - 591
.as_array() - 592
.unwrap() - 593
.iter() - 594
.any(|v| v == "bash(rm -rf /)"), - 595
"the default agent must not see newsy's deny rule: {default_rules}" - 596
); - 597
} - 598
- 599
/// `PATCH /finops` sets budget caps in one Agent's own `.vak/config.toml` — - 600
/// same isolation contract as hooks/MCP, checked here for FinOps caps. - 601
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 602
async fn finops_caps_are_scoped_per_agent() { - 603
let (app, _core, _temp) = two_agent_app().await; - 604
- 605
let (status, _) = call( - 606
&app, - 607
"PATCH", - 608
"/finops", - 609
json!({ - 610
"max_run_usd": 1.5, - 611
"agent": "newsy", - 612
}), - 613
) - 614
.await; - 615
assert_eq!(status, StatusCode::OK); - 616
- 617
let (_, newsy_status) = call(&app, "GET", "/finops?agent=newsy", json!({})).await; - 618
assert_eq!(newsy_status["run_cap_usd"].as_f64(), Some(1.5)); - 619
- 620
let (_, default_status) = call(&app, "GET", "/finops", json!({})).await; - 621
assert_ne!( - 622
default_status["run_cap_usd"].as_f64(), - 623
Some(1.5), - 624
"the default agent must not see newsy's run cap: {default_status}" - 625
); - 626
} - 627
- 628
/// `GET /sessions/{id}/checkpoints` must resolve a *closed* session's real - 629
/// owning Agent via `?agent=` rather than silently falling back to the - 630
/// default Agent once no in-memory session handle exists for it — the exact - 631
/// gap `resolve_scoped_core`'s session-id-first, agent-id-fallback path - 632
/// exists to close. - 633
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 634
async fn checkpoints_resolve_the_owning_agent_once_the_session_is_closed() { - 635
let (app, core, _temp) = two_agent_app().await; - 636
- 637
// Seed a checkpoint directly under Newsy's isolated workspace/home, - 638
// exactly as a real run would have via `vak_core::checkpoints::capture` - 639
// — no session handle is ever registered for `sid`, simulating a - 640
// session that closed (or a server restart) before this request. - 641
let newsy_cwd = vak_config::paths::agent_workspace(core.cwd(), "newsy"); - 642
std::fs::create_dir_all(&newsy_cwd).unwrap(); - 643
let newsy_home = vak_config::paths::agent_home_at(&core.shared_data_home(), "newsy"); - 644
let sid = "closed-newsy-session"; - 645
let (cp, _) = vak_core::checkpoints::capture(&newsy_cwd, &newsy_home, sid, 1, "seed").unwrap(); - 646
vak_core::checkpoints::store(&newsy_home, &cp).unwrap(); - 647
- 648
let (status, newsy_checkpoints) = call( - 649
&app, - 650
"GET", - 651
&format!("/sessions/{sid}/checkpoints?agent=newsy"), - 652
json!({}), - 653
) - 654
.await; - 655
assert_eq!(status, StatusCode::OK, "{newsy_checkpoints}"); - 656
assert_eq!( - 657
newsy_checkpoints["checkpoints"].as_array().unwrap().len(), - 658
1, - 659
"newsy's own checkpoint must be found via ?agent=newsy: {newsy_checkpoints}" - 660
); - 661
- 662
let (_, default_checkpoints) = call( - 663
&app, - 664
"GET", - 665
&format!("/sessions/{sid}/checkpoints"), - 666
json!({}), - 667
) - 668
.await; - 669
assert!( - 670
default_checkpoints["checkpoints"] - 671
.as_array() - 672
.unwrap() - 673
.is_empty(), - 674
"the default agent must not see newsy's checkpoint: {default_checkpoints}" - 675
); - 676
} - 677
- 678
/// A user-created Agent's isolated workspace is never separately visited or - 679
/// trust-prompted, so without `agents::save` carrying the creating context's - 680
/// own trust decision forward onto it (`vak_core::trust::mark_trusted`), its - 681
/// own privileged config — `permission_mode`, `hooks`, `mcp.servers`, ... — - 682
/// gets silently stripped by `CorePool::resolve_at` forever, no matter how - 683
/// correctly it's scoped per-Agent. This is the live-tested gap that - 684
/// surfaced *after* every other per-Agent scoping fix in this file: a write - 685
/// through `/config/mode?agent=newsy` persisted correctly to newsy's own - 686
/// `config.toml`, but read back as the untouched default because the - 687
/// directory it lived in had no trust marker. - 688
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 689
async fn a_new_agents_workspace_is_trusted_when_its_creator_is() { - 690
let (app, _core, _temp) = two_agent_app().await; - 691
- 692
let (status, _) = call( - 693
&app, - 694
"POST", - 695
"/config/mode", - 696
json!({"mode": "ReadOnly", "agent": "newsy"}), - 697
) - 698
.await; - 699
assert_eq!(status, StatusCode::OK); - 700
- 701
let (_, newsy_config) = call(&app, "GET", "/config?agent=newsy", json!({})).await; - 702
assert_eq!( - 703
newsy_config["permission_mode"], "ReadOnly", - 704
"newsy's own permission mode must actually take effect once its \ - 705
workspace is trusted, not silently stay at the untrusted default: \ - 706
{newsy_config}" - 707
); - 708
} - 709
- 710
/// `PUT /config/hooks` writes the project's own `[[hooks]]` array - 711
/// (docs/design/45-prompt-layers.md-adjacent config-layer semantics) — a - 712
/// hook saved for one Agent must not appear in, or be overwritten by, a - 713
/// different Agent's `?agent=` view, since each Agent has its own isolated - 714
/// workspace file (see commit 15c9c256's memory/proposals precedent). - 715
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 716
async fn hooks_are_scoped_per_agent() { - 717
let (app, _core, _temp) = two_agent_app().await; - 718
- 719
let (status, body) = call( - 720
&app, - 721
"PUT", - 722
"/config/hooks", - 723
json!({ - 724
"hooks": [{"event": "session_start", "command": "echo newsy-hook", "enabled": true}], - 725
"agent": "newsy", - 726
}), - 727
) - 728
.await; - 729
assert_eq!(status, StatusCode::OK, "{body}"); - 730
- 731
let (_, newsy_hooks) = call(&app, "GET", "/config/hooks?agent=newsy", json!({})).await; - 732
assert!(newsy_hooks.to_string().contains("newsy-hook")); - 733
- 734
let (_, other_hooks) = call(&app, "GET", "/config/hooks?agent=other", json!({})).await; - 735
assert!( - 736
!other_hooks.to_string().contains("newsy-hook"), - 737
"other agent must not see newsy's hook" - 738
); - 739
- 740
let (_, default_hooks) = call(&app, "GET", "/config/hooks", json!({})).await; - 741
assert!( - 742
!default_hooks.to_string().contains("newsy-hook"), - 743
"the default agent must not see newsy's hook either" - 744
); - 745
} - 746
- 747
/// `PUT /config/mcp` writes the project's own MCP server map — same - 748
/// isolation contract as hooks above, checked here for the MCP config - 749
/// layer. - 750
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 751
async fn mcp_servers_are_scoped_per_agent() { - 752
let (app, _core, _temp) = two_agent_app().await; - 753
- 754
let (status, _) = call( - 755
&app, - 756
"PUT", - 757
"/config/mcp", - 758
json!({ - 759
"servers": {"newsy-server": {"command": "true", "args": [], "env": {}}}, - 760
"agent": "newsy", - 761
}), - 762
) - 763
.await; - 764
assert_eq!(status, StatusCode::OK); - 765
- 766
let (_, newsy_servers) = call(&app, "GET", "/config/mcp?agent=newsy", json!({})).await; - 767
assert!(newsy_servers.to_string().contains("newsy-server")); - 768
- 769
let (_, other_servers) = call(&app, "GET", "/config/mcp?agent=other", json!({})).await; - 770
assert!( - 771
!other_servers.to_string().contains("newsy-server"), - 772
"other agent must not see newsy's MCP server" - 773
); - 774
- 775
let (_, default_servers) = call(&app, "GET", "/config/mcp", json!({})).await; - 776
assert!( - 777
!default_servers.to_string().contains("newsy-server"), - 778
"the default agent must not see newsy's MCP server either" - 779
); - 780
} - 781
- 782
/// The plugin store (retired-plugin sweep, catalog sources, key - 783
/// revocation, install/enable/disable/rollback/remove) is rooted at - 784
/// `<Agent's own cwd>/.vak` (`plugin_store`) — revoking a signing key for - 785
/// one Agent's workspace-scoped plugin store must not touch a different - 786
/// Agent's, or the default Agent's, own store. - 787
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 788
async fn plugin_key_revocation_is_scoped_per_agent() { - 789
let (app, _core, _temp) = two_agent_app().await; - 790
- 791
let (status, _) = call( - 792
&app, - 793
"POST", - 794
"/plugins/keys/test-signing-key/revoke?agent=newsy&scope=workspace", - 795
json!({}), - 796
) - 797
.await; - 798
assert_eq!(status, StatusCode::OK); - 799
- 800
// Read each Agent's own plugin registry file directly — there is no - 801
// dedicated "list revoked keys" endpoint, and the registry file is - 802
// exactly what every plugin_store-backed endpoint (install, enable, - 803
// disable, rollback, remove, sources) reads and writes. - 804
let newsy_root = vak_config::paths::agent_workspace(_core.cwd(), "newsy").join(".vak"); - 805
let other_root = vak_config::paths::agent_workspace(_core.cwd(), "other").join(".vak"); - 806
let default_root = _core.cwd().join(".vak"); - 807
- 808
let newsy_registry = vak_plugin::PluginStore::new(newsy_root) - 809
.load_sources() - 810
.unwrap(); - 811
assert!( - 812
newsy_registry.revoked_keys.contains("test-signing-key"), - 813
"newsy's own store must record the revocation" - 814
); - 815
- 816
let other_registry = vak_plugin::PluginStore::new(other_root) - 817
.load_sources() - 818
.unwrap(); - 819
assert!( - 820
!other_registry.revoked_keys.contains("test-signing-key"), - 821
"other agent's plugin store must not see newsy's key revocation" - 822
); - 823
- 824
let default_registry = vak_plugin::PluginStore::new(default_root) - 825
.load_sources() - 826
.unwrap(); - 827
assert!( - 828
!default_registry.revoked_keys.contains("test-signing-key"), - 829
"the default agent's plugin store must not see newsy's key revocation either" - 830
); - 831
} - 832
- 833
/// Isolated, credential-free browser fixture. Never reads the operator's home. - 834
/// Run with: cargo test -p vak-server --test agent_chats browser_fixture -- --ignored --nocapture - 835
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 836
#[ignore = "manual browser verification server; stops after ten minutes"] - 837
async fn browser_fixture() { - 838
vak_config::paths::isolate_home_for_tests(); - 839
let temp = tempfile::tempdir().unwrap(); - 840
let cwd = temp.path().join("workspace"); - 841
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 842
std::fs::write( - 843
cwd.join(".vak/config.toml"), - 844
"[memory]\nreflection = false\n", - 845
) - 846
.unwrap(); - 847
let core = vak_core::Core::new_with_trust(cwd, true).unwrap(); - 848
core.set_sessions_home(temp.path().join("sessions")); - 849
core.set_provider_instance(Arc::new(Capture::default())); - 850
let app = vak_server::router(core); - 851
call( - 852
&app, - 853
"PUT", - 854
"/config/agents", - 855
json!({"agents":[profile("newsy","Newsy"),profile("other","Other")]}), - 856
) - 857
.await; - 858
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 859
println!( - 860
"AGENT_BROWSER_URL=http://{}/app", - 861
listener.local_addr().unwrap() - 862
); - 863
let server = axum::serve(listener, app).with_graceful_shutdown(async { - 864
tokio::time::sleep(std::time::Duration::from_secs(600)).await; - 865
}); - 866
server.await.unwrap(); - 867
} - 868
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.