- 1001
break; - 1002
} - 1003
} - 1004
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 1005
} - 1006
let ct = wait_transcript(&client, &base, &child_id).await; - 1007
assert_eq!(ct["count"].as_u64(), Some(2)); - 1008
let wt_root = cwd.join(".vak/worktrees"); - 1009
assert_eq!(std::fs::read_dir(&wt_root).unwrap().count(), 1); - 1010
- 1011
// Second run replaces the worktree (latest-only retention). - 1012
client - 1013
.post(format!("{base}/tasks/{tid}/run-now")) - 1014
.send() - 1015
.await - 1016
.unwrap(); - 1017
let deadline2 = std::time::Instant::now() + std::time::Duration::from_secs(15); - 1018
loop { - 1019
assert!(std::time::Instant::now() < deadline2, "second run timeout"); - 1020
let t: serde_json::Value = client - 1021
.get(format!("{base}/tasks")) - 1022
.send() - 1023
.await - 1024
.unwrap() - 1025
.json() - 1026
.await - 1027
.unwrap(); - 1028
if t["tasks"][0]["last_session_id"].as_str() != Some(child_id.as_str()) - 1029
&& t["tasks"][0]["last_summary"] == "task ran again" - 1030
{ - 1031
break; - 1032
} - 1033
tokio::time::sleep(std::time::Duration::from_millis(150)).await; - 1034
} - 1035
assert_eq!( - 1036
std::fs::read_dir(&wt_root).unwrap().count(), - 1037
1, - 1038
"old worktree replaced" - 1039
); - 1040
- 1041
// Delete cleans up the remaining worktree. - 1042
let del = client - 1043
.delete(format!("{base}/tasks/{tid}")) - 1044
.send() - 1045
.await - 1046
.unwrap(); - 1047
assert_eq!(del.status(), 200); - 1048
assert_eq!(std::fs::read_dir(&wt_root).unwrap().count(), 0); - 1049
} - 1050
- 1051
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1052
async fn launch_config_and_process_lifecycle() { - 1053
let provider = Arc::new(Scripted { - 1054
responses: Mutex::new(VecDeque::new()), - 1055
}); - 1056
let (base, token, cwd, _server) = spawn_secured(provider).await; - 1057
let client = client_with(&token); - 1058
- 1059
let anchor: String = client - 1060
.post(format!("{base}/sessions")) - 1061
.send() - 1062
.await - 1063
.unwrap() - 1064
.json::<serde_json::Value>() - 1065
.await - 1066
.unwrap()["session_id"] - 1067
.as_str() - 1068
.unwrap() - 1069
.to_string(); - 1070
- 1071
// No config yet → empty list (auto-detect finds nothing in a tempdir). - 1072
let empty: serde_json::Value = client - 1073
.get(format!("{base}/sessions/{anchor}/launch")) - 1074
.send() - 1075
.await - 1076
.unwrap() - 1077
.json() - 1078
.await - 1079
.unwrap(); - 1080
assert_eq!(empty["servers"].as_array().unwrap().len(), 0); - 1081
- 1082
std::fs::create_dir_all(cwd.join(".vak")).unwrap(); - 1083
std::fs::write( - 1084
cwd.join(".vak/launch.toml"), - 1085
"[[server]]\nname = \"static\"\ncmd = \"python3\"\nargs = [\"-m\", \"http.server\", \"4519\"]\nport = 4519\n", - 1086
) - 1087
.unwrap(); - 1088
- 1089
let cfg: serde_json::Value = client - 1090
.get(format!("{base}/sessions/{anchor}/launch")) - 1091
.send() - 1092
.await - 1093
.unwrap() - 1094
.json() - 1095
.await - 1096
.unwrap(); - 1097
let servers = cfg["servers"].as_array().unwrap(); - 1098
assert_eq!(servers.len(), 1); - 1099
assert_eq!(servers[0]["port"], 4519); - 1100
assert_eq!(servers[0]["running"], false); - 1101
- 1102
let py = std::process::Command::new("python3") - 1103
.arg("--version") - 1104
.output() - 1105
.map(|o| o.status.success()) - 1106
.unwrap_or(false); - 1107
if !py { - 1108
eprintln!("python3 unavailable; skipping process assertions"); - 1109
return; - 1110
} - 1111
- 1112
let start = client - 1113
.post(format!("{base}/sessions/{anchor}/launch/start")) - 1114
.json(&serde_json::json!({"name": "static"})) - 1115
.send() - 1116
.await - 1117
.unwrap(); - 1118
assert_eq!( - 1119
start.status(), - 1120
200, - 1121
"{}", - 1122
start.text().await.unwrap_or_default() - 1123
); - 1124
let body: serde_json::Value = start.json().await.unwrap(); - 1125
assert_eq!(body["started"], true); - 1126
assert_eq!(body["listening"], true, "http.server should bind quickly"); - 1127
- 1128
// Double-start conflicts. - 1129
let again = client - 1130
.post(format!("{base}/sessions/{anchor}/launch/start")) - 1131
.json(&serde_json::json!({"name": "static"})) - 1132
.send() - 1133
.await - 1134
.unwrap(); - 1135
assert_eq!(again.status(), 409); - 1136
- 1137
// Logs are flowing. - 1138
let logs: serde_json::Value = client - 1139
.get(format!("{base}/sessions/{anchor}/launch/logs?name=static")) - 1140
.send() - 1141
.await - 1142
.unwrap() - 1143
.json() - 1144
.await - 1145
.unwrap(); - 1146
assert!(logs["lines"].as_array().is_some()); - 1147
- 1148
let stop = client - 1149
.post(format!("{base}/sessions/{anchor}/launch/stop")) - 1150
.json(&serde_json::json!({"name": "static"})) - 1151
.send() - 1152
.await - 1153
.unwrap(); - 1154
assert_eq!(stop.status(), 200); - 1155
- 1156
// Port actually released. - 1157
tokio::time::sleep(std::time::Duration::from_millis(300)).await; - 1158
assert!( - 1159
tokio::net::TcpStream::connect("127.0.0.1:4519") - 1160
.await - 1161
.is_err() - 1162
); - 1163
} - 1164
- 1165
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1166
async fn checkpoints_list_and_restore_roundtrip() { - 1167
let (base, token, cwd, _srv) = spawn_secured(Arc::new(Scripted { - 1168
responses: Mutex::new(VecDeque::new()), - 1169
})) - 1170
.await; - 1171
let client = client_with(&token); - 1172
let home = cwd.join("home"); // mirrors spawn_secured's sessions_home - 1173
- 1174
let created: serde_json::Value = client - 1175
.post(format!("{base}/sessions")) - 1176
.body("{}") - 1177
.send() - 1178
.await - 1179
.unwrap() - 1180
.json() - 1181
.await - 1182
.unwrap(); - 1183
let id = created["session_id"].as_str().unwrap().to_string(); - 1184
- 1185
// Capture a checkpoint directly (the primitive the loop uses at turn - 1186
// start), then mutate the workspace "like an agent would". - 1187
std::fs::write(cwd.join("notes.txt"), "original").unwrap(); - 1188
let (cp, _) = vak_core::checkpoints::capture(&cwd, &home, &id, 1, "turn 1").unwrap(); - 1189
vak_core::checkpoints::store(&home, &cp).unwrap(); - 1190
std::fs::write(cwd.join("notes.txt"), "mutated by agent").unwrap(); - 1191
std::fs::write(cwd.join("stray.txt"), "extra").unwrap(); - 1192
- 1193
let listed: serde_json::Value = client - 1194
.get(format!("{base}/sessions/{id}/checkpoints")) - 1195
.send() - 1196
.await - 1197
.unwrap() - 1198
.json() - 1199
.await - 1200
.unwrap(); - 1201
let items = listed["checkpoints"].as_array().unwrap(); - 1202
assert_eq!(items.len(), 1); - 1203
assert_eq!(items[0]["seq"], 1); - 1204
assert_eq!(items[0]["label"], "turn 1"); - 1205
// At least two files live here: notes.txt plus the session ledger - 1206
// itself, because this test nests sessions_home inside the workspace. - 1207
// The gateway may also seed a live-reloadable `gateway/allowlist.json` - 1208
// from `gateway.chat_allowlist` on startup (docs/design/34), which - 1209
// adds one more file whenever a config layer sets that key — not - 1210
// asserted as an exact count so this stays independent of the - 1211
// environment's own config layering. - 1212
let expected_files = items[0]["files"].as_u64().unwrap(); - 1213
assert!( - 1214
expected_files >= 2, - 1215
"expected at least notes.txt + session ledger, got {expected_files}" - 1216
); - 1217
- 1218
let restored: serde_json::Value = client - 1219
.post(format!("{base}/sessions/{id}/checkpoints/1/restore")) - 1220
.send() - 1221
.await - 1222
.unwrap() - 1223
.json() - 1224
.await - 1225
.unwrap(); - 1226
assert_eq!(restored["restored"].as_u64(), Some(expected_files)); - 1227
assert!( - 1228
restored["deleted"].as_u64().unwrap() >= 1, - 1229
"the stray file must be removed" - 1230
); - 1231
assert_eq!( - 1232
std::fs::read_to_string(cwd.join("notes.txt")).unwrap(), - 1233
"original" - 1234
); - 1235
assert!(!cwd.join("stray.txt").exists()); - 1236
// Observed-at-capture files are rewritten, never removed: the ledger - 1237
// must survive the rewind untouched. - 1238
let agent_home = home.join("agents").join("vak"); - 1239
let ledger_home = if agent_home.exists() { - 1240
agent_home - 1241
} else { - 1242
home.clone() - 1243
}; - 1244
let ledger = - 1245
vak_session::SessionPath::sessions_dir(&ledger_home, &cwd).join(format!("{id}.jsonl")); - 1246
assert!( - 1247
ledger.is_file(), - 1248
"rewind must never delete the session ledger" - 1249
); - 1250
- 1251
let missing = client - 1252
.post(format!("{base}/sessions/{id}/checkpoints/99/restore")) - 1253
.send() - 1254
.await - 1255
.unwrap(); - 1256
assert_eq!(missing.status(), 404); - 1257
} - 1258
- 1259
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1260
async fn archive_toggle_is_reflected_in_session_list() { - 1261
let dir = tempfile::tempdir().unwrap(); - 1262
let cwd = dir.path().to_path_buf(); - 1263
vak_config::paths::isolate_home_for_tests(); - 1264
let core_a = Core::new(cwd.clone()).unwrap(); - 1265
core_a.set_sessions_home(cwd.join("home")); - 1266
std::mem::forget(dir); - 1267
- 1268
let mut log = core_a.start_session().await.unwrap(); - 1269
let session_id = log.header().unwrap().session_id.clone(); - 1270
use vak_llm::types::{ContentBlock as CB, Role}; - 1271
log.append_message(vak_session::MessageRecord { - 1272
message: vak_llm::Message { - 1273
role: Role::User, - 1274
content: vec![CB::text("archivable task")], - 1275
}, - 1276
meta: None, - 1277
}) - 1278
.unwrap(); - 1279
drop(log); - 1280
- 1281
vak_config::paths::isolate_home_for_tests(); - 1282
let core = Core::new(cwd.clone()).unwrap(); - 1283
core.set_sessions_home(cwd.join("home")); - 1284
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 1285
let addr = listener.local_addr().unwrap(); - 1286
let (app, token) = vak_server::secured_router(core); - 1287
tokio::spawn(async move { - 1288
axum::serve(listener, app).await.unwrap(); - 1289
}); - 1290
let base = format!("http://{addr}"); - 1291
let client = client_with(&token); - 1292
- 1293
let archived_flag = |body: serde_json::Value| { - 1294
body["sessions"] - 1295
.as_array() - 1296
.unwrap() - 1297
.iter() - 1298
.find(|s| s["session_id"] == session_id.as_str()) - 1299
.expect("session must be listed")["archived"] - 1300
.as_bool() - 1301
.unwrap_or(false) - 1302
}; - 1303
- 1304
let listed: serde_json::Value = client - 1305
.get(format!("{base}/sessions")) - 1306
.send() - 1307
.await - 1308
.unwrap() - 1309
.json() - 1310
.await - 1311
.unwrap(); - 1312
assert!(!archived_flag(listed)); - 1313
- 1314
let on = client - 1315
.post(format!("{base}/sessions/{session_id}/archive")) - 1316
.json(&serde_json::json!({ "archived": true })) - 1317
.send() - 1318
.await - 1319
.unwrap(); - 1320
assert_eq!(on.status(), 200); - 1321
let listed: serde_json::Value = client - 1322
.get(format!("{base}/sessions")) - 1323
.send() - 1324
.await - 1325
.unwrap() - 1326
.json() - 1327
.await - 1328
.unwrap(); - 1329
assert!(archived_flag(listed)); - 1330
- 1331
// The sidecar must persist independently of the running server. - 1332
let raw = std::fs::read_to_string(cwd.join("home/archive.json")).unwrap(); - 1333
assert!(raw.contains(&session_id)); - 1334
- 1335
let off = client - 1336
.post(format!("{base}/sessions/{session_id}/archive")) - 1337
.json(&serde_json::json!({ "archived": false })) - 1338
.send() - 1339
.await - 1340
.unwrap(); - 1341
assert_eq!(off.status(), 200); - 1342
let listed: serde_json::Value = client - 1343
.get(format!("{base}/sessions")) - 1344
.send() - 1345
.await - 1346
.unwrap() - 1347
.json() - 1348
.await - 1349
.unwrap(); - 1350
assert!(!archived_flag(listed)); - 1351
- 1352
let unknown = client - 1353
.post(format!("{base}/sessions/not-a-session/archive")) - 1354
.json(&serde_json::json!({ "archived": true })) - 1355
.send() - 1356
.await - 1357
.unwrap(); - 1358
assert_eq!(unknown.status(), 404); - 1359
} - 1360
- 1361
/// `archive.json`/`deleted.json` are one shared map per `sessions_home`, - 1362
/// keyed by session id with no workspace scoping of their own — a ledger - 1363
/// file only ever lives under *this* process's own - 1364
/// `sessions_dir(sessions_home, cwd)`. Single-session delete already checks - 1365
/// that file exists before acting on it; `DELETE /sessions/archived` - 1366
/// (bulk) iterated every archived id in the shared map with no such check, - 1367
/// so running it from a server bound to workspace B could soft-delete an - 1368
/// archived session that only ever belonged to workspace A. - 1369
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1370
async fn delete_all_archived_never_touches_a_different_workspaces_session() { - 1371
use vak_llm::types::{ContentBlock as CB, Role}; - 1372
let home_dir = tempfile::tempdir().unwrap(); - 1373
let home = home_dir.path().to_path_buf(); - 1374
std::mem::forget(home_dir); - 1375
- 1376
async fn make_archived_session(cwd: &std::path::Path, home: &std::path::Path) -> String { - 1377
vak_config::paths::isolate_home_for_tests(); - 1378
let core = Core::new(cwd.to_path_buf()).unwrap(); - 1379
core.set_sessions_home(home.to_path_buf()); - 1380
let mut log = core.start_session().await.unwrap(); - 1381
let id = log.header().unwrap().session_id.clone(); - 1382
log.append_message(vak_session::MessageRecord { - 1383
message: vak_llm::Message { - 1384
role: Role::User, - 1385
content: vec![CB::text("task")], - 1386
}, - 1387
meta: None, - 1388
}) - 1389
.unwrap(); - 1390
drop(log); - 1391
id - 1392
} - 1393
- 1394
let dir_a = tempfile::tempdir().unwrap(); - 1395
let cwd_a = dir_a.path().to_path_buf(); - 1396
std::mem::forget(dir_a); - 1397
let dir_b = tempfile::tempdir().unwrap(); - 1398
let cwd_b = dir_b.path().to_path_buf(); - 1399
std::mem::forget(dir_b); - 1400
- 1401
let id_a = make_archived_session(&cwd_a, &home).await; - 1402
let id_b = make_archived_session(&cwd_b, &home).await; - 1403
- 1404
// Archive both directly in the shared sidecar, exactly as the running - 1405
// server would after two `POST .../archive` calls from two different - 1406
// workspaces. - 1407
let archive_path = home.join("archive.json"); - 1408
std::fs::write( - 1409
&archive_path, - 1410
serde_json::to_string(&serde_json::json!({ &id_a: true, &id_b: true })).unwrap(), - 1411
) - 1412
.unwrap(); - 1413
- 1414
// A server bound to workspace B only. - 1415
vak_config::paths::isolate_home_for_tests(); - 1416
let core_b = Core::new(cwd_b.clone()).unwrap(); - 1417
core_b.set_sessions_home(home.clone()); - 1418
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 1419
let addr = listener.local_addr().unwrap(); - 1420
let (app, token) = vak_server::secured_router(core_b); - 1421
tokio::spawn(async move { - 1422
axum::serve(listener, app).await.unwrap(); - 1423
}); - 1424
let base = format!("http://{addr}"); - 1425
let client = client_with(&token); - 1426
- 1427
let res = client - 1428
.delete(format!("{base}/sessions/archived")) - 1429
.send() - 1430
.await - 1431
.unwrap(); - 1432
assert_eq!(res.status(), 200); - 1433
let body: serde_json::Value = res.json().await.unwrap(); - 1434
assert_eq!(body["trashed"], 1, "only workspace B's own session, {body}"); - 1435
- 1436
let deleted: serde_json::Value = - 1437
serde_json::from_str(&std::fs::read_to_string(home.join("deleted.json")).unwrap()).unwrap(); - 1438
assert_eq!( - 1439
deleted[&id_b], true, - 1440
"workspace B's archived session must be deleted" - 1441
); - 1442
assert!( - 1443
deleted.get(&id_a).is_none() || deleted[&id_a] != true, - 1444
"workspace A's archived session must survive a bulk delete run from workspace B" - 1445
); - 1446
} - 1447
- 1448
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1449
async fn skills_listing_and_pascalcase_mode() { - 1450
let dir = tempfile::tempdir().unwrap(); - 1451
let cwd = dir.path().to_path_buf(); - 1452
let skill_dir = cwd.join(".vak/skills/tdd"); - 1453
std::fs::create_dir_all(&skill_dir).unwrap(); - 1454
std::fs::write( - 1455
skill_dir.join("SKILL.md"), - 1456
"---\nname: tdd\ndescription: red green refactor loop\n---\nbody", - 1457
) - 1458
.unwrap(); - 1459
std::mem::forget(dir); - 1460
- 1461
vak_config::paths::isolate_home_for_tests(); - 1462
let core = Core::new(cwd.clone()).unwrap(); - 1463
core.set_sessions_home(cwd.join("home")); - 1464
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 1465
let addr = listener.local_addr().unwrap(); - 1466
let (app, token) = vak_server::secured_router(core); - 1467
tokio::spawn(async move { - 1468
axum::serve(listener, app).await.unwrap(); - 1469
}); - 1470
let base = format!("http://{addr}"); - 1471
let client = client_with(&token); - 1472
- 1473
let skills: serde_json::Value = client - 1474
.get(format!("{base}/skills")) - 1475
.send() - 1476
.await - 1477
.unwrap() - 1478
.json() - 1479
.await - 1480
.unwrap(); - 1481
let names: Vec<&str> = skills["skills"] - 1482
.as_array() - 1483
.unwrap() - 1484
.iter() - 1485
.map(|s| s["name"].as_str().unwrap()) - 1486
.collect(); - 1487
assert!(names.contains(&"tdd")); - 1488
let tdd = skills["skills"] - 1489
.as_array() - 1490
.unwrap() - 1491
.iter() - 1492
.find(|s| s["name"] == "tdd") - 1493
.unwrap(); - 1494
assert_eq!(tdd["description"], "red green refactor loop"); - 1495
- 1496
// The status bar sends the Debug spelling surfaced by /health. - 1497
for mode in [ - 1498
"ReadOnly", - 1499
"WorkspaceWrite", - 1500
"FullAccess", - 1501
"workspace-write", - 1502
] { - 1503
let res = client - 1504
.post(format!("{base}/config/mode")) - 1505
.json(&serde_json::json!({ "mode": mode })) - 1506
.send() - 1507
.await - 1508
.unwrap(); - 1509
assert_eq!(res.status(), 200, "mode {mode} must parse"); - 1510
} - 1511
let bad = client - 1512
.post(format!("{base}/config/mode")) - 1513
.json(&serde_json::json!({ "mode": "yolo" })) - 1514
.send() - 1515
.await - 1516
.unwrap(); - 1517
assert_eq!(bad.status(), 400); - 1518
} - 1519
- 1520
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1521
async fn providers_listing_and_key_storage_roundtrip() { - 1522
let dir = tempfile::tempdir().unwrap(); - 1523
let cwd = dir.path().to_path_buf(); - 1524
std::mem::forget(dir); - 1525
- 1526
vak_config::paths::isolate_home_for_tests(); - 1527
let core = Core::new(cwd.clone()).unwrap(); - 1528
core.set_sessions_home(cwd.join("home")); - 1529
// Hermetic secret store: never touch the developer's real ~/.vak. - 1530
let user_env = cwd.join("user-home/.vak/.env"); - 1531
core.set_user_env_path(user_env.clone()); - 1532
// Pin the current provider so the assertion reflects THIS fixture, not - 1533
// whatever default the developer's global config selects. - 1534
core.set_provider("anthropic".to_string()); - 1535
- 1536
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 1537
let addr = listener.local_addr().unwrap(); - 1538
let (app, token) = vak_server::secured_router(core); - 1539
tokio::spawn(async move { - 1540
axum::serve(listener, app).await.unwrap(); - 1541
}); - 1542
let base = format!("http://{addr}"); - 1543
let client = client_with(&token); - 1544
- 1545
// Listing: names, env var mapping, configured flags, curated models. - 1546
let listed: serde_json::Value = client - 1547
.get(format!("{base}/providers")) - 1548
.send() - 1549
.await - 1550
.unwrap() - 1551
.json() - 1552
.await - 1553
.unwrap(); - 1554
assert_eq!(listed["current"], "anthropic"); - 1555
let providers = listed["providers"].as_array().unwrap(); - 1556
let anthropic = providers - 1557
.iter() - 1558
.find(|p| p["name"] == "anthropic") - 1559
.expect("anthropic must be listed"); - 1560
assert_eq!(anthropic["env_var"], "ANTHROPIC_API_KEY"); - 1561
assert_eq!(anthropic["requires_key"], true); - 1562
// Nothing is configured in this hermetic environment. - 1563
assert_eq!(anthropic["configured"], false); - 1564
let ollama = providers.iter().find(|p| p["name"] == "ollama").unwrap(); - 1565
assert_eq!(ollama["requires_key"], false); - 1566
assert_eq!(ollama["configured"], true); - 1567
// Model lists are discovered per provider, never shipped in this - 1568
// payload — a static catalogue would drift from what the key reaches. - 1569
assert!( - 1570
listed.get("models").is_none(), - 1571
"/providers must not ship a hardcoded model catalogue" - 1572
); - 1573
assert_eq!(listed["current_configured"], false); - 1574
- 1575
// Saving a key: effective immediately, persisted to the store, never - 1576
// echoed back. - 1577
let saved: serde_json::Value = client - 1578
.put(format!("{base}/config/key")) - 1579
.json(&serde_json::json!({ "provider": "opencode-zen", "key": " sk-test-123 " })) - 1580
.send() - 1581
.await - 1582
.unwrap() - 1583
.json() - 1584
.await - 1585
.unwrap(); - 1586
assert_eq!(saved["provider"], "opencode-zen"); - 1587
assert_eq!(saved["env_var"], "OPENCODE_API_KEY"); - 1588
assert_eq!(saved["configured"], true); - 1589
let body_text = serde_json::to_string(&saved).unwrap(); - 1590
assert!(!body_text.contains("sk-test-123"), "key must not echo back"); - 1591
- 1592
assert_eq!( - 1593
vak_config::read_env_file_var(&user_env, "OPENCODE_API_KEY"), - 1594
Some("sk-test-123".to_string()) - 1595
); - 1596
- 1597
// The runtime override makes it visible to auth lookups instantly. - 1598
let after: serde_json::Value = client - 1599
.get(format!("{base}/providers")) - 1600
.send() - 1601
.await - 1602
.unwrap() - 1603
.json() - 1604
.await - 1605
.unwrap(); - 1606
let zen = after["providers"] - 1607
.as_array() - 1608
.unwrap() - 1609
.iter() - 1610
.find(|p| p["name"] == "opencode-zen") - 1611
.unwrap(); - 1612
assert_eq!(zen["configured"], true); - 1613
assert_eq!( - 1614
zen["label"], "OpenCode Zen", - 1615
"everyday screens show the service's name, not its id" - 1616
); - 1617
- 1618
// Re-saving replaces the line instead of appending duplicates. - 1619
client - 1620
.put(format!("{base}/config/key")) - 1621
.json(&serde_json::json!({ "provider": "opencode-zen", "key": "sk-test-456" })) - 1622
.send() - 1623
.await - 1624
.unwrap(); - 1625
assert_eq!( - 1626
vak_config::read_env_file_var(&user_env, "OPENCODE_API_KEY"), - 1627
Some("sk-test-456".to_string()), - 1628
"upsert must replace, not duplicate" - 1629
); - 1630
- 1631
// Revoking strips the key, leaves an unrelated one in the same scope - 1632
// intact, and flips the provider back to unconfigured. - 1633
vak_config::upsert_env_file(&user_env, "UNRELATED_VALUE", "keep-me").unwrap(); - 1634
let removed: serde_json::Value = client - 1635
.delete(format!("{base}/config/key")) - 1636
.json(&serde_json::json!({ "provider": "opencode-zen" })) - 1637
.send() - 1638
.await - 1639
.unwrap() - 1640
.json() - 1641
.await - 1642
.unwrap(); - 1643
assert_eq!(removed["env_var"], "OPENCODE_API_KEY"); - 1644
assert_eq!(removed["configured"], false); - 1645
assert_eq!(removed["shadowed_by_env"], false); - 1646
assert_eq!( - 1647
vak_config::read_env_file_var(&user_env, "OPENCODE_API_KEY"), - 1648
None, - 1649
"key must be gone" - 1650
); - 1651
assert_eq!( - 1652
vak_config::read_env_file_var(&user_env, "UNRELATED_VALUE"), - 1653
Some("keep-me".to_string()), - 1654
"revoking one key must not disturb another in the same scope" - 1655
); - 1656
let after_remove: serde_json::Value = client - 1657
.get(format!("{base}/providers")) - 1658
.send() - 1659
.await - 1660
.unwrap() - 1661
.json() - 1662
.await - 1663
.unwrap(); - 1664
let zen = after_remove["providers"] - 1665
.as_array() - 1666
.unwrap() - 1667
.iter() - 1668
.find(|p| p["name"] == "opencode-zen") - 1669
.unwrap(); - 1670
assert_eq!(zen["configured"], false); - 1671
- 1672
// Revoking a provider we do not know is a value, not a panic. - 1673
let bogus = client - 1674
.delete(format!("{base}/config/key")) - 1675
.json(&serde_json::json!({ "provider": "nope" })) - 1676
.send() - 1677
.await - 1678
.unwrap(); - 1679
assert_eq!(bogus.status(), 400); - 1680
- 1681
// Discovery for an unknown provider is a 404, and for a configured-but - 1682
// -unreachable one it reports the reason rather than a fallback list. - 1683
let unknown_models = client - 1684
.get(format!("{base}/providers/nope/models")) - 1685
.send() - 1686
.await - 1687
.unwrap(); - 1688
assert_eq!(unknown_models.status(), 404); - 1689
- 1690
// Discovery for a provider whose key was just removed keeps the precise - 1691
// message for an operator and types it, so a client can say "needs an - 1692
// account key" without matching on the text. - 1693
let keyless_models = client - 1694
.get(format!("{base}/providers/opencode-zen/models")) - 1695
.send() - 1696
.await - 1697
.unwrap(); - 1698
assert_eq!(keyless_models.status(), 502); - 1699
let keyless: serde_json::Value = keyless_models.json().await.unwrap(); - 1700
assert_eq!(keyless["kind"], "no_ai_service"); - 1701
assert_eq!(keyless["provider"], "opencode-zen"); - 1702
assert!( - 1703
keyless["error"] - 1704
.as_str() - 1705
.is_some_and(|e| e.contains("OPENCODE_API_KEY")), - 1706
"the message still names what to set, got: {keyless}" - 1707
); - 1708
- 1709
// Unknown and keyless providers are rejected as values, not panics. - 1710
let unknown = client - 1711
.put(format!("{base}/config/key")) - 1712
.json(&serde_json::json!({ "provider": "nope", "key": "x" })) - 1713
.send() - 1714
.await - 1715
.unwrap(); - 1716
assert_eq!(unknown.status(), 400); - 1717
let ollama_key = client - 1718
.put(format!("{base}/config/key")) - 1719
.json(&serde_json::json!({ "provider": "ollama", "key": "x" })) - 1720
.send() - 1721
.await - 1722
.unwrap(); - 1723
assert_eq!(ollama_key.status(), 400); - 1724
} - 1725
- 1726
/// The guided first task is read-only **even when the workspace is - 1727
/// full-access** (doc 46 security invariant 5). - 1728
/// - 1729
/// This is the assertion that makes the guarantee real rather than - 1730
/// documented: the workspace below is configured `full-access` and - 1731
/// trusted, so an ordinary session there would be unsandboxed — and the - 1732
/// starter session still comes back ReadOnly, because the cap is applied - 1733
/// by `CorePool`'s `capped_by` (a `min`) and carried on the handle the run - 1734
/// path uses. - 1735
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1736
async fn the_first_task_is_read_only_even_in_a_full_access_workspace() { - 1737
vak_config::paths::isolate_home_for_tests(); - 1738
let (base, token, cwd, _server) = spawn_secured(Arc::new(Scripted { - 1739
responses: Mutex::new(VecDeque::new()), - 1740
})) - 1741
.await; - 1742
let client = client_with(&token); - 1743
- 1744
// Make the workspace as permissive as it can be. - 1745
std::fs::write( - 1746
cwd.join(".vak/config.toml"), - 1747
"permission_mode = \"full-access\"\n[memory]\nreflection = false\n", - 1748
) - 1749
.unwrap(); - 1750
client - 1751
.post(format!("{base}/config/mode")) - 1752
.json(&serde_json::json!({ "mode": "full-access" })) - 1753
.send() - 1754
.await - 1755
.unwrap(); - 1756
let config: serde_json::Value = client - 1757
.get(format!("{base}/config")) - 1758
.send() - 1759
.await - 1760
.unwrap() - 1761
.json() - 1762
.await - 1763
.unwrap(); - 1764
assert_eq!( - 1765
config["permission_mode"], "FullAccess", - 1766
"precondition: the workspace really is unrestricted" - 1767
); - 1768
- 1769
let started: serde_json::Value = client - 1770
.post(format!("{base}/onboarding/first-task")) - 1771
.send() - 1772
.await - 1773
.unwrap() - 1774
.json() - 1775
.await - 1776
.unwrap(); - 1777
- 1778
assert_eq!( - 1779
started["permission_mode"], "ReadOnly", - 1780
"the guided first task is capped regardless of the workspace mode" - 1781
); - 1782
assert!( - 1783
started["session_id"] - 1784
.as_str() - 1785
.is_some_and(|s| !s.is_empty()), - 1786
"and it produced a real session to run" - 1787
); - 1788
assert!( - 1789
started["prompt"] - 1790
.as_str() - 1791
.is_some_and(|p| p.contains("Do not modify files")), - 1792
"the prompt is ours, not the caller's" - 1793
); - 1794
} - 1795
- 1796
/// The trust review describes a workspace **without loading it**. - 1797
/// - 1798
/// Describing a project's privileged config by parsing it through the - 1799
/// normal loader would activate the very thing the operator is being - 1800
/// asked to decide about (doc 46, Step 2). This asserts the review - 1801
/// reports what the file asks for while the Core serving it stays - 1802
/// untrusted — so nothing in that file took effect. - 1803
#[tokio::test] - 1804
async fn a_workspace_review_reports_privileges_without_granting_them() { - 1805
vak_config::paths::isolate_home_for_tests(); - 1806
let (base, token, cwd, _server) = spawn_secured(Arc::new(Scripted { - 1807
responses: Mutex::new(VecDeque::new()), - 1808
})) - 1809
.await; - 1810
let client = client_with(&token); - 1811
- 1812
// A workspace that asks for execution power. - 1813
let project = cwd.join("hostile"); - 1814
std::fs::create_dir_all(project.join(".vak")).unwrap(); - 1815
std::fs::write( - 1816
project.join(".vak/config.toml"), - 1817
"permission_mode = \"full-access\"\n[mcp.servers.thing]\ncommand = \"sh\"\n", - 1818
) - 1819
.unwrap(); - 1820
- 1821
let review: serde_json::Value = client - 1822
.post(format!("{base}/onboarding/workspace-review")) - 1823
.json(&serde_json::json!({ "path": project })) - 1824
.send() - 1825
.await - 1826
.unwrap() - 1827
.json() - 1828
.await - 1829
.unwrap(); - 1830
- 1831
assert_eq!(review["requests_privilege"], true); - 1832
assert_eq!(review["trusted"], false, "reviewing is not trusting"); - 1833
let named = review["privileges"].as_array().unwrap(); - 1834
assert!( - 1835
named.iter().any(|p| p == "a permission mode"), - 1836
"the review names what the project asks for: {named:?}" - 1837
); - 1838
assert!( - 1839
named.iter().any(|p| p == "external tool servers"), - 1840
"{named:?}" - 1841
); - 1842
- 1843
// The reviewed mode is not in force anywhere: the server's own Core - 1844
// never loaded that file. - 1845
let config: serde_json::Value = client - 1846
.get(format!("{base}/config")) - 1847
.send() - 1848
.await - 1849
.unwrap() - 1850
.json() - 1851
.await - 1852
.unwrap(); - 1853
assert_ne!( - 1854
config["permission_mode"], "FullAccess", - 1855
"a reviewed workspace must not have granted itself anything" - 1856
); - 1857
} - 1858
- 1859
/// A bot's token round-trips through its own id-addressed route, and is - 1860
/// never returned once stored. - 1861
/// - 1862
/// This replaces a test that exercised `PUT/DELETE /config/telegram-token` - 1863
/// — a per-surface credential slot that could describe only one bot per - 1864
/// transport. A surface is a transport, not a credential slot (AGENTS.md - 1865
/// invariant 23), so the slot and its route are gone. - 1866
#[tokio::test] - 1867
async fn bot_token_storage_roundtrip() { - 1868
let dir = tempfile::tempdir().unwrap(); - 1869
let cwd = dir.path().to_path_buf(); - 1870
std::mem::forget(dir); - 1871
- 1872
vak_config::paths::isolate_home_for_tests(); - 1873
let core = Core::new(cwd.clone()).unwrap(); - 1874
core.set_sessions_home(cwd.join("home")); - 1875
// Hermetic secret store: never touch the developer's real home. - 1876
let user_env = cwd.join("user-home/.vak/.env"); - 1877
std::fs::create_dir_all(user_env.parent().unwrap()).unwrap(); - 1878
core.set_user_env_path(user_env.clone()); - 1879
- 1880
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - 1881
let addr = listener.local_addr().unwrap(); - 1882
let (app, token) = vak_server::secured_router(core); - 1883
tokio::spawn(async move { - 1884
axum::serve(listener, app).await.unwrap(); - 1885
}); - 1886
let base = format!("http://{addr}"); - 1887
let client = client_with(&token); - 1888
- 1889
let created = client - 1890
.post(format!("{base}/gateway/bots")) - 1891
.json(&serde_json::json!({ - 1892
"id": "support", - 1893
"surface": "telegram", - 1894
"label": "Support", - 1895
})) - 1896
.send() - 1897
.await - 1898
.unwrap(); - 1899
assert_eq!(created.status(), 200, "a bot is created explicitly"); - 1900
- 1901
let listed: serde_json::Value = client - 1902
.get(format!("{base}/gateway/bots")) - 1903
.send() - 1904
.await - 1905
.unwrap() - 1906
.json() - 1907
.await - 1908
.unwrap(); - 1909
assert_eq!(listed["bots"][0]["token_configured"], false); - 1910
- 1911
let saved = client - 1912
.put(format!("{base}/gateway/bots/support/token")) - 1913
.json(&serde_json::json!({ "token": "123:abc" })) - 1914
.send() - 1915
.await - 1916
.unwrap(); - 1917
assert_eq!(saved.status(), 200); - 1918
let saved: serde_json::Value = saved.json().await.unwrap(); - 1919
assert!( - 1920
!saved.to_string().contains("123:abc"), - 1921
"the token is never returned once stored" - 1922
); - 1923
- 1924
let after: serde_json::Value = client - 1925
.get(format!("{base}/gateway/bots")) - 1926
.send() - 1927
.await - 1928
.unwrap() - 1929
.json() - 1930
.await - 1931
.unwrap(); - 1932
assert_eq!(after["bots"][0]["token_configured"], true); - 1933
- 1934
let removed = client - 1935
.delete(format!("{base}/gateway/bots/support/token")) - 1936
.send() - 1937
.await - 1938
.unwrap(); - 1939
assert_eq!(removed.status(), 200); - 1940
- 1941
let after_remove: serde_json::Value = client - 1942
.get(format!("{base}/gateway/bots")) - 1943
.send() - 1944
.await - 1945
.unwrap() - 1946
.json() - 1947
.await - 1948
.unwrap(); - 1949
assert_eq!(after_remove["bots"][0]["token_configured"], false); - 1950
} - 1951
- 1952
/// `PUT /config/hooks` used to drop a disabled hook from `config.toml` - 1953
/// entirely instead of recording it as off (there was nowhere in - 1954
/// `[[hooks]]` to say "off" before `HookConfig::enabled` existed), and `GET - 1955
/// /config/hooks` always reported `enabled: true` regardless. A console - 1956
/// toggle unchecking "enabled" therefore deleted the hook's definition - 1957
/// outright rather than pausing it. This locks in both the round-trip and - 1958
/// that `get_hooks` reports the *project* file's own hooks, not the - 1959
/// runtime-merged effective set (see the vak-config `merge_into` tests for - 1960
/// why the merged set must never be resubmitted here). - 1961
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] - 1962
async fn hooks_roundtrip_preserves_disabled_hooks_instead_of_deleting_them() { - 1963
let (base, token, cwd, _server) = spawn_secured(Arc::new(Scripted { - 1964
responses: Mutex::new(VecDeque::new()), - 1965
})) - 1966
.await; - 1967
let client = client_with(&token); - 1968
- 1969
let put = client - 1970
.put(format!("{base}/config/hooks")) - 1971
.json(&serde_json::json!({ - 1972
"hooks": [ - 1973
{"event": "pre_tool_use", "matcher": null, "command": "echo on", "timeout_ms": 5000, "enabled": true}, - 1974
{"event": "stop", "matcher": null, "command": "echo off", "timeout_ms": 5000, "enabled": false}, - 1975
] - 1976
})) - 1977
.send() - 1978
.await - 1979
.unwrap(); - 1980
assert_eq!(put.status(), 200); - 1981
// The success payload counts only the *enabled* hooks written — one of - 1982
// the two, here. - 1983
let put_body: serde_json::Value = put.json().await.unwrap(); - 1984
assert_eq!(put_body["count"], 1); - 1985
- 1986
let got: serde_json::Value = client - 1987
.get(format!("{base}/config/hooks")) - 1988
.send() - 1989
.await - 1990
.unwrap() - 1991
.json() - 1992
.await - 1993
.unwrap(); - 1994
let hooks = got["hooks"].as_array().unwrap(); - 1995
assert_eq!( - 1996
hooks.len(), - 1997
2, - 1998
"the disabled hook must still be listed: {hooks:?}" - 1999
); - 2000
let off = hooks
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.