- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
use std::collections::VecDeque; - 4
use std::path::{Path, PathBuf}; - 5
use std::sync::{Arc, Mutex}; - 6
use std::time::Duration; - 7
- 8
use tokio_util::sync::CancellationToken; - 9
- 10
use vak_core::Core; - 11
use vak_llm::stream; - 12
use vak_llm::types::{AssistantMessage, ChatRequest, ContentBlock, Usage}; - 13
use vak_llm::{EventStream, LlmError, Provider}; - 14
- 15
struct Scripted { - 16
responses: Mutex<VecDeque<AssistantMessage>>, - 17
} - 18
- 19
#[async_trait::async_trait] - 20
impl Provider for Scripted { - 21
fn name(&self) -> &str { - 22
"scripted" - 23
} - 24
- 25
async fn stream( - 26
&self, - 27
_request: ChatRequest, - 28
_cancel: CancellationToken, - 29
) -> Result<EventStream, LlmError> { - 30
let next = self.responses.lock().unwrap().pop_front(); - 31
let (mut sink, rx) = stream::channel(64); - 32
match next { - 33
Some(m) => { - 34
sink.push(stream::StreamEvent::Start { partial: m.clone() }); - 35
sink.close_message(m).await; - 36
} - 37
None => sink.close_error(LlmError::Parse("exhausted".into())).await, - 38
} - 39
Ok(rx) - 40
} - 41
} - 42
- 43
fn text(t: &str) -> AssistantMessage { - 44
AssistantMessage { - 45
content: vec![ContentBlock::text(t)], - 46
stop_reason: vak_llm::types::StopReason::EndTurn, - 47
usage: Usage { - 48
input_tokens: 7, - 49
output_tokens: 3, - 50
..Default::default() - 51
}, - 52
model: "test-model".into(), - 53
response_id: None, - 54
} - 55
} - 56
- 57
fn tool_call(id: &str, name: &str, input: serde_json::Value) -> AssistantMessage { - 58
AssistantMessage { - 59
content: vec![ContentBlock::ToolUse { - 60
id: id.into(), - 61
name: name.into(), - 62
input, - 63
}], - 64
stop_reason: vak_llm::types::StopReason::ToolUse, - 65
usage: Usage::default(), - 66
model: "test-model".into(), - 67
response_id: None, - 68
} - 69
} - 70
- 71
struct Gateway { - 72
base: String, - 73
token: String, - 74
home: PathBuf, - 75
_server: tokio::task::JoinHandle<()>, - 76
} - 77
- 78
/// Gateway enabled purely through trusted project config. The agent modes - 79
/// (permission/approval) are pinned in the project config by [`config`] so an - 80
/// ambient global `fullaccess` profile cannot silently collapse the Ask gate - 81
/// these forwarded-approval tests assert on, or hang the deny test on a - 82
/// blocking approver. Project overrides global per the layered-config merge; - 83
/// note a runtime `set_approval_mode` would be clobbered by the control-plane - 84
/// refresh, so the modes live in the persisted config instead. - 85
async fn spawn_with_config(provider: Arc<dyn Provider>, gateway_toml: &str) -> Gateway { - 86
let dir = tempfile::tempdir().unwrap(); - 87
let cwd = dir.path().to_path_buf(); - 88
let project = cwd.join(".vak"); - 89
std::fs::create_dir_all(&project).unwrap(); - 90
std::fs::write(project.join("config.toml"), gateway_toml).unwrap(); - 91
- 92
vak_config::paths::isolate_home_for_tests(); - 93
let core = Core::new_with_trust(cwd.clone(), true).unwrap(); - 94
let home = dir.path().join("home"); - 95
core.set_sessions_home(home.clone()); - 96
core.set_provider_instance(provider); - 97
core.set_tool_worker_exe(std::path::PathBuf::from(env!( - 98
"CARGO_BIN_EXE_vak-tool-worker" - 99
))); - 100
std::mem::forget(dir); - 101
- 102
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 103
let addr = listener.local_addr().unwrap(); - 104
let (app, token) = vak_server::secured_router_with(core, false); - 105
let server = tokio::spawn(async move { - 106
axum::serve(listener, app).await.unwrap(); - 107
}); - 108
Gateway { - 109
base: format!("http://{addr}"), - 110
token, - 111
home, - 112
_server: server, - 113
} - 114
} - 115
- 116
fn client_with(token: &str) -> reqwest::Client { - 117
reqwest::ClientBuilder::new() - 118
.default_headers({ - 119
let mut h = reqwest::header::HeaderMap::new(); - 120
h.insert( - 121
reqwest::header::AUTHORIZATION, - 122
format!("Bearer {token}").parse().unwrap(), - 123
); - 124
h - 125
}) - 126
.build() - 127
.unwrap() - 128
} - 129
- 130
fn config(toml_body: &str) -> String { - 131
config_with_modes(toml_body, "workspace-write", "ask") - 132
} - 133
- 134
fn config_with_modes(toml_body: &str, permission_mode: &str, approval_mode: &str) -> String { - 135
format!( - 136
"permission_mode = \"{permission_mode}\"\n\ - 137
approval_mode = \"{approval_mode}\"\n\ - 138
[gateway]\n\ - 139
enabled = true\n\ - 140
chat_allowlist_open = true\n\ - 141
{toml_body}\n\n\ - 142
[memory]\n\ - 143
reflection = false\n" - 144
) - 145
} - 146
- 147
async fn inbound( - 148
client: &reqwest::Client, - 149
base: &str, - 150
surface: &str, - 151
chat: &str, - 152
text: &str, - 153
wait: bool, - 154
) -> reqwest::Response { - 155
client - 156
.post(format!("{base}/gateway/inbound")) - 157
.json(&serde_json::json!({ - 158
"surface": surface, "chat": chat, "text": text, "wait": wait - 159
})) - 160
.send() - 161
.await - 162
.unwrap() - 163
} - 164
- 165
fn deliveries(home: &Path) -> PathBuf { - 166
home.join("gateway/deliveries.jsonl") - 167
} - 168
- 169
async fn wait_for_delivery(home: &Path, needle: &str, secs: u64) -> bool { - 170
let deadline = std::time::Instant::now() + Duration::from_secs(secs); - 171
while std::time::Instant::now() < deadline { - 172
if let Ok(raw) = std::fs::read_to_string(deliveries(home)) - 173
&& raw.contains(needle) - 174
{ - 175
return true; - 176
} - 177
tokio::time::sleep(Duration::from_millis(150)).await; - 178
} - 179
false - 180
} - 181
- 182
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 183
async fn forwarded_gate_resolves_from_approver_chat() { - 184
let gw = spawn_with_config( - 185
Arc::new(Scripted { - 186
responses: Mutex::new(VecDeque::from(vec![ - 187
tool_call( - 188
"t1", - 189
"bash", - 190
serde_json::json!({"command": "echo approved-run"}), - 191
), - 192
text("done after yes"), - 193
])), - 194
}), - 195
&config("approvals = \"forward\"\napprover = \"log:ops\"\n"), - 196
) - 197
.await; - 198
let client = client_with(&gw.token); - 199
- 200
// Kick off an unattended turn that needs escalation. - 201
let res = inbound(&client, &gw.base, "webhook", "ci", "run it", false).await; - 202
assert_eq!(res.status(), 202); - 203
- 204
// The gate lands on the approver surface's delivery transport. - 205
let announced = wait_for_delivery(&gw.home, "Approval requested", 15).await; - 206
if !announced { - 207
let status: serde_json::Value = client - 208
.get(format!("{}/gateway/status", gw.base)) - 209
.send() - 210
.await - 211
.unwrap() - 212
.json() - 213
.await - 214
.unwrap(); - 215
eprintln!("DBG status={status}"); - 216
if let Ok(raw) = std::fs::read_to_string(deliveries(&gw.home)) { - 217
eprintln!("DBG deliveries={raw}"); - 218
} else { - 219
eprintln!("DBG deliveries=<missing> home={:?}", gw.home); - 220
} - 221
} - 222
assert!(announced, "gate must be announced on the approver surface"); - 223
- 224
// Answering from the approver chat resolves it. - 225
let res = inbound(&client, &gw.base, "log", "ops", "yes", false).await; - 226
assert_eq!(res.status(), 200); - 227
let body: serde_json::Value = res.json().await.unwrap(); - 228
assert_eq!(body["state"], "approval_resolved"); - 229
assert_eq!(body["approved"], true); - 230
- 231
// The tool then really runs and the turn completes. - 232
let sid: String = { - 233
let status: serde_json::Value = client - 234
.get(format!("{}/gateway/status", gw.base)) - 235
.send() - 236
.await - 237
.unwrap() - 238
.json() - 239
.await - 240
.unwrap(); - 241
status["bindings"][0]["session_id"] - 242
.as_str() - 243
.unwrap() - 244
.to_string() - 245
}; - 246
let deadline = std::time::Instant::now() + Duration::from_secs(20); - 247
let mut raw = String::new(); - 248
while std::time::Instant::now() < deadline { - 249
if let Ok(res) = client - 250
.get(format!("{}/sessions/{sid}/transcript", gw.base)) - 251
.send() - 252
.await - 253
&& res.status() == reqwest::StatusCode::OK - 254
{ - 255
let t: serde_json::Value = res.json().await.unwrap(); - 256
if t.get("error").is_none() { - 257
raw = serde_json::to_string(&t).unwrap(); - 258
if raw.contains("approved-run") && raw.contains("done after yes") { - 259
break; - 260
} - 261
} - 262
} - 263
tokio::time::sleep(Duration::from_millis(150)).await; - 264
} - 265
assert!( - 266
raw.contains("approved-run"), - 267
"bash ran after approval: {raw}" - 268
); - 269
assert!(raw.contains("done after yes"), "turn completed: {raw}"); - 270
- 271
// Pending count back to zero. - 272
let status: serde_json::Value = client - 273
.get(format!("{}/gateway/status", gw.base)) - 274
.send() - 275
.await - 276
.unwrap() - 277
.json() - 278
.await - 279
.unwrap(); - 280
assert_eq!(status["approvals"]["pending"], 0); - 281
} - 282
- 283
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 284
async fn unanswered_gate_times_out_and_fails_closed() { - 285
let gw = spawn_with_config( - 286
Arc::new(Scripted { - 287
responses: Mutex::new(VecDeque::from(vec![ - 288
tool_call( - 289
"t1", - 290
"bash", - 291
serde_json::json!({"command": "echo never-runs"}), - 292
), - 293
text("bash denied: moving on without it"), - 294
])), - 295
}), - 296
&config("approvals = \"forward\"\napprover = \"log:ops\"\napproval_timeout_secs = 5\n"), - 297
) - 298
.await; - 299
let client = client_with(&gw.token); - 300
- 301
let res = inbound(&client, &gw.base, "webhook", "ci", "go", false).await; - 302
assert_eq!(res.status(), 202); - 303
assert!( - 304
wait_for_delivery(&gw.home, "Approval requested", 15).await, - 305
"gate announced" - 306
); - 307
- 308
// Let the 5s window lapse; the late reply must resolve nothing. - 309
tokio::time::sleep(Duration::from_secs(6)).await; - 310
let res = inbound(&client, &gw.base, "log", "ops", "yes", false).await; - 311
let body: serde_json::Value = res.json().await.unwrap(); - 312
assert_eq!(body["state"], "no_pending_approvals"); - 313
- 314
// The turn finishes anyway; the tool never executed. - 315
let sid: String = { - 316
let status: serde_json::Value = client - 317
.get(format!("{}/gateway/status", gw.base)) - 318
.send() - 319
.await - 320
.unwrap() - 321
.json() - 322
.await - 323
.unwrap(); - 324
status["bindings"][0]["session_id"] - 325
.as_str() - 326
.unwrap() - 327
.to_string() - 328
}; - 329
let deadline = std::time::Instant::now() + Duration::from_secs(20); - 330
let mut raw = String::new(); - 331
while std::time::Instant::now() < deadline { - 332
if let Ok(res) = client - 333
.get(format!("{}/sessions/{sid}/transcript", gw.base)) - 334
.send() - 335
.await - 336
&& res.status() == reqwest::StatusCode::OK - 337
{ - 338
let t: serde_json::Value = res.json().await.unwrap(); - 339
if t.get("error").is_none() { - 340
raw = serde_json::to_string(&t).unwrap(); - 341
if raw.contains("moving on without it") { - 342
break; - 343
} - 344
} - 345
} - 346
tokio::time::sleep(Duration::from_millis(150)).await; - 347
} - 348
assert!(raw.contains("moving on without it"), "run finished: {raw}"); - 349
} - 350
- 351
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 352
async fn addressed_yes_resolves_only_that_gate_and_reports_it() { - 353
let gw = spawn_with_config( - 354
Arc::new(Scripted { - 355
responses: Mutex::new(VecDeque::from(vec![ - 356
tool_call( - 357
"t1", - 358
"bash", - 359
serde_json::json!({"command": "echo addressed-run"}), - 360
), - 361
text("done after addressed yes"), - 362
])), - 363
}), - 364
&config("approvals = \"forward\"\napprover = \"log:ops\"\n"), - 365
) - 366
.await; - 367
let client = client_with(&gw.token); - 368
- 369
let res = inbound(&client, &gw.base, "webhook", "ci", "run it", false).await; - 370
assert_eq!(res.status(), 202); - 371
assert!( - 372
wait_for_delivery(&gw.home, "Approval requested", 15).await, - 373
"gate announced" - 374
); - 375
let raw = std::fs::read_to_string(deliveries(&gw.home)).unwrap(); - 376
let announcement = raw - 377
.lines() - 378
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) - 379
.find_map(|line| line["text"].as_str().map(str::to_string)) - 380
.unwrap(); - 381
let start = announcement.find('[').unwrap() + 1; - 382
let short = announcement[start..start + 8].to_string(); - 383
- 384
// A verdict addressed to a nonexistent gate must leave the live gate - 385
// untouched. - 386
let res = inbound(&client, &gw.base, "log", "ops", "yes 00000000", false).await; - 387
let body: serde_json::Value = res.json().await.unwrap(); - 388
assert_eq!(body["state"], "no_pending_approvals"); - 389
let status: serde_json::Value = client - 390
.get(format!("{}/gateway/status", gw.base)) - 391
.send() - 392
.await - 393
.unwrap() - 394
.json() - 395
.await - 396
.unwrap(); - 397
assert_eq!( - 398
status["approvals"]["pending"], 1, - 399
"live gate survives a mis-addressed yes" - 400
); - 401
- 402
// Addressing the real gate resolves exactly it, and the reply says so. - 403
let res = inbound( - 404
&client, - 405
&gw.base, - 406
"log", - 407
"ops", - 408
&format!("yes {short}"), - 409
false, - 410
) - 411
.await; - 412
let body: serde_json::Value = res.json().await.unwrap(); - 413
assert_eq!(body["state"], "approval_resolved"); - 414
assert_eq!(body["approved"], true); - 415
assert!( - 416
body["gate"].as_str().unwrap().starts_with(&short), - 417
"resolved gate id reported: {body}" - 418
); - 419
assert_eq!(body["remaining"], 0); - 420
assert!( - 421
body["session_id"].as_str().is_some_and(|s| !s.is_empty()), - 422
"session attribution present" - 423
); - 424
- 425
// The tool then really runs. - 426
let sid = body["session_id"].as_str().unwrap().to_string(); - 427
let deadline = std::time::Instant::now() + Duration::from_secs(20); - 428
let mut transcript = String::new(); - 429
while std::time::Instant::now() < deadline { - 430
if let Ok(res) = client - 431
.get(format!("{}/sessions/{sid}/transcript", gw.base)) - 432
.send() - 433
.await - 434
&& res.status() == reqwest::StatusCode::OK - 435
{ - 436
let t: serde_json::Value = res.json().await.unwrap(); - 437
if t.get("error").is_none() - 438
&& let Ok(raw) = serde_json::to_string(&t) - 439
&& raw.contains("addressed-run") - 440
{ - 441
transcript = raw; - 442
break; - 443
} - 444
} - 445
tokio::time::sleep(Duration::from_millis(150)).await; - 446
} - 447
assert!( - 448
transcript.contains("addressed-run"), - 449
"bash ran: {transcript}" - 450
); - 451
} - 452
- 453
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 454
async fn default_policy_denies_without_forwarding() { - 455
let gw = spawn_with_config( - 456
Arc::new(Scripted { - 457
// Denial feeds an error result back; the completion guard may - 458
// ask for one more pass, so keep a spare response queued. - 459
responses: Mutex::new(VecDeque::from(vec![ - 460
tool_call( - 461
"t1", - 462
"bash", - 463
serde_json::json!({"command": "echo denied-run"}), - 464
), - 465
text("skipping that step"), - 466
text("skipping that step"), - 467
])), - 468
}), - 469
&config_with_modes("", "read-only", "ask"), // approvals unset => deny - 470
) - 471
.await; - 472
let client = client_with(&gw.token); - 473
- 474
// No approver configured: forward_mode() must be false even if someone - 475
// sends verdict-shaped text to any chat. - 476
let res = inbound(&client, &gw.base, "log", "ops", "yes", false).await; - 477
let body: serde_json::Value = res.json().await.unwrap(); - 478
assert_ne!(body["state"], "approval_resolved"); - 479
- 480
// The unattended turn completes via auto-deny; nothing was forwarded. - 481
let res = inbound(&client, &gw.base, "webhook", "ci", "go", true).await; - 482
assert_eq!(res.status(), 200); - 483
let body: serde_json::Value = res.json().await.unwrap(); - 484
assert_eq!(body["text"], "skipping that step"); - 485
assert!( - 486
!deliveries(&gw.home).exists(), - 487
"deny mode must not announce anything" - 488
); - 489
} - 490
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.