- 1
//! The browser surface (docs/design/48-web-client.md). - 2
//! - 3
//! Everything here exists so the same workspace client that runs inside - 4
//! the Tauri shell can run in a tab against a headless box: one login for - 5
//! every browser surface, a host descriptor standing in for the shell's - 6
//! `backend_info`, workspace selection backed by `CorePool`, and a - 7
//! directory browser over the *server's* filesystem — because that is the - 8
//! machine whose folders matter. - 9
//! - 10
//! Nothing here re-implements the agent protocol. The client speaks the - 11
//! same `/sessions/*` contract every other surface does; these are only - 12
//! the pieces a browser needs that a native shell provided locally. - 13
- 14
use std::path::{Path, PathBuf}; - 15
- 16
use axum::Json; - 17
use axum::extract::State; - 18
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; - 19
use axum::http::{StatusCode, header}; - 20
use axum::response::{IntoResponse, Response}; - 21
use serde::Deserialize; - 22
- 23
use crate::AppState; - 24
use crate::admin::SESSION_COOKIE; - 25
- 26
// ---- auth: one login for every browser surface ----------------------------- - 27
- 28
#[derive(Debug, Deserialize)] - 29
pub(crate) struct LoginBody { - 30
pub token: String, - 31
} - 32
- 33
/// Cookie attributes for this deployment. - 34
/// - 35
/// `Secure` is conditional and must be: a browser silently DISCARDS a - 36
/// `Secure` cookie delivered over plain http, so setting it unconditionally - 37
/// would make every loopback login appear to succeed and then never - 38
/// persist. It is switched on exactly when the operator has told us the - 39
/// public origin is https (`[server] public_url`), or a terminating proxy - 40
/// says so on the request itself. - 41
fn cookie_attributes(state: &AppState, forwarded_proto: Option<&str>) -> String { - 42
let cfg = state.core.config(); - 43
let https = cfg.server.cookie_is_secure() || forwarded_proto == Some("https"); - 44
let max_age = cfg.server.session_ttl_hours.saturating_mul(3600); - 45
let secure = if https { "; Secure" } else { "" }; - 46
format!("HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age}{secure}") - 47
} - 48
- 49
fn forwarded_proto(headers: &header::HeaderMap) -> Option<&str> { - 50
headers - 51
.get("x-forwarded-proto") - 52
.and_then(|v| v.to_str().ok()) - 53
.map(|v| v.split(',').next().unwrap_or(v).trim()) - 54
} - 55
- 56
/// Constant-time token check → HttpOnly session cookie. - 57
/// - 58
/// Browsers need this because `EventSource` cannot send an `Authorization` - 59
/// header, so a cookie is the only channel that covers both `fetch` and - 60
/// SSE. The token itself is never stored client-side: it arrives once in - 61
/// this request body and what goes back is HttpOnly, so script can neither - 62
/// read it nor exfiltrate it afterwards. - 63
pub(crate) async fn login( - 64
State(state): State<AppState>, - 65
headers: header::HeaderMap, - 66
Json(body): Json<LoginBody>, - 67
) -> Response { - 68
use subtle::ConstantTimeEq; - 69
let ok: bool = body - 70
.token - 71
.as_bytes() - 72
.ct_eq(state.auth_token.as_bytes()) - 73
.into(); - 74
if !ok { - 75
let ip = headers - 76
.get("x-forwarded-for") - 77
.and_then(|v| v.to_str().ok()) - 78
.and_then(|v| v.split(',').next()) - 79
.map(str::trim) - 80
.filter(|s| !s.is_empty()); - 81
vak_core::security_events::record( - 82
&state.core.sessions_home(), - 83
vak_core::security_events::EventKind::AuthFailure, - 84
"login_failed", - 85
"invalid token on /auth/login", - 86
ip, - 87
); - 88
state.hub.emit_security("AuthFailure", "login_failed"); - 89
return ( - 90
StatusCode::UNAUTHORIZED, - 91
Json(serde_json::json!({ "error": "invalid token" })), - 92
) - 93
.into_response(); - 94
} - 95
let attributes = cookie_attributes(&state, forwarded_proto(&headers)); - 96
( - 97
[( - 98
header::SET_COOKIE, - 99
format!("{SESSION_COOKIE}={}; {attributes}", body.token), - 100
)], - 101
Json(serde_json::json!({ "ok": true })), - 102
) - 103
.into_response() - 104
} - 105
- 106
pub(crate) async fn logout() -> Response { - 107
( - 108
[( - 109
header::SET_COOKIE, - 110
format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"), - 111
)], - 112
Json(serde_json::json!({ "ok": true })), - 113
) - 114
.into_response() - 115
} - 116
- 117
/// Whether this browser already holds a session. - 118
/// - 119
/// Deliberately auth-exempt and deliberately NOT a 401: an unauthenticated - 120
/// client needs to tell "no session yet" (show the login form) apart from - 121
/// "server unreachable" (show an error), and a 401 conflates them. - 122
pub(crate) async fn session_status( - 123
State(state): State<AppState>, - 124
headers: header::HeaderMap, - 125
) -> Response { - 126
use subtle::ConstantTimeEq; - 127
let held = headers - 128
.get(header::COOKIE) - 129
.and_then(|v| v.to_str().ok()) - 130
.and_then(|cookies| { - 131
cookies.split(';').find_map(|pair| { - 132
pair.trim() - 133
.strip_prefix(&format!("{SESSION_COOKIE}=")) - 134
.map(str::trim) - 135
.map(String::from) - 136
}) - 137
}) - 138
.map(|value| { - 139
let ok: bool = value.as_bytes().ct_eq(state.auth_token.as_bytes()).into(); - 140
ok - 141
}) - 142
.unwrap_or(false); - 143
if held { - 144
return Json(serde_json::json!({ "authenticated": true })).into_response(); - 145
} - 146
- 147
// No session yet. On THIS machine, hand one over rather than asking - 148
// someone to go and find a token to reach their own computer. - 149
// - 150
// The probe doubles as the sign-in deliberately: the client already - 151
// calls it before deciding whether to show a login form, so there is no - 152
// second endpoint to discover and no extra round trip. Scope is exactly - 153
// what `[server] loopback_auto_login` describes — loopback only, off if - 154
// an operator says so, and never reachable from a real hostname. - 155
let cfg = state.core.config(); - 156
let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()); - 157
if cfg.server.loopback_auto_login && crate::host_is_loopback(host) { - 158
let attributes = cookie_attributes(&state, forwarded_proto(&headers)); - 159
return ( - 160
[( - 161
header::SET_COOKIE, - 162
format!("{SESSION_COOKIE}={}; {attributes}", state.auth_token), - 163
)], - 164
Json(serde_json::json!({ "authenticated": true, "granted": "loopback" })), - 165
) - 166
.into_response(); - 167
} - 168
Json(serde_json::json!({ "authenticated": false })).into_response() - 169
} - 170
- 171
// ---- the front door -------------------------------------------------------- - 172
// - 173
// `/` and its sub-pages moved to `site.rs` when the landing page grew from - 174
// one hand-written file into a multi-page site built from one source - 175
// (docs/design/48-web-client.md §4.6). Only the build stamp those pages - 176
// read stayed here. - 177
- 178
/// Build identity, for the public site's footer and build readout. - 179
/// - 180
/// Version and commit only. `/health` already answers unauthenticated (it - 181
/// is a liveness probe) but reports provider, model, sandbox and permission - 182
/// mode with it — detail a public front door has no business handing out. - 183
pub(crate) async fn version() -> Response { - 184
Json(serde_json::json!({ - 185
"version": env!("CARGO_PKG_VERSION"), - 186
"git_sha": option_env!("VAK_GIT_SHA").unwrap_or("unknown"), - 187
})) - 188
.into_response() - 189
} - 190
- 191
// ---- host descriptor ------------------------------------------------------- - 192
- 193
/// What `backend_info` is on the desktop: everything the client needs to - 194
/// know about the process it is talking to, before it knows anything else. - 195
/// - 196
/// `base_url` and `token` are deliberately absent. The web client is - 197
/// same-origin and cookie-authenticated, and an absent base URL is how it - 198
/// knows that — see `adoptBackend` in the client's api.ts. - 199
pub(crate) fn host_payload(state: &AppState) -> serde_json::Value { - 200
let core = state.active_core(); - 201
let cfg = state.core.config(); - 202
// A terminal reaches a real shell, so it is advertised only when the - 203
// operator enabled it — and, unless they said otherwise, only to - 204
// loopback. The client renders no Terminal tab at all when this is - 205
// false: a disabled control that cannot explain itself is worse than - 206
// an absent one (docs/design/48-web-client.md §6). - 207
let terminal = cfg.server.web_terminal; - 208
serde_json::json!({ - 209
"ready": true, - 210
"version": env!("CARGO_PKG_VERSION"), - 211
"cwd": core.cwd().to_string_lossy(), - 212
"recent_workspaces": recent_workspaces(state), - 213
"terminal": terminal, - 214
}) - 215
} - 216
- 217
pub(crate) async fn host_info(State(state): State<AppState>) -> Response { - 218
Json(host_payload(&state)).into_response() - 219
} - 220
- 221
// ---- workspaces ------------------------------------------------------------ - 222
- 223
/// Workspaces this server has seen, active one first. - 224
/// - 225
/// Read back out of the session ledger rather than kept as its own list. - 226
/// Sessions are already stored per workspace (`<home>/sessions/<hash of - 227
/// cwd>/`) and every ledger header names the cwd it was created in, so the - 228
/// answer is derivable — and a second store of the same fact is a second - 229
/// thing that has to stay true forever (invariant 30). The hash is one-way, - 230
/// hence reading a header rather than reversing a directory name. - 231
fn recent_workspaces(state: &AppState) -> Vec<String> { - 232
let mut discovered: Vec<PathBuf> = vec![state.active_core().cwd().clone()]; - 233
let root = state.core.sessions_home().join("sessions"); - 234
let Ok(read) = std::fs::read_dir(&root) else { - 235
return vak_core::workspaces::visible(discovered) - 236
.into_iter() - 237
.map(|p| p.to_string_lossy().into_owned()) - 238
.collect(); - 239
}; - 240
// Most recently touched project directory first, which is what makes - 241
// this a "recents" list rather than an arbitrary one. - 242
let mut dirs: Vec<(std::time::SystemTime, PathBuf)> = read - 243
.flatten() - 244
.map(|entry| entry.path()) - 245
.filter(|path| path.is_dir()) - 246
.map(|path| { - 247
let modified = std::fs::metadata(&path) - 248
.and_then(|m| m.modified()) - 249
.unwrap_or(std::time::UNIX_EPOCH); - 250
(modified, path) - 251
}) - 252
.collect(); - 253
// Newest first: `Reverse` rather than a flipped comparator, which - 254
// clippy rightly reads as a sort key spelled the long way. - 255
dirs.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified)); - 256
- 257
for (_, dir) in dirs.into_iter().take(24) { - 258
if let Some(cwd) = workspace_of_ledger_dir(&dir) { - 259
discovered.push(PathBuf::from(cwd)); - 260
} - 261
} - 262
// `visible` applies the operator's own removals and drops folders that - 263
// no longer exist. Filtering here rather than in each surface is what - 264
// stops a ledger rescan from resurrecting something someone removed. - 265
vak_core::workspaces::visible(discovered) - 266
.into_iter() - 267
.take(12) - 268
.map(|p| p.to_string_lossy().into_owned()) - 269
.collect() - 270
} - 271
- 272
/// The cwd recorded in the first readable ledger header under `dir`. - 273
fn workspace_of_ledger_dir(dir: &Path) -> Option<String> { - 274
let read = std::fs::read_dir(dir).ok()?; - 275
for entry in read.flatten() { - 276
let path = entry.path(); - 277
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { - 278
continue; - 279
} - 280
let Ok(text) = std::fs::read_to_string(&path) else { - 281
continue; - 282
}; - 283
// The header is the first line by construction (append-only), so - 284
// this reads one line rather than parsing a whole transcript. - 285
let Some(first) = text.lines().next() else { - 286
continue; - 287
}; - 288
if let Ok(value) = serde_json::from_str::<serde_json::Value>(first) - 289
&& let Some(cwd) = value - 290
.get("cwd") - 291
.or_else(|| value.pointer("/header/cwd")) - 292
.and_then(|v| v.as_str()) - 293
{ - 294
return Some(cwd.to_string()); - 295
} - 296
} - 297
None - 298
} - 299
- 300
pub(crate) async fn list_workspaces(State(state): State<AppState>) -> Response { - 301
let active = state.active_core().cwd().to_string_lossy().into_owned(); - 302
let entries: Vec<serde_json::Value> = recent_workspaces(&state) - 303
.into_iter() - 304
.map(|path| { - 305
let trusted = vak_core::trust::is_trusted(Path::new(&path)); - 306
serde_json::json!({ - 307
"path": path, - 308
"active": path == active, - 309
"trusted": trusted, - 310
}) - 311
}) - 312
.collect(); - 313
Json(serde_json::json!({ "workspaces": entries })).into_response() - 314
} - 315
- 316
#[derive(Debug, Deserialize)] - 317
pub(crate) struct OpenWorkspaceBody { - 318
pub path: String, - 319
/// The operator's answer when they have just been asked; `None` when - 320
/// nobody is being asked, in which case the decision already on record - 321
/// governs. Opening safely is the *absence* of a decision, so there is - 322
/// nothing to record for it. - 323
#[serde(default)] - 324
pub trust: Option<bool>, - 325
} - 326
- 327
/// Make `path` the workspace new tasks run in. - 328
/// - 329
/// Resolution goes through `CorePool`, which calls `Core::new_with_trust` - 330
/// exactly as a local `vak` run in that folder would (docs/design/34 Phase - 331
/// 2): pooling grants nothing a local session would not already have. - 332
pub(crate) async fn open_workspace( - 333
State(state): State<AppState>, - 334
Json(body): Json<OpenWorkspaceBody>, - 335
) -> Response { - 336
let path = PathBuf::from(body.path.trim()); - 337
let Ok(path) = path.canonicalize() else { - 338
return ( - 339
StatusCode::BAD_REQUEST, - 340
Json(serde_json::json!({ "error": format!("not a directory: {}", body.path) })), - 341
) - 342
.into_response(); - 343
}; - 344
if !path.is_dir() { - 345
return ( - 346
StatusCode::BAD_REQUEST, - 347
Json(serde_json::json!({ "error": format!("not a directory: {}", path.display()) })), - 348
) - 349
.into_response(); - 350
} - 351
if !within_roots(&state, &path) { - 352
return ( - 353
StatusCode::FORBIDDEN, - 354
Json(serde_json::json!({ - 355
"error": "that folder is outside [server] workspace_roots", - 356
})), - 357
) - 358
.into_response(); - 359
} - 360
if body.trust == Some(true) - 361
&& let Err(e) = vak_core::trust::record(&path) - 362
{ - 363
eprintln!("warning: could not record the trust decision: {e}"); - 364
} - 365
if let Err(e) = vak_config::ensure_project_config(&path) { - 366
return ( - 367
StatusCode::INTERNAL_SERVER_ERROR, - 368
Json(serde_json::json!({ "error": e.to_string() })), - 369
) - 370
.into_response(); - 371
} - 372
let core = match state - 373
.gateway - 374
.core_pool - 375
.resolve_at(&path, None, std::time::Instant::now()) - 376
{ - 377
Ok(core) => core.with_surface(vak_core::Surface::Web), - 378
Err(e) => { - 379
return ( - 380
StatusCode::INTERNAL_SERVER_ERROR, - 381
Json(serde_json::json!({ "error": e })), - 382
) - 383
.into_response(); - 384
} - 385
}; - 386
// Opening is also how a removed workspace comes back — there is no - 387
// separate "restore" verb, because the action a person takes is to - 388
// open it again. - 389
if let Err(e) = vak_core::workspaces::remember(&path) { - 390
eprintln!("warning: could not record the workspace: {e}"); - 391
} - 392
*state - 393
.active_core - 394
.lock() - 395
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(core); - 396
Json(host_payload(&state)).into_response() - 397
} - 398
- 399
/// Stop listing a workspace. Its sessions, memory, and settings survive. - 400
/// - 401
/// Deliberately NOT a delete: removing a project from a list is a thing - 402
/// people do casually, and it must therefore be a thing that costs nothing - 403
/// to undo. Erasing an append-only ledger is a different operation with - 404
/// different consequences, and it does not live behind this button. - 405
pub(crate) async fn forget_workspace( - 406
State(state): State<AppState>, - 407
Json(body): Json<OpenWorkspaceBody>, - 408
) -> Response { - 409
let path = PathBuf::from(body.path.trim()); - 410
// If the active workspace is being forgotten, fall back to the default core. - 411
if canonical_eq(&path, state.active_core().cwd()) && !canonical_eq(&path, state.core.cwd()) { - 412
*state - 413
.active_core - 414
.lock() - 415
.unwrap_or_else(std::sync::PoisonError::into_inner) = None; - 416
} - 417
match vak_core::workspaces::forget(&path) { - 418
Ok(()) => Json(serde_json::json!({ "forgotten": body.path })).into_response(), - 419
Err(e) => ( - 420
StatusCode::INTERNAL_SERVER_ERROR, - 421
Json(serde_json::json!({ "error": e.to_string() })), - 422
) - 423
.into_response(), - 424
} - 425
} - 426
- 427
/// Same folder, allowing for symlinks. - 428
fn canonical_eq(a: &Path, b: &Path) -> bool { - 429
let resolve = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - 430
resolve(a) == resolve(b) - 431
} - 432
- 433
// ---- directory browser ----------------------------------------------------- - 434
- 435
/// Roots the picker may browse. Empty config means the operator's home - 436
/// directory, which is where projects live on every deployment this - 437
/// targets; naming roots explicitly narrows it further. - 438
fn workspace_roots(state: &AppState) -> Vec<PathBuf> { - 439
let configured = state.core.config().server.workspace_roots.clone(); - 440
if !configured.is_empty() { - 441
return configured; - 442
} - 443
std::env::var_os("HOME") - 444
.map(PathBuf::from) - 445
.into_iter() - 446
.collect() - 447
} - 448
- 449
fn within_roots(state: &AppState, path: &Path) -> bool { - 450
path_within(&workspace_roots(state), path) - 451
} - 452
- 453
/// Whether `path` sits under any of `roots`. - 454
/// - 455
/// BOTH sides are canonicalized, and that is the whole substance of this - 456
/// function. Comparing a raw path against a canonical root silently fails - 457
/// wherever a path component is a symlink — `/var` is a link to - 458
/// `/private/var` on macOS, `/home` often is on Linux — so the check would - 459
/// reject folders that are genuinely inside a root. Canonicalizing only the - 460
/// root (the first version of this) had exactly that bug. - 461
/// - 462
/// A path that does not exist yet cannot be canonicalized, so it falls back - 463
/// to its literal form; callers that matter (`open_workspace`, `list_dirs`) - 464
/// canonicalize before calling anyway, because a folder you are about to - 465
/// open has to exist. - 466
fn path_within(roots: &[PathBuf], path: &Path) -> bool { - 467
if roots.is_empty() { - 468
return true; // no HOME and nothing configured: nothing to enforce - 469
} - 470
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - 471
roots.iter().any(|root| { - 472
let root = root.canonicalize().unwrap_or_else(|_| root.clone()); - 473
path.starts_with(root) - 474
}) - 475
} - 476
- 477
#[derive(Debug, Deserialize)] - 478
pub(crate) struct DirQuery { - 479
#[serde(default)] - 480
pub path: Option<String>, - 481
} - 482
- 483
/// Directory names under `path`. Never file contents, never files at all — - 484
/// this answers "which folders are there", and nothing more, because that - 485
/// is the whole question the workspace picker asks. - 486
pub(crate) async fn list_dirs( - 487
State(state): State<AppState>, - 488
axum::extract::Query(query): axum::extract::Query<DirQuery>, - 489
) -> Response { - 490
let roots = workspace_roots(&state); - 491
let here = match query - 492
.path - 493
.as_deref() - 494
.map(str::trim) - 495
.filter(|p| !p.is_empty()) - 496
{ - 497
Some(path) => match PathBuf::from(path).canonicalize() { - 498
Ok(path) => path, - 499
Err(e) => { - 500
return ( - 501
StatusCode::BAD_REQUEST, - 502
Json(serde_json::json!({ "error": format!("{path}: {e}") })), - 503
) - 504
.into_response(); - 505
} - 506
}, - 507
None => roots.first().cloned().unwrap_or_else(|| PathBuf::from("/")), - 508
}; - 509
if !within_roots(&state, &here) { - 510
return ( - 511
StatusCode::FORBIDDEN, - 512
Json(serde_json::json!({ "error": "outside [server] workspace_roots" })), - 513
) - 514
.into_response(); - 515
} - 516
let mut entries: Vec<serde_json::Value> = Vec::new(); - 517
if let Ok(read) = std::fs::read_dir(&here) { - 518
for item in read.flatten() { - 519
let path = item.path(); - 520
if !path.is_dir() { - 521
continue; - 522
} - 523
let name = item.file_name().to_string_lossy().into_owned(); - 524
// Dotfolders are noise in a project picker, and `.git` is the - 525
// one thing a repository is *inside*, never a project itself. - 526
if name.starts_with('.') { - 527
continue; - 528
} - 529
entries.push(serde_json::json!({ - 530
"name": name, - 531
"path": path.to_string_lossy(), - 532
"git": path.join(".git").exists(), - 533
})); - 534
} - 535
} - 536
entries.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - 537
- 538
// Only offer "up" while it stays inside a root: the picker must not be - 539
// walkable to `/` one click at a time. - 540
let parent = here - 541
.parent() - 542
.filter(|p| within_roots(&state, p)) - 543
.map(|p| p.to_string_lossy().into_owned()); - 544
- 545
Json(serde_json::json!({ - 546
"path": here.to_string_lossy(), - 547
"parent": parent, - 548
"entries": entries, - 549
})) - 550
.into_response() - 551
} - 552
- 553
#[cfg(test)] - 554
#[allow(clippy::unwrap_used, clippy::expect_used)] - 555
mod tests { - 556
use super::*; - 557
- 558
/// The picker must not be walkable out of its roots one "up" at a time. - 559
#[test] - 560
fn paths_outside_the_roots_are_refused() { - 561
let dir = tempfile::tempdir().unwrap(); - 562
let roots = vec![dir.path().to_path_buf()]; - 563
assert!(!path_within(&roots, Path::new("/etc"))); - 564
assert!(!path_within(&roots, Path::new("/"))); - 565
assert!(path_within(&roots, dir.path())); - 566
} - 567
- 568
/// A root reached through a symlink is still that root. - 569
/// - 570
/// This is not hypothetical: `tempfile` hands out paths under `/var` on - 571
/// macOS, which is a symlink to `/private/var`. Canonicalizing only the - 572
/// root — the first version of this check — rejected every real folder - 573
/// inside it, and the picker would have refused the operator's own - 574
/// project directory with "outside workspace_roots". - 575
#[test] - 576
fn a_symlinked_root_still_contains_its_children() { - 577
let dir = tempfile::tempdir().unwrap(); - 578
let child = dir.path().join("project"); - 579
std::fs::create_dir_all(&child).unwrap(); - 580
let roots = vec![dir.path().to_path_buf()]; - 581
assert!(path_within(&roots, &child)); - 582
// And the canonical spelling of the same folder agrees. - 583
assert!(path_within(&roots, &child.canonicalize().unwrap())); - 584
} - 585
- 586
/// No roots at all (no HOME, nothing configured) enforces nothing — - 587
/// but must not accidentally enforce *everything* and lock the picker. - 588
#[test] - 589
fn an_empty_root_list_enforces_nothing() { - 590
assert!(path_within(&[], Path::new("/anywhere"))); - 591
} - 592
- 593
/// The recents list reads a workspace back out of a real ledger header. - 594
/// - 595
/// Written against the actual `Entry`/`SessionHeader` types rather than - 596
/// a hand-rolled JSON string, because the thing that could break this - 597
/// is precisely the ledger's serialization shape changing — and a test - 598
/// that hardcodes today's shape would keep passing through exactly the - 599
/// change it exists to catch. - 600
#[test] - 601
fn a_workspace_is_recovered_from_its_ledger_header() { - 602
use vak_session::types::{Entry, EntryPayload, FrozenContract, SessionHeader}; - 603
- 604
let dir = tempfile::tempdir().unwrap(); - 605
let workspace = dir.path().join("some-project"); - 606
std::fs::create_dir_all(&workspace).unwrap(); - 607
let ledger_dir = dir.path().join("ledger"); - 608
std::fs::create_dir_all(&ledger_dir).unwrap(); - 609
- 610
let header = SessionHeader { - 611
agent: None, - 612
session_id: "s1".into(), - 613
created_at: chrono::Utc::now(), - 614
cwd: workspace.clone(), - 615
parent_session_id: None, - 616
contract_id: None, - 617
work_item_id: None, - 618
conversation: None, - 619
contract: FrozenContract { - 620
app_version: "test".into(), - 621
provider: "p".into(), - 622
model: "m".into(), - 623
route_ladder: Vec::new(), - 624
route_objective: String::new(), - 625
route_annotations: Vec::new(), - 626
system_prompt: String::new(), - 627
permission_mode: "read-only".into(), - 628
capabilities: Vec::new(), - 629
prompt_layers: Vec::new(), - 630
}, - 631
}; - 632
let entry = Entry::new(None, EntryPayload::Header(header)); - 633
std::fs::write( - 634
ledger_dir.join("s1.jsonl"), - 635
format!("{}\n", serde_json::to_string(&entry).unwrap()), - 636
) - 637
.unwrap(); - 638
- 639
assert_eq!( - 640
workspace_of_ledger_dir(&ledger_dir).as_deref(), - 641
Some(workspace.to_string_lossy().as_ref()), - 642
"the recents list could not read a workspace out of a real header" - 643
); - 644
} - 645
} - 646
- 647
// ---- terminal over WebSocket (docs/design/48-web-client.md §6) ------------- - 648
// - 649
// A PTY over HTTP is remote shell access. Everything else the client can - 650
// reach is mediated by the permission engine and the broker (invariants 14 - 651
// and 16); a terminal is not — it is the operator's own hands, which is - 652
// exactly what makes it useful and exactly why it does not ship on by - 653
// default. - 654
// - 655
// Three gates, all of which must pass: - 656
// 1. `[server.web] terminal` — off unless an operator turned it on. - 657
// 2. `terminal_requires_loopback` — on by default, so enabling the - 658
// terminal for local convenience does not silently also expose it to - 659
// whatever hostname the server answers to. - 660
// 3. The session cookie, checked at upgrade like any other route. - 661
- 662
#[derive(Debug, Deserialize)] - 663
pub(crate) struct PtyQuery { - 664
#[serde(default)] - 665
pub cwd: Option<String>, - 666
} - 667
- 668
/// Whether this request may open a shell, and why not when it may not. - 669
fn terminal_refusal(state: &AppState, headers: &header::HeaderMap) -> Option<&'static str> { - 670
let cfg = state.core.config(); - 671
if !cfg.server.web_terminal { - 672
return Some("the terminal is disabled; set [server.web] terminal = true to enable it"); - 673
} - 674
if cfg.server.web_terminal_requires_loopback { - 675
let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()); - 676
if !crate::host_is_loopback(host) { - 677
return Some( - 678
"the terminal is enabled but restricted to loopback; \ - 679
set [server.web] terminal_requires_loopback = false to allow remote shells", - 680
); - 681
} - 682
} - 683
None - 684
} - 685
- 686
pub(crate) async fn pty_socket( - 687
State(state): State<AppState>, - 688
headers: header::HeaderMap, - 689
axum::extract::Query(query): axum::extract::Query<PtyQuery>, - 690
upgrade: WebSocketUpgrade, - 691
) -> Response { - 692
if let Some(reason) = terminal_refusal(&state, &headers) { - 693
return ( - 694
StatusCode::FORBIDDEN, - 695
Json(serde_json::json!({ "error": reason })), - 696
) - 697
.into_response(); - 698
} - 699
// A shell inherits the workspace, so it is bounded by the same roots - 700
// the picker is — a `cwd` query parameter must not be a way to start a - 701
// shell somewhere the operator never authorized. - 702
let cwd = match query - 703
.cwd - 704
.as_deref() - 705
.map(str::trim) - 706
.filter(|c| !c.is_empty()) - 707
{ - 708
Some(path) => { - 709
let path = PathBuf::from(path); - 710
match path.canonicalize() { - 711
Ok(path) if path.is_dir() && within_roots(&state, &path) => path, - 712
_ => state.active_core().cwd().clone(), - 713
} - 714
} - 715
None => state.active_core().cwd().clone(), - 716
}; - 717
upgrade.on_upgrade(move |socket| drive_pty(socket, cwd)) - 718
} - 719
- 720
/// Control frames the client sends as text; keystrokes are binary. Nothing - 721
/// a user can type is mistakable for a control message. - 722
#[derive(Debug, Deserialize)] - 723
struct PtyControl { - 724
resize: Option<PtyResize>, - 725
} - 726
- 727
#[derive(Debug, Deserialize)] - 728
struct PtyResize { - 729
cols: u16, - 730
rows: u16, - 731
} - 732
- 733
async fn drive_pty(socket: WebSocket, cwd: PathBuf) { - 734
use futures::{SinkExt, StreamExt}; - 735
use portable_pty::{CommandBuilder, PtySize, native_pty_system}; - 736
- 737
let pair = match native_pty_system().openpty(PtySize { - 738
rows: 24, - 739
cols: 80, - 740
pixel_width: 0, - 741
pixel_height: 0, - 742
}) { - 743
Ok(pair) => pair, - 744
Err(error) => { - 745
let mut socket = socket; - 746
let _ = socket - 747
.send(Message::Text(format!("openpty failed: {error}").into())) - 748
.await; - 749
return; - 750
} - 751
}; - 752
- 753
let mut cmd = CommandBuilder::new_default_prog(); - 754
cmd.cwd(&cwd); - 755
cmd.env("TERM", "xterm-256color"); - 756
let mut child = match pair.slave.spawn_command(cmd) { - 757
Ok(child) => child, - 758
Err(error) => { - 759
let mut socket = socket; - 760
let _ = socket - 761
.send(Message::Text(format!("shell spawn failed: {error}").into())) - 762
.await; - 763
return; - 764
} - 765
}; - 766
drop(pair.slave); - 767
- 768
let killer = child.clone_killer(); - 769
let Ok(mut reader) = pair.master.try_clone_reader() else { - 770
let _ = child.kill(); - 771
return; - 772
}; - 773
let Ok(mut writer) = pair.master.take_writer() else { - 774
let _ = child.kill(); - 775
return; - 776
}; - 777
let master = pair.master; - 778
- 779
let (mut sink, mut stream) = socket.split(); - 780
- 781
// Shell -> socket. A blocking read on its own thread, handed across by - 782
// a channel, because `portable_pty`'s reader is not async. - 783
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64); - 784
std::thread::spawn(move || { - 785
let mut buf = [0u8; 8192]; - 786
loop { - 787
match std::io::Read::read(&mut reader, &mut buf) { - 788
Ok(0) | Err(_) => break, - 789
Ok(n) => { - 790
if out_tx.blocking_send(buf[..n].to_vec()).is_err() { - 791
break; - 792
} - 793
} - 794
} - 795
} - 796
}); - 797
let pump = tokio::spawn(async move { - 798
while let Some(bytes) = out_rx.recv().await { - 799
if sink.send(Message::Binary(bytes.into())).await.is_err() { - 800
break; - 801
} - 802
} - 803
let _ = sink.close().await; - 804
}); - 805
- 806
// Socket -> shell, until the client goes away. - 807
while let Some(Ok(message)) = stream.next().await { - 808
match message { - 809
Message::Binary(bytes) => { - 810
if std::io::Write::write_all(&mut writer, &bytes).is_err() { - 811
break; - 812
} - 813
} - 814
Message::Text(text) => { - 815
if let Ok(PtyControl { resize: Some(size) }) = - 816
serde_json::from_str::<PtyControl>(&text) - 817
{ - 818
let _ = master.resize(PtySize { - 819
rows: size.rows, - 820
cols: size.cols, - 821
pixel_width: 0, - 822
pixel_height: 0, - 823
}); - 824
} - 825
} - 826
Message::Close(_) => break, - 827
_ => {} - 828
} - 829
} - 830
- 831
// The socket IS the shell's lifetime. Closing the tab kills the - 832
// process rather than leaving it running for the life of the server — - 833
// the exact leak the desktop's own PTY had until `pty_close` landed. - 834
let mut killer = killer; - 835
let _ = killer.kill(); - 836
let _ = child.wait(); - 837
pump.abort(); - 838
} - 839
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.