- 1
//! Canonical install/data layout (docs/design/32-release-engineering.md, - 2
//! "Canonical layout" section). THE single source of truth for where - 3
//! vak puts binaries, data, caches, and logs on each platform. - 4
//! - 5
//! Every crate resolves paths through these functions — never by hand- - 6
//! rolling `HOME/.vak` again. The 0.7 drift incident (services kept - 7
//! executing an old image while five call sites disagreed about home) - 8
//! is the standing reason this module exists. - 9
//! - 10
//! macOS (Apple File System Programming Guide): - 11
//! data ~/Library/Application Support/vak - 12
//! cache ~/Library/Caches/vak - 13
//! logs ~/Library/Logs/vak - 14
//! Linux (XDG Base Directory Specification): - 15
//! data $XDG_DATA_HOME/vak (~/.local/share/vak) - 16
//! cache $XDG_CACHE_HOME/vak (~/.cache/vak) - 17
//! logs $XDG_STATE_HOME/vak/logs (~/.local/state/vak/logs) - 18
//! - 19
//! `VAK_HOME` overrides the DATA home everywhere — an explicit override - 20
//! is the user's layout choice and yields a self-contained tree. - 21
- 22
use std::path::PathBuf; - 23
- 24
use crate::get_var; - 25
- 26
/// The user data home: sessions, memory, tasks, config state, audit logs. - 27
pub fn data_home() -> PathBuf { - 28
resolve(get_var("VAK_HOME").as_deref()).data - 29
} - 30
- 31
/// Rebuildable artifacts only (the SQLite FTS index and WAL sidecars). - 32
/// Deleting this directory must always be safe; it is rebuilt from JSONL. - 33
pub fn cache_home() -> PathBuf { - 34
resolve(get_var("VAK_HOME").as_deref()).cache - 35
} - 36
- 37
/// Service + CLI log files (Console.app-visible on macOS). - 38
pub fn logs_dir() -> PathBuf { - 39
resolve(get_var("VAK_HOME").as_deref()).logs - 40
} - 41
- 42
/// The canonical **project workspace** a fresh install brings up its - 43
/// durable services against — `~/vak-home`, a plain directory a person - 44
/// can `cd` into, distinct from `data_home()` (which holds sessions, - 45
/// config, and other application-managed state, not something a user - 46
/// browses or edits directly). - 47
/// - 48
/// This exists because nothing previously named a workspace at install - 49
/// time: `self install` placed binaries, and `self services-sync` - 50
/// captured whatever directory it happened to be run from as the - 51
/// gateway's workspace (docs/design/32 invariant 3) — correct as a - 52
/// mechanism, but with no answer to "which directory" until an operator - 53
/// picked one. A real incident: services-sync was run from inside the - 54
/// vak *source checkout* while developing it, silently binding an - 55
/// always-on Telegram bridge to the tool's own dev repo. `self install` - 56
/// now creates this directory and syncs services against it on a truly - 57
/// fresh install (no prior units), so there is always a sane, isolated - 58
/// default — never the workspace a person happened to be standing in. - 59
pub fn default_workspace() -> PathBuf { - 60
resolve(get_var("VAK_HOME").as_deref()).workspace - 61
} - 62
- 63
/// The gateway's persisted workspace selection. An absent or malformed - 64
/// selection intentionally falls back to the canonical default workspace. - 65
pub fn gateway_workspace() -> PathBuf { - 66
let homes = resolve(get_var("VAK_HOME").as_deref()); - 67
gateway_workspace_at(&homes.data, &homes.workspace) - 68
} - 69
- 70
/// Per-agent data home: sessions, memory, and agent-specific config. - 71
/// Under data_home()/agents/<agent_id>/ - 72
pub fn agent_home(agent_id: &str) -> PathBuf { - 73
agent_home_at(&data_home(), agent_id) - 74
} - 75
- 76
/// Per-agent data home resolved from an explicit data home root. - 77
pub fn agent_home_at(data: &std::path::Path, agent_id: &str) -> PathBuf { - 78
data.join("agents").join(agent_id) - 79
} - 80
- 81
/// Per-session collaboration records for shared Office drafts. - 82
/// Session IDs are generated by Vak and callers must validate externally - 83
/// supplied values before resolving them here. - 84
pub fn office_workspaces_at(data: &std::path::Path, session_id: &str) -> PathBuf { - 85
data.join("office-workspaces").join(session_id) - 86
} - 87
- 88
/// Per-agent project workspace (the directory file/shell tools operate in), - 89
/// distinct from `agent_home` (which holds sessions/memory). The built-in - 90
/// "vak" agent keeps using the base workspace so existing single-agent - 91
/// installs see no path change; every other agent gets an isolated - 92
/// subdirectory nested *under that same base workspace*, so agents never see - 93
/// each other's files, while the same agent id opened against two different - 94
/// base workspaces (e.g. two server instances, or a gateway channel pointed - 95
/// at a different project) still resolves to two independently isolated - 96
/// workspaces rather than one shared global directory keyed on agent id - 97
/// alone. - 98
pub fn agent_workspace(base: &std::path::Path, agent_id: &str) -> PathBuf { - 99
if agent_id == "vak" { - 100
base.to_path_buf() - 101
} else { - 102
base.join(".vak/agents").join(agent_id).join("workspace") - 103
} - 104
} - 105
- 106
/// Resolve the gateway workspace from an explicit data home. This variant - 107
/// keeps server tests isolated when a `Core` uses a temporary sessions home. - 108
pub fn gateway_workspace_at(data: &std::path::Path, default: &std::path::Path) -> PathBuf { - 109
let path = data.join("gateway/default-workspace"); - 110
std::fs::read_to_string(path) - 111
.ok() - 112
.map(|raw| PathBuf::from(raw.trim())) - 113
.filter(|workspace| workspace.is_absolute() && workspace.is_dir()) - 114
.unwrap_or_else(|| default.to_path_buf()) - 115
} - 116
- 117
/// Persist or clear the user-selected gateway workspace atomically. - 118
pub fn persist_gateway_workspace_at( - 119
data: &std::path::Path, - 120
workspace: Option<&std::path::Path>, - 121
) -> Result<(), std::io::Error> { - 122
let dir = data.join("gateway"); - 123
std::fs::create_dir_all(&dir)?; - 124
let path = dir.join("default-workspace"); - 125
match workspace { - 126
Some(workspace) => { - 127
let temp = dir.join(format!(".default-workspace.{}.tmp", std::process::id())); - 128
std::fs::write(&temp, format!("{}\n", workspace.display()))?; - 129
std::fs::rename(temp, path)?; - 130
} - 131
None => match std::fs::remove_file(path) { - 132
Ok(()) => {} - 133
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - 134
Err(error) => return Err(error), - 135
}, - 136
} - 137
Ok(()) - 138
} - 139
- 140
/// Canonical toolchain search directories across standard locations (Homebrew, Cargo, Local, System, version managers). - 141
/// Essential for macOS GUI app bundles and daemon/service processes where shell profile is not evaluated. - 142
pub fn canonical_toolchain_paths() -> Vec<PathBuf> { - 143
let home = base_home(); - 144
let mut dirs = Vec::new(); - 145
- 146
// Homebrew / standard system paths - 147
for dir in [ - 148
"/opt/homebrew/bin", - 149
"/opt/homebrew/sbin", - 150
"/usr/local/bin", - 151
"/usr/local/sbin", - 152
"/usr/bin", - 153
"/bin", - 154
"/usr/sbin", - 155
"/sbin", - 156
] { - 157
let p = PathBuf::from(dir); - 158
if p.is_dir() && !dirs.contains(&p) { - 159
dirs.push(p); - 160
} - 161
} - 162
- 163
// User toolchains - 164
for sub in [ - 165
".cargo/bin", - 166
".local/bin", - 167
"bin", - 168
".local/share/pnpm", - 169
"Library/pnpm", - 170
".local/share/fnm/current/bin", - 171
".fnm/current/bin", - 172
".asdf/shims", - 173
".local/share/mise/shims", - 174
".pyenv/shims", - 175
] { - 176
let p = home.join(sub); - 177
if p.is_dir() && !dirs.contains(&p) { - 178
dirs.push(p); - 179
} - 180
} - 181
- 182
// Scan active node versions in nvm - 183
let nvm_node = home.join(".nvm/versions/node"); - 184
if nvm_node.is_dir() - 185
&& let Ok(entries) = std::fs::read_dir(&nvm_node) - 186
{ - 187
for entry in entries.flatten() { - 188
let bin = entry.path().join("bin"); - 189
if bin.is_dir() && !dirs.contains(&bin) { - 190
dirs.push(bin); - 191
} - 192
} - 193
} - 194
- 195
dirs - 196
} - 197
- 198
/// Computes an augmented PATH value merging the current process PATH with canonical toolchain paths. - 199
pub fn augmented_process_path() -> std::ffi::OsString { - 200
let mut parts: Vec<PathBuf> = Vec::new(); - 201
if let Some(existing) = std::env::var_os("PATH") { - 202
parts.extend(std::env::split_paths(&existing).filter(|p| !p.as_os_str().is_empty())); - 203
} - 204
for toolchain_path in canonical_toolchain_paths() { - 205
if !parts.contains(&toolchain_path) { - 206
parts.push(toolchain_path); - 207
} - 208
} - 209
std::env::join_paths(parts).unwrap_or_default() - 210
} - 211
- 212
/// Every home derived from one override decision. Pure so tests can - 213
/// exercise both branches without touching process-global environment, - 214
/// and so a caller needing two of them reads `VAK_HOME` once rather than - 215
/// pairing values from before and after another thread changed it. - 216
struct Homes { - 217
data: PathBuf, - 218
cache: PathBuf, - 219
logs: PathBuf, - 220
workspace: PathBuf, - 221
} - 222
- 223
fn resolve(override_home: Option<&str>) -> Homes { - 224
let base = base_home(); - 225
match override_home.filter(|s| !s.is_empty()) { - 226
// An explicit override is a self-contained sandbox: everything - 227
// nests under it so tests and portable installs stay one tree, - 228
// including the Shared config's workspace, which would otherwise - 229
// inherit the operator's real ~/vak-home state. - 230
Some(h) => { - 231
let data = PathBuf::from(h); - 232
Homes { - 233
cache: data.join("cache"), - 234
logs: data.join("logs"), - 235
workspace: data.join("vak-home"), - 236
data, - 237
} - 238
} - 239
None => Homes { - 240
workspace: base.join("vak-home"), - 241
#[cfg(target_os = "macos")] - 242
data: base - 243
.join("Library") - 244
.join("Application Support") - 245
.join(app_dir_name()), - 246
#[cfg(target_os = "macos")] - 247
cache: base.join("Library").join("Caches").join(app_dir_name()), - 248
#[cfg(target_os = "macos")] - 249
logs: base.join("Library").join("Logs").join(app_dir_name()), - 250
#[cfg(not(target_os = "macos"))] - 251
data: xdg(&base, "XDG_DATA_HOME", ".local/share"), - 252
#[cfg(not(target_os = "macos"))] - 253
cache: xdg(&base, "XDG_CACHE_HOME", ".cache"), - 254
#[cfg(not(target_os = "macos"))] - 255
logs: xdg(&base, "XDG_STATE_HOME", ".local/state").join("logs"), - 256
}, - 257
} - 258
} - 259
- 260
#[cfg(not(target_os = "macos"))] - 261
fn xdg(base: &std::path::Path, env_key: &str, default_suffix: &str) -> PathBuf { - 262
if let Some(v) = std::env::var_os(env_key) - 263
&& !v.is_empty() - 264
{ - 265
return PathBuf::from(v).join(app_dir_name()); - 266
} - 267
base.join(default_suffix).join(app_dir_name()) - 268
} - 269
- 270
fn app_dir_name() -> &'static str { - 271
"vak" - 272
} - 273
- 274
fn base_home() -> PathBuf { - 275
let environment_home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")); - 276
#[allow(deprecated)] - 277
let account_home = std::env::home_dir(); - 278
resolve_base_home(environment_home.map(PathBuf::from), account_home) - 279
} - 280
- 281
fn resolve_base_home(environment_home: Option<PathBuf>, account_home: Option<PathBuf>) -> PathBuf { - 282
environment_home - 283
.filter(|path| path.is_absolute()) - 284
.or_else(|| account_home.filter(|path| path.is_absolute())) - 285
.unwrap_or_else(|| PathBuf::from("/")) - 286
} - 287
- 288
/// Pin this process's installation home, at the highest precedence. - 289
/// - 290
/// `VAK_HOME` is normally read from the environment, which makes it - 291
/// awkward to set from inside a test: `std::env::set_var` is `unsafe`, and - 292
/// `unsafe_code` is denied workspace-wide (AGENTS.md invariant 6). The - 293
/// override map [`crate::set_override`] already sits above the real - 294
/// environment in [`crate::get_var`]'s precedence, so pinning the home is - 295
/// safe and needs no `unsafe` at all. - 296
pub fn set_home_override(path: &std::path::Path) { - 297
crate::set_override("VAK_HOME", path.to_string_lossy().into_owned()); - 298
} - 299
- 300
/// Point this process at a private, empty home, and return it. - 301
/// - 302
/// **Every test that builds a `Core` must call this.** Without it, - 303
/// `load_with_trust` reads the operator's real Shared layer - 304
/// (`~/vak-home/.vak/config.toml` and the real Shared secret scope), so a - 305
/// personal setting silently changes what the test exercises — a real MCP server - 306
/// gets advertised, `[memory] reflection = true` consumes a scripted - 307
/// provider response, a real provider key makes an "unconfigured" case - 308
/// pass. Tests were reading the developer's machine. - 309
/// - 310
/// Idempotent per process: the first call wins and later ones return the - 311
/// same directory, so tests sharing a binary share one home. That is the - 312
/// isolation that matters — each test already scopes its own cwd and - 313
/// sessions home. The directory is always a new one: pids are recycled, and - 314
/// a home an earlier process left (seeded Shared skills and plugins, a - 315
/// Shared config) would otherwise be this process's starting state. - 316
/// - 317
/// Sharing holds only while no test in the binary writes the Shared layer, - 318
/// which every `Core::new` reads. A test that must write it runs in a - 319
/// binary of its own, on a private home per test — see - 320
/// `crates/vak-server/tests/shared_config_layer.rs`. - 321
pub fn isolate_home_for_tests() -> PathBuf { - 322
static ISOLATED: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new(); - 323
ISOLATED - 324
.get_or_init(|| { - 325
let dir = fresh_test_home(); - 326
set_home_override(&dir); - 327
dir - 328
}) - 329
.clone() - 330
} - 331
- 332
/// A directory under the temp dir that this call created, so nothing an - 333
/// earlier process left can be in it. - 334
fn fresh_test_home() -> PathBuf { - 335
let base = std::env::temp_dir(); - 336
let _ = std::fs::create_dir_all(&base); - 337
let pid = std::process::id(); - 338
let mut dir = base.join(format!("vak-test-home-{pid}")); - 339
let mut taken = 0u64; - 340
while std::fs::create_dir(&dir).is_err_and(|e| e.kind() == std::io::ErrorKind::AlreadyExists) { - 341
taken += 1; - 342
dir = base.join(format!("vak-test-home-{pid}-{taken}")); - 343
} - 344
dir - 345
} - 346
- 347
#[cfg(test)] - 348
mod tests { - 349
#![allow(clippy::unwrap_used, clippy::expect_used)] - 350
- 351
use super::*; - 352
- 353
/// The override must win everywhere and produce a self-contained - 354
/// tree (tests + portable installs depend on one-directory depth). - 355
#[test] - 356
fn override_pins_every_directory_to_one_tree() { - 357
let sandbox = tempfile::tempdir().unwrap(); - 358
let h = resolve(Some(sandbox.path().to_str().unwrap())); - 359
assert_eq!(h.data, sandbox.path()); - 360
assert_eq!( - 361
h.logs, - 362
h.data.join("logs"), - 363
"overridden homes are self-contained" - 364
); - 365
assert_eq!(h.cache, h.data.join("cache")); - 366
} - 367
- 368
#[test] - 369
fn default_workspace_is_a_plain_dir_under_the_account_home_not_data_home() { - 370
let canonical = resolve(None); - 371
assert_eq!(canonical.workspace, base_home().join("vak-home")); - 372
assert_ne!( - 373
canonical.workspace, canonical.data, - 374
"the default workspace must never collide with the app's own data home" - 375
); - 376
- 377
let sandbox = tempfile::tempdir().unwrap(); - 378
let overridden = resolve(Some(sandbox.path().to_str().unwrap())); - 379
assert_eq!(overridden.workspace, overridden.data.join("vak-home")); - 380
} - 381
- 382
#[test] - 383
fn canonical_homes_follow_platform_convention() { - 384
let h = resolve(None); - 385
let base = base_home(); - 386
#[cfg(target_os = "macos")] - 387
{ - 388
assert_eq!(h.data, base.join("Library/Application Support/vak")); - 389
assert_eq!(h.cache, base.join("Library/Caches/vak")); - 390
assert_eq!(h.logs, base.join("Library/Logs/vak")); - 391
} - 392
// Invariants that hold on every platform: - 393
assert!( - 394
!h.cache.starts_with(&h.data), - 395
"cache must not nest inside data" - 396
); - 397
for p in [&h.data, &h.cache, &h.logs] { - 398
assert!( - 399
!p.to_string_lossy().contains("/.vak"), - 400
"canonical layout must not use the legacy dotdir: {}", - 401
p.display() - 402
); - 403
} - 404
} - 405
- 406
#[test] - 407
fn empty_override_is_not_an_override() { - 408
let h = resolve(Some("")); - 409
assert!( - 410
!h.data.to_string_lossy().contains("/cache"), - 411
"empty string must fall through to the platform layout" - 412
); - 413
assert_eq!(h.workspace, base_home().join("vak-home")); - 414
} - 415
- 416
#[test] - 417
fn missing_environment_home_uses_absolute_account_home() { - 418
assert_eq!( - 419
resolve_base_home(None, Some(PathBuf::from("/Users/example"))), - 420
PathBuf::from("/Users/example") - 421
); - 422
assert_eq!( - 423
resolve_base_home(Some(PathBuf::from("relative")), None), - 424
PathBuf::from("/") - 425
); - 426
} - 427
- 428
/// The home used to be named by pid alone and reused whenever a pid - 429
/// came round again, so a test process could start inside the home of - 430
/// a finished one, Shared skills and all. - 431
#[test] - 432
fn a_test_home_is_never_a_directory_that_already_existed() { - 433
let first = fresh_test_home(); - 434
std::fs::write(first.join("left-behind"), "stale").unwrap(); - 435
let second = fresh_test_home(); - 436
assert_ne!(first, second); - 437
assert_eq!( - 438
std::fs::read_dir(&second).unwrap().count(), - 439
0, - 440
"a new home starts empty" - 441
); - 442
let _ = std::fs::remove_dir_all(&first); - 443
let _ = std::fs::remove_dir_all(&second); - 444
} - 445
- 446
#[test] - 447
fn gateway_workspace_sidecar_defaults_and_round_trips() { - 448
let data = tempfile::tempdir().unwrap(); - 449
let default = PathBuf::from("/Users/example/vak-home"); - 450
assert_eq!(gateway_workspace_at(data.path(), &default), default); - 451
let selected = tempfile::tempdir().unwrap(); - 452
persist_gateway_workspace_at(data.path(), Some(selected.path())).unwrap(); - 453
assert_eq!(gateway_workspace_at(data.path(), &default), selected.path()); - 454
persist_gateway_workspace_at(data.path(), None).unwrap(); - 455
assert_eq!(gateway_workspace_at(data.path(), &default), default); - 456
} - 457
- 458
#[test] - 459
fn gateway_workspace_ignores_relative_or_missing_sidecars() { - 460
let data = tempfile::tempdir().unwrap(); - 461
let path = data.path().join("gateway/default-workspace"); - 462
std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - 463
std::fs::write(&path, "relative\n").unwrap(); - 464
let default = PathBuf::from("/Users/example/vak-home"); - 465
assert_eq!(gateway_workspace_at(data.path(), &default), default); - 466
} - 467
- 468
#[test] - 469
fn agent_home_at_resolves_scoped_subdirectory() { - 470
let data = PathBuf::from("/tmp/vak-test-home"); - 471
assert_eq!(agent_home_at(&data, "vak"), data.join("agents/vak")); - 472
assert_eq!( - 473
agent_home_at(&data, "agent-123"), - 474
data.join("agents/agent-123") - 475
); - 476
} - 477
- 478
#[test] - 479
fn agent_workspace_isolates_user_created_agents_from_each_other() { - 480
let base = PathBuf::from("/Users/example/vak-home"); - 481
// The built-in agent keeps the process's own base workspace, so - 482
// existing single-agent installs see no path change. - 483
assert_eq!(agent_workspace(&base, "vak"), base); - 484
// Every other agent gets its own isolated directory nested under - 485
// that same base, distinct from the base itself and each other. - 486
let a = agent_workspace(&base, "agent-a"); - 487
let b = agent_workspace(&base, "agent-b"); - 488
assert_ne!(a, base); - 489
assert_ne!(b, base); - 490
assert_ne!(a, b); - 491
assert_eq!(a, base.join(".vak/agents/agent-a/workspace")); - 492
} - 493
- 494
#[test] - 495
fn agent_workspace_isolates_the_same_agent_id_across_different_base_workspaces() { - 496
let base_a = PathBuf::from("/Users/example/project-a"); - 497
let base_b = PathBuf::from("/Users/example/project-b"); - 498
assert_ne!( - 499
agent_workspace(&base_a, "newsy"), - 500
agent_workspace(&base_b, "newsy"), - 501
"the same agent id under two different base workspaces must not collide" - 502
); - 503
} - 504
} - 505
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.