- 10464
if body.is_empty() { - 10465
return ( - 10466
StatusCode::BAD_REQUEST, - 10467
Json(serde_json::json!({ "error": "the file is empty" })), - 10468
) - 10469
.into_response(); - 10470
} - 10471
let workspace = state.active_core().cwd().to_path_buf(); - 10472
let name = q.name.clone(); - 10473
let saved = tokio::task::spawn_blocking(move || { - 10474
inbox::save_to_inbox(&workspace, &name, &body) - 10475
.and_then(|saved| inbox::attached(&workspace, &saved)) - 10476
}) - 10477
.await; - 10478
match saved { - 10479
Ok(Ok((_, file))) => Json(serde_json::json!({ - 10480
"path": file.path, - 10481
"name": file.name, - 10482
"bytes": file.bytes, - 10483
})) - 10484
.into_response(), - 10485
Ok(Err(error)) => ( - 10486
StatusCode::UNPROCESSABLE_ENTITY, - 10487
Json(serde_json::json!({ "error": error })), - 10488
) - 10489
.into_response(), - 10490
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - 10491
} - 10492
} - 10493
- 10494
/// What the Canvas draws of a workspace Office file (docs/design/72, P4): - 10495
/// a page of its content or its structure, parsed in the worker, never - 10496
/// here (invariant 39). Drafts are workspace files under `.vak/scratch`. - 10497
async fn read_office_projection( - 10498
State(state): State<AppState>, - 10499
axum::extract::Query(q): axum::extract::Query<OfficeProjectionQuery>, - 10500
) -> axum::response::Response { - 10501
use axum::response::IntoResponse; - 10502
if !vak_ooxml::is_openxml_path(&q.path) { - 10503
return ( - 10504
StatusCode::BAD_REQUEST, - 10505
"not a Word, Excel, PowerPoint or Visio file", - 10506
) - 10507
.into_response(); - 10508
} - 10509
let Some(path) = resolve_confined_file(&state, &q.path) else { - 10510
return (StatusCode::FORBIDDEN, "path outside workspace").into_response(); - 10511
}; - 10512
if !path.is_file() { - 10513
return StatusCode::NOT_FOUND.into_response(); - 10514
} - 10515
let view = q.view(); - 10516
match vak_tools::broker::office_project(&state.core.tool_worker_exe(), &path, view).await { - 10517
Ok(mut body) => { - 10518
body["path"] = serde_json::Value::String(q.path.clone()); - 10519
Json(body).into_response() - 10520
} - 10521
Err(error) => ( - 10522
StatusCode::UNPROCESSABLE_ENTITY, - 10523
Json(serde_json::json!({ "error": error })), - 10524
) - 10525
.into_response(), - 10526
} - 10527
} - 10528
- 10529
async fn read_file( - 10530
State(state): State<AppState>, - 10531
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 10532
) -> axum::response::Response { - 10533
use axum::response::IntoResponse; - 10534
let Some(path) = resolve_confined_file(&state, &q.path) else { - 10535
return (StatusCode::FORBIDDEN, "path outside workspace").into_response(); - 10536
}; - 10537
match tokio::fs::read(&path).await { - 10538
Ok(bytes) => { - 10539
// Never hand back lossily-decoded bytes: the editor can save what - 10540
// it was given, and a lossy round trip would destroy the file. - 10541
// Binary is reported as binary; images ride back as base64 so the - 10542
// UI can render them. - 10543
let kind = vak_core::files::classify(&path, &bytes); - 10544
let mut body = serde_json::json!({ - 10545
"path": q.path, - 10546
"kind": kind.as_str(), - 10547
"bytes": bytes.len(), - 10548
}); - 10549
match kind { - 10550
vak_core::files::FileKind::Text => { - 10551
let mime = vak_core::files::mime_for(&path); - 10552
if mime == "image/svg+xml" { - 10553
use base64::Engine; - 10554
body["data_url"] = serde_json::json!(format!( - 10555
"data:{mime};base64,{}", - 10556
base64::engine::general_purpose::STANDARD.encode(&bytes) - 10557
)); - 10558
} - 10559
body["content"] = serde_json::json!(String::from_utf8_lossy(&bytes)); - 10560
body["editable"] = serde_json::json!(true); - 10561
} - 10562
vak_core::files::FileKind::Image => { - 10563
use base64::Engine; - 10564
body["data_url"] = serde_json::json!(format!( - 10565
"data:{};base64,{}", - 10566
vak_core::files::mime_for(&path), - 10567
base64::engine::general_purpose::STANDARD.encode(&bytes) - 10568
)); - 10569
body["editable"] = serde_json::json!(false); - 10570
} - 10571
vak_core::files::FileKind::Binary => { - 10572
// Browser-renderable binary formats still need a safe, - 10573
// authenticated representation. Keep the existing binary - 10574
// classification (never editable), but expose bounded - 10575
// data URLs for media/document viewers. - 10576
let mime = match path - 10577
.extension() - 10578
.and_then(|e| e.to_str()) - 10579
.unwrap_or("") - 10580
.to_ascii_lowercase() - 10581
.as_str() - 10582
{ - 10583
"pdf" => Some("application/pdf"), - 10584
"mp3" => Some("audio/mpeg"), - 10585
"wav" => Some("audio/wav"), - 10586
"ogg" => Some("audio/ogg"), - 10587
"mp4" => Some("video/mp4"), - 10588
"webm" => Some("video/webm"), - 10589
_ => None, - 10590
}; - 10591
if let Some(mime) = mime - 10592
&& bytes.len() <= 16 * 1024 * 1024 - 10593
{ - 10594
use base64::Engine; - 10595
body["data_url"] = serde_json::json!(format!( - 10596
"data:{mime};base64,{}", - 10597
base64::engine::general_purpose::STANDARD.encode(&bytes) - 10598
)); - 10599
} - 10600
body["editable"] = serde_json::json!(false); - 10601
} - 10602
} - 10603
(StatusCode::OK, Json(body)).into_response() - 10604
} - 10605
Err(_) => (StatusCode::NOT_FOUND, "file not found").into_response(), - 10606
} - 10607
} - 10608
- 10609
/// Authenticated raw artifact access for renderers that cannot consume a JSON - 10610
/// data URL (compound web apps, large media, PDFs, and browser-native formats). - 10611
/// The path is still workspace-confined and the response never exposes a - 10612
/// filesystem path outside the requested relative name. - 10613
async fn read_file_raw( - 10614
State(state): State<AppState>, - 10615
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 10616
) -> axum::response::Response { - 10617
use axum::body::Body; - 10618
use axum::response::IntoResponse; - 10619
let Some(path) = resolve_confined_file(&state, &q.path) else { - 10620
return (StatusCode::FORBIDDEN, "path outside workspace").into_response(); - 10621
}; - 10622
let bytes = match tokio::fs::read(&path).await { - 10623
Ok(bytes) => bytes, - 10624
Err(_) => return (StatusCode::NOT_FOUND, "file not found").into_response(), - 10625
}; - 10626
let mime = raw_mime_for(&path); - 10627
let disposition = if mime.starts_with("text/") - 10628
|| mime == "image/svg+xml" - 10629
|| mime == "application/pdf" - 10630
|| mime.starts_with("audio/") - 10631
|| mime.starts_with("video/") - 10632
{ - 10633
"inline" - 10634
} else { - 10635
"attachment" - 10636
}; - 10637
let filename = path - 10638
.file_name() - 10639
.and_then(|n| n.to_str()) - 10640
.unwrap_or("artifact") - 10641
.chars() - 10642
.map(|ch| { - 10643
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ' ') { - 10644
ch - 10645
} else { - 10646
'_' - 10647
} - 10648
}) - 10649
.collect::<String>(); - 10650
let headers = [ - 10651
(axum::http::header::CONTENT_TYPE, mime), - 10652
( - 10653
axum::http::header::CONTENT_DISPOSITION, - 10654
&format!("{disposition}; filename=\"{filename}\""), - 10655
), - 10656
(axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"), - 10657
(axum::http::header::CACHE_CONTROL, "no-store"), - 10658
]; - 10659
(headers, Body::from(bytes)).into_response() - 10660
} - 10661
- 10662
/// Serve a workspace-confined artifact through a stable path so compound HTML - 10663
/// previews can resolve relative stylesheets, scripts, images, and imports. - 10664
/// The response is still sandboxed by CSP; it is never a general static-file - 10665
/// server. - 10666
async fn preview_file( - 10667
State(state): State<AppState>, - 10668
axum::extract::Path(path): axum::extract::Path<String>, - 10669
) -> axum::response::Response { - 10670
use axum::body::Body; - 10671
use axum::response::IntoResponse; - 10672
let Some(path) = resolve_confined_file(&state, &path) else { - 10673
return (StatusCode::FORBIDDEN, "path outside workspace").into_response(); - 10674
}; - 10675
let bytes = match tokio::fs::read(&path).await { - 10676
Ok(bytes) => bytes, - 10677
Err(_) => return (StatusCode::NOT_FOUND, "file not found").into_response(), - 10678
}; - 10679
let headers = [ - 10680
(axum::http::header::CONTENT_TYPE, raw_mime_for(&path)), - 10681
(axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"), - 10682
(axum::http::header::CACHE_CONTROL, "no-store"), - 10683
( - 10684
axum::http::header::CONTENT_SECURITY_POLICY, - 10685
"sandbox allow-scripts; default-src 'self'; object-src 'none'; connect-src 'none'; base-uri 'self'", - 10686
), - 10687
]; - 10688
(headers, Body::from(bytes)).into_response() - 10689
} - 10690
- 10691
fn raw_mime_for(path: &std::path::Path) -> &'static str { - 10692
match path - 10693
.extension() - 10694
.and_then(|e| e.to_str()) - 10695
.unwrap_or("") - 10696
.to_ascii_lowercase() - 10697
.as_str() - 10698
{ - 10699
"html" | "htm" => "text/html; charset=utf-8", - 10700
"css" => "text/css; charset=utf-8", - 10701
"js" | "mjs" => "text/javascript; charset=utf-8", - 10702
"json" => "application/json", - 10703
"md" => "text/markdown; charset=utf-8", - 10704
"svg" => "image/svg+xml", - 10705
"png" => "image/png", - 10706
"jpg" | "jpeg" => "image/jpeg", - 10707
"gif" => "image/gif", - 10708
"webp" => "image/webp", - 10709
"pdf" => "application/pdf", - 10710
"mp3" => "audio/mpeg", - 10711
"wav" => "audio/wav", - 10712
"ogg" => "audio/ogg", - 10713
"mp4" => "video/mp4", - 10714
"webm" => "video/webm", - 10715
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - 10716
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - 10717
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - 10718
"vsdx" => "application/vnd.ms-visio.drawing", - 10719
_ => "application/octet-stream", - 10720
} - 10721
} - 10722
- 10723
#[derive(serde::Deserialize)] - 10724
struct WriteBody { - 10725
path: String, - 10726
content: String, - 10727
} - 10728
- 10729
async fn write_file(State(state): State<AppState>, Json(body): Json<WriteBody>) -> StatusCode { - 10730
let Some(path) = resolve_confined_file(&state, &body.path) else { - 10731
return StatusCode::FORBIDDEN; - 10732
}; - 10733
// Refuse to overwrite a file this endpoint could never have rendered - 10734
// faithfully: saving text over an image or binary destroys it. - 10735
if let Ok(existing) = tokio::fs::read(&path).await - 10736
&& !vak_core::files::classify(&path, &existing).editable() - 10737
{ - 10738
return StatusCode::UNSUPPORTED_MEDIA_TYPE; - 10739
} - 10740
if let Some(parent) = path.parent() - 10741
&& tokio::fs::create_dir_all(parent).await.is_err() - 10742
{ - 10743
return StatusCode::INTERNAL_SERVER_ERROR; - 10744
} - 10745
match tokio::fs::write(&path, body.content.as_bytes()).await { - 10746
Ok(()) => StatusCode::OK, - 10747
Err(_) => StatusCode::INTERNAL_SERVER_ERROR, - 10748
} - 10749
} - 10750
- 10751
fn sandbox_records_path(state: &AppState) -> std::path::PathBuf { - 10752
state - 10753
.core - 10754
.sessions_home() - 10755
.join("sandbox") - 10756
.join("records.jsonl") - 10757
} - 10758
- 10759
fn sandbox_candidates_root(state: &AppState) -> std::path::PathBuf { - 10760
state - 10761
.core - 10762
.sessions_home() - 10763
.join("sandbox") - 10764
.join("candidates") - 10765
} - 10766
- 10767
fn sandbox_promotions_root(state: &AppState) -> std::path::PathBuf { - 10768
state - 10769
.core - 10770
.shared_data_home() - 10771
.join("sandbox") - 10772
.join("promotions") - 10773
} - 10774
- 10775
fn session_sandbox_events_path(state: &AppState, session_id: &str) -> std::path::PathBuf { - 10776
state - 10777
.core - 10778
.sessions_home() - 10779
.join("sandbox") - 10780
.join("executions") - 10781
.join(format!("{session_id}.jsonl")) - 10782
} - 10783
- 10784
fn sandbox_session_workspace(state: &AppState, session_id: &str) -> Option<std::path::PathBuf> { - 10785
if let Some(handle) = state.get(session_id) { - 10786
return Some(handle.cwd.clone()); - 10787
} - 10788
open_historical_session(state, session_id) - 10789
.and_then(|session| session.header().map(|header| header.contract_cwd())) - 10790
} - 10791
- 10792
async fn session_sandbox_executions( - 10793
State(state): State<AppState>, - 10794
Path(id): Path<String>, - 10795
) -> axum::response::Response { - 10796
use axum::response::IntoResponse; - 10797
let path = session_sandbox_events_path(&state, &id); - 10798
let text = match tokio::fs::read_to_string(&path).await { - 10799
Ok(text) => text, - 10800
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - 10801
Err(error) => { - 10802
return ( - 10803
StatusCode::INTERNAL_SERVER_ERROR, - 10804
Json(serde_json::json!({"error": error.to_string()})), - 10805
) - 10806
.into_response(); - 10807
} - 10808
}; - 10809
let events = text - 10810
.lines() - 10811
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) - 10812
.collect::<Vec<_>>(); - 10813
Json(serde_json::json!({ "session_id": id, "events": events })).into_response() - 10814
} - 10815
- 10816
fn append_session_sandbox_event(home: &std::path::Path, session_id: &str, event: &AgentEvent) { - 10817
let AgentEvent::Sandbox(sandbox) = event else { - 10818
return; - 10819
}; - 10820
let path = home - 10821
.join("sandbox") - 10822
.join("executions") - 10823
.join(format!("{session_id}.jsonl")); - 10824
let Some(parent) = path.parent() else { - 10825
return; - 10826
}; - 10827
if std::fs::create_dir_all(parent).is_err() { - 10828
return; - 10829
} - 10830
let Ok(mut file) = std::fs::OpenOptions::new() - 10831
.create(true) - 10832
.append(true) - 10833
.open(path) - 10834
else { - 10835
return; - 10836
}; - 10837
let Ok(line) = serde_json::to_string(sandbox) else { - 10838
return; - 10839
}; - 10840
use std::io::Write; - 10841
let _ = writeln!(file, "{line}"); - 10842
} - 10843
- 10844
async fn list_sandbox_records(State(state): State<AppState>) -> axum::response::Response { - 10845
use axum::response::IntoResponse; - 10846
let path = sandbox_records_path(&state); - 10847
match vak_sandbox::load_records(&path) { - 10848
Ok(records) => Json(serde_json::json!({ "records": records })).into_response(), - 10849
Err(error) => ( - 10850
StatusCode::INTERNAL_SERVER_ERROR, - 10851
Json(serde_json::json!({ "error": error.to_string() })), - 10852
) - 10853
.into_response(), - 10854
} - 10855
} - 10856
- 10857
async fn list_session_sandbox_records( - 10858
State(state): State<AppState>, - 10859
Path(id): Path<String>, - 10860
) -> axum::response::Response { - 10861
use axum::response::IntoResponse; - 10862
let path = sandbox_records_path(&state); - 10863
match vak_sandbox::load_records(&path) { - 10864
Ok(records) => { - 10865
let records = records - 10866
.into_iter() - 10867
.filter(|record| match record { - 10868
vak_sandbox::DurableRecord::Candidate(value) => value.session_id == id, - 10869
vak_sandbox::DurableRecord::Promotion(value) => value.session_id == id, - 10870
vak_sandbox::DurableRecord::PromotionUndo(value) => value.session_id == id, - 10871
vak_sandbox::DurableRecord::WorkspaceCheck(value) => value.session_id == id, - 10872
vak_sandbox::DurableRecord::Environment(_) => false, - 10873
vak_sandbox::DurableRecord::PreviewPreparation(value) => value.session_id == id, - 10874
vak_sandbox::DurableRecord::CandidateRevision(value) => value.session_id == id, - 10875
}) - 10876
.collect::<Vec<_>>(); - 10877
Json(serde_json::json!({ "records": records })).into_response() - 10878
} - 10879
Err(error) => ( - 10880
StatusCode::INTERNAL_SERVER_ERROR, - 10881
Json(serde_json::json!({ "error": error.to_string() })), - 10882
) - 10883
.into_response(), - 10884
} - 10885
} - 10886
- 10887
fn sandbox_result_binding( - 10888
state: &AppState, - 10889
session_id: &str, - 10890
execution_id: &str, - 10891
) -> Result<(String, String), (StatusCode, &'static str)> { - 10892
let timeline = if let Some(handle) = state.get(session_id) { - 10893
let guard = handle - 10894
.session - 10895
.lock() - 10896
.unwrap_or_else(std::sync::PoisonError::into_inner); - 10897
if let Some(session) = guard.as_ref() { - 10898
crate::projection::snapshot(session_id, session) - 10899
} else { - 10900
handle - 10901
.presentation - 10902
.lock() - 10903
.unwrap_or_else(std::sync::PoisonError::into_inner) - 10904
.clone() - 10905
} - 10906
} else if let Some(session) = open_historical_session(state, session_id) { - 10907
crate::projection::snapshot(session_id, &session) - 10908
} else { - 10909
return Err((StatusCode::NOT_FOUND, "unknown session")); - 10910
}; - 10911
let Some(turn_id) = timeline.items.iter().find_map(|item| { - 10912
item.provenance.as_ref().and_then(|value| { - 10913
(value.tool_call_id.as_deref() == Some(execution_id)).then(|| item.turn_id.clone()) - 10914
}) - 10915
}) else { - 10916
return Err(( - 10917
StatusCode::NOT_FOUND, - 10918
"execution is not part of this session", - 10919
)); - 10920
}; - 10921
let Some(result_id) = timeline.items.iter().rev().find_map(|item| { - 10922
(item.turn_id == turn_id && item.role == vak_delivery::OutputRole::Assistant) - 10923
.then(|| item.outcome.as_ref().map(|value| value.result_id.clone())) - 10924
.flatten() - 10925
}) else { - 10926
return Err((StatusCode::CONFLICT, "result is not ready for review")); - 10927
}; - 10928
Ok((turn_id, result_id)) - 10929
} - 10930
- 10931
fn sandbox_execution_scratch( - 10932
state: &AppState, - 10933
session_id: &str, - 10934
execution_id: &str, - 10935
) -> Option<std::path::PathBuf> { - 10936
let workspace = sandbox_session_workspace(state, session_id)?; - 10937
let text = std::fs::read_to_string(session_sandbox_events_path(state, session_id)).ok()?; - 10938
text.lines().find_map(|line| { - 10939
let event = serde_json::from_str::<vak_tools::SandboxEvent>(line).ok()?; - 10940
match event { - 10941
vak_tools::SandboxEvent::ExecutionStarted { - 10942
execution_id: observed, - 10943
scratch_dir, - 10944
.. - 10945
} if observed == execution_id => confined_path(&workspace, &scratch_dir), - 10946
_ => None, - 10947
} - 10948
}) - 10949
} - 10950
- 10951
#[derive(Debug, serde::Deserialize)] - 10952
struct SandboxCandidateBody { - 10953
execution_id: String, - 10954
source: String, - 10955
#[serde(default)] - 10956
destination: String, - 10957
} - 10958
- 10959
fn javascript_package_manager( - 10960
package: &serde_json::Value, - 10961
has_effective_file: impl Fn(&str) -> bool, - 10962
) -> &'static str { - 10963
let declared = package - 10964
.get("packageManager") - 10965
.and_then(serde_json::Value::as_str) - 10966
.and_then(|value| value.split_once('@').map(|(manager, _)| manager)); - 10967
match declared { - 10968
Some("pnpm") => "pnpm", - 10969
Some("yarn") => "yarn", - 10970
Some("bun") => "bun", - 10971
Some("npm") => "npm", - 10972
_ if has_effective_file("pnpm-lock.yaml") => "pnpm", - 10973
_ if has_effective_file("yarn.lock") => "yarn", - 10974
_ if has_effective_file("bun.lock") || has_effective_file("bun.lockb") => "bun", - 10975
_ => "npm", - 10976
} - 10977
} - 10978
- 10979
fn planned_workspace_checks( - 10980
candidate: &vak_sandbox::CandidateManifest, - 10981
) -> Vec<vak_sandbox::WorkspaceCheckPlan> { - 10982
let effective_file = |name: &str| match candidate.files.iter().find(|file| file.path == name) { - 10983
Some(file) if file.operation == vak_sandbox::CandidateOperation::Upsert => { - 10984
Some(candidate.source_root.join(name)) - 10985
} - 10986
Some(_) => None, - 10987
None => Some(candidate.destination_root.join(name)), - 10988
}; - 10989
let has = |name: &str| effective_file(name).is_some_and(|path| path.is_file()); - 10990
let mut checks = Vec::new(); - 10991
if has("Cargo.toml") { - 10992
checks.push(vak_sandbox::WorkspaceCheckPlan { - 10993
id: "rust.cargo-test".into(), - 10994
label: "Rust tests".into(), - 10995
command: if has("Cargo.lock") { - 10996
"cargo test --locked".into() - 10997
} else { - 10998
"cargo test".into() - 10999
}, - 11000
}); - 11001
} - 11002
if let Some(package_path) = effective_file("package.json") - 11003
&& let Ok(bytes) = std::fs::read(package_path) - 11004
&& let Ok(package) = serde_json::from_slice::<serde_json::Value>(&bytes) - 11005
{ - 11006
let package_manager = javascript_package_manager(&package, has); - 11007
let script_command = |name: &str| format!("{package_manager} run {name}"); - 11008
let has_script = |name: &str| { - 11009
package - 11010
.get("scripts") - 11011
.and_then(|scripts| scripts.get(name)) - 11012
.and_then(serde_json::Value::as_str) - 11013
.is_some_and(|script| !script.trim().is_empty()) - 11014
}; - 11015
if has_script("build") { - 11016
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11017
id: "javascript.build".into(), - 11018
label: "Production build".into(), - 11019
command: script_command("build"), - 11020
}); - 11021
} - 11022
if has_script("test") { - 11023
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11024
id: "javascript.test".into(), - 11025
label: "Project tests".into(), - 11026
command: script_command("test"), - 11027
}); - 11028
} - 11029
} - 11030
if has("go.mod") { - 11031
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11032
id: "go.test".into(), - 11033
label: "Go tests".into(), - 11034
command: "go test ./...".into(), - 11035
}); - 11036
} - 11037
if has("pytest.ini") || has("conftest.py") { - 11038
checks.push(vak_sandbox::WorkspaceCheckPlan { - 11039
id: "python.pytest".into(), - 11040
label: "Python tests".into(), - 11041
command: "python -m pytest".into(), - 11042
}); - 11043
} - 11044
checks - 11045
} - 11046
- 11047
/// The sandbox a dev-server preview runs in: the agent's own, plus - 11048
/// listening on a port where the backend can grant it (outbound connections - 11049
/// stay denied). A preview is a server the user configured; one that cannot - 11050
/// listen dies on start. - 11051
fn preview_sandbox(core: &vak_core::Core) -> Option<Arc<dyn vak_tools::sandbox::Sandbox>> { - 11052
let sandbox = core.agent_sandbox(); - 11053
sandbox - 11054
.as_ref() - 11055
.and_then(|sandbox| sandbox.listening_variant()) - 11056
.or(sandbox) - 11057
} - 11058
- 11059
async fn export_sandbox_candidate( - 11060
State(state): State<AppState>, - 11061
Path(session_id): Path<String>, - 11062
Json(body): Json<SandboxCandidateBody>, - 11063
) -> axum::response::Response { - 11064
use axum::response::IntoResponse; - 11065
let (turn_id, result_id) = match sandbox_result_binding(&state, &session_id, &body.execution_id) - 11066
{ - 11067
Ok(binding) => binding, - 11068
Err(error) => return error.into_response(), - 11069
}; - 11070
let Some(workspace) = sandbox_session_workspace(&state, &session_id) else { - 11071
return (StatusCode::NOT_FOUND, "unknown session").into_response(); - 11072
}; - 11073
let Some(source) = confined_path(&workspace, &body.source) else { - 11074
return (StatusCode::FORBIDDEN, "candidate source outside workspace").into_response(); - 11075
}; - 11076
let Some(execution_scratch) = - 11077
sandbox_execution_scratch(&state, &session_id, &body.execution_id) - 11078
else { - 11079
return ( - 11080
StatusCode::NOT_FOUND, - 11081
"sandbox execution evidence not found", - 11082
) - 11083
.into_response(); - 11084
}; - 11085
if std::fs::canonicalize(&source).ok() != std::fs::canonicalize(&execution_scratch).ok() { - 11086
return ( - 11087
StatusCode::FORBIDDEN, - 11088
"candidate source does not belong to this execution", - 11089
) - 11090
.into_response(); - 11091
} - 11092
// A command works in the workspace itself, so its files are already where - 11093
// they belong; only an execution that ran inside `.vak/scratch/` has a - 11094
// draft to promote. Freezing the workspace as its own candidate would - 11095
// promote it onto itself. - 11096
let scratch_root = std::fs::canonicalize(workspace.join(".vak").join("scratch")).ok(); - 11097
let in_scratch = std::fs::canonicalize(&execution_scratch) - 11098
.ok() - 11099
.zip(scratch_root) - 11100
.is_some_and(|(dir, root)| dir.starts_with(root)); - 11101
if !in_scratch { - 11102
return ( - 11103
StatusCode::CONFLICT, - 11104
"this execution worked in the workspace; its files are already there", - 11105
) - 11106
.into_response(); - 11107
} - 11108
let destination_text = if body.destination.trim().is_empty() { - 11109
".".to_string() - 11110
} else { - 11111
body.destination - 11112
}; - 11113
let Some(destination) = confined_path(&workspace, &destination_text) else { - 11114
return ( - 11115
StatusCode::FORBIDDEN, - 11116
"candidate destination outside workspace", - 11117
) - 11118
.into_response(); - 11119
}; - 11120
let id = uuid::Uuid::now_v7().to_string(); - 11121
let frozen_root = sandbox_candidates_root(&state).join(&id); - 11122
if let Err(error) = std::fs::create_dir_all(sandbox_candidates_root(&state)) { - 11123
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(); - 11124
} - 11125
match vak_sandbox::freeze_candidate(&id, &source, &destination, &frozen_root) { - 11126
Ok(mut candidate) => { - 11127
candidate.target_checks = vak_sandbox::default_target_verifiers().plan(&candidate); - 11128
candidate.workspace_checks = planned_workspace_checks(&candidate); - 11129
let draft_checks = vak_tools::broker::verify_targets( - 11130
&state.core.tool_worker_exe(), - 11131
&candidate.source_root, - 11132
&candidate.target_checks, - 11133
) - 11134
.await; - 11135
let candidate_digest = match vak_sandbox::candidate_digest(&candidate) { - 11136
Ok(value) => value, - 11137
Err(error) => { - 11138
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11139
return ( - 11140
StatusCode::INTERNAL_SERVER_ERROR, - 11141
Json(serde_json::json!({"error": error.to_string()})), - 11142
) - 11143
.into_response(); - 11144
} - 11145
}; - 11146
let record = vak_sandbox::DurableRecord::Candidate(vak_sandbox::CandidateRecord { - 11147
record_id: format!("candidate-{id}"), - 11148
session_id, - 11149
turn_id, - 11150
result_id, - 11151
execution_id: body.execution_id, - 11152
environment_id: source.to_string_lossy().into_owned(), - 11153
candidate_digest, - 11154
candidate: candidate.clone(), - 11155
verified: true, - 11156
draft_checks, - 11157
updated_at: chrono::Utc::now().to_rfc3339(), - 11158
parent_candidate_id: None, - 11159
revision_session_id: None, - 11160
narrowed: None, - 11161
}); - 11162
match vak_sandbox::append_record(&sandbox_records_path(&state), &record) { - 11163
Ok(()) => Json(record).into_response(), - 11164
Err(error) => { - 11165
let _ = vak_sandbox::remove_frozen_candidate(&frozen_root); - 11166
( - 11167
StatusCode::INTERNAL_SERVER_ERROR, - 11168
Json(serde_json::json!({ "error": error.to_string() })), - 11169
) - 11170
.into_response() - 11171
} - 11172
} - 11173
} - 11174
Err(error) => ( - 11175
StatusCode::BAD_REQUEST, - 11176
Json(serde_json::json!({ "error": error.to_string() })), - 11177
) - 11178
.into_response(), - 11179
} - 11180
} - 11181
- 11182
async fn sandbox_candidate_file_bytes( - 11183
state: &AppState, - 11184
session_id: &str, - 11185
candidate_id: &str, - 11186
relative_path: &str, - 11187
) -> Result<Vec<u8>, StatusCode> { - 11188
let records = match vak_sandbox::load_records(&sandbox_records_path(state)) { - 11189
Ok(records) => records, - 11190
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), - 11191
}; - 11192
let Some(candidate) = records.iter().rev().find_map(|record| match record { - 11193
vak_sandbox::DurableRecord::Candidate(saved) - 11194
if saved.session_id == session_id && saved.candidate.candidate_id == candidate_id => - 11195
{ - 11196
Some(&saved.candidate) - 11197
} - 11198
_ => None, - 11199
}) else { - 11200
return Err(StatusCode::NOT_FOUND); - 11201
}; - 11202
let Some(file) = candidate - 11203
.files - 11204
.iter() - 11205
.find(|file| file.path == relative_path) - 11206
else { - 11207
return Err(StatusCode::NOT_FOUND); - 11208
}; - 11209
let expected_root = sandbox_candidates_root(state).join(candidate_id); - 11210
if candidate.source_root != expected_root { - 11211
return Err(StatusCode::FORBIDDEN); - 11212
} - 11213
let Some(path) = confined_path(&expected_root, &file.path) else { - 11214
return Err(StatusCode::FORBIDDEN); - 11215
}; - 11216
let bytes = match tokio::fs::read(&path).await { - 11217
Ok(bytes) => bytes, - 11218
Err(_) => return Err(StatusCode::NOT_FOUND), - 11219
}; - 11220
if vak_sandbox::digest(&bytes) != file.candidate_hash { - 11221
return Err(StatusCode::CONFLICT); - 11222
} - 11223
Ok(bytes) - 11224
} - 11225
- 11226
fn saved_candidate( - 11227
state: &AppState, - 11228
session_id: &str, - 11229
candidate_id: &str, - 11230
) -> Result<vak_sandbox::CandidateRecord, StatusCode> { - 11231
let records = vak_sandbox::load_records(&sandbox_records_path(state)) - 11232
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - 11233
records - 11234
.into_iter() - 11235
.rev() - 11236
.find_map(|record| match record { - 11237
vak_sandbox::DurableRecord::Candidate(saved) - 11238
if saved.session_id == session_id - 11239
&& saved.candidate.candidate_id == candidate_id => - 11240
{ - 11241
Some(saved) - 11242
} - 11243
_ => None, - 11244
}) - 11245
.ok_or(StatusCode::NOT_FOUND) - 11246
} - 11247
- 11248
/// How the Office draft `path` of execution `execution_id` was made: the - 11249
/// `office_apply` calls in this session's ledger, followed back through - 11250
/// each call whose source was an earlier draft to the file the chain - 11251
/// started from (docs/design/72, P3). Only successful calls count; the - 11252
/// worker then refuses a lineage that does not reproduce the draft. - 11253
/// Each `office_apply` call in a session that succeeded, in ledger order, - 11254
/// with its arguments. A call's id is its execution id, so it names the - 11255
/// directory its draft is in (`vak_tools::office_apply::draft_dir`). - 11256
fn successful_office_calls(log: &vak_session::SessionLog) -> Vec<(String, serde_json::Value)> { - 11257
let mut calls = Vec::new(); - 11258
let mut succeeded = std::collections::HashSet::new(); - 11259
for (_, message) in log.message_chain() { - 11260
for block in &message.content { - 11261
match block { - 11262
vak_llm::ContentBlock::ToolUse { id, name, input } if name == "office_apply" => { - 11263
calls.push((id.clone(), input.clone())); - 11264
} - 11265
vak_llm::ContentBlock::ToolResult { - 11266
tool_use_id, - 11267
is_error: false, - 11268
.. - 11269
} => { - 11270
succeeded.insert(tool_use_id.clone()); - 11271
} - 11272
_ => {} - 11273
} - 11274
} - 11275
} - 11276
calls.retain(|(id, _)| succeeded.contains(id)); - 11277
calls - 11278
} - 11279
- 11280
/// The Office drafts a revision turn delivered in its task copy, one per - 11281
/// file: the newest successful `office_apply` call for that file whose draft - 11282
/// exists. Each draft is a whole file, so a later one supersedes an earlier - 11283
/// one; a call repeated verbatim is answered with the earlier draft and - 11284
/// writes none, which is why a missing draft is passed over. - 11285
fn revision_office_drafts( - 11286
log: &vak_session::SessionLog, - 11287
task_root: &std::path::Path, - 11288
) -> Vec<vak_sandbox::RevisionDraft> { - 11289
let Ok(root) = task_root.canonicalize() else { - 11290
return Vec::new(); - 11291
}; - 11292
let agent = log - 11293
.header() - 11294
.and_then(|header| header.agent.as_ref().map(|agent| agent.id.clone())); - 11295
let mut drafts: Vec<vak_sandbox::RevisionDraft> = Vec::new(); - 11296
for (call_id, args) in successful_office_calls(log).into_iter().rev() { - 11297
let Some(relative) = args - 11298
.get("path") - 11299
.and_then(serde_json::Value::as_str) - 11300
.and_then(|path| confined_path(&root, path.trim())) - 11301
.and_then(|path| { - 11302
path.strip_prefix(&root) - 11303
.ok() - 11304
.map(std::path::Path::to_path_buf) - 11305
}) - 11306
else { - 11307
continue; - 11308
}; - 11309
let path = relative.to_string_lossy().replace('\\', "/"); - 11310
if drafts.iter().any(|draft| draft.path == path) { - 11311
continue; - 11312
} - 11313
let draft = vak_tools::office_apply::draft_dir(agent.as_deref(), &call_id).join(&relative); - 11314
if root.join(&draft).is_file() { - 11315
drafts.push(vak_sandbox::RevisionDraft { - 11316
path, - 11317
draft: draft.to_string_lossy().replace('\\', "/"), - 11318
}); - 11319
} - 11320
} - 11321
drafts - 11322
} - 11323
- 11324
fn office_lineage( - 11325
state: &AppState, - 11326
session_id: &str, - 11327
execution_id: &str, - 11328
path: &str, - 11329
) -> Result<vak_tools::broker::OfficeLineage, String> { - 11330
let workspace = sandbox_session_workspace(state, session_id).ok_or("unknown session")?; - 11331
let root = workspace - 11332
.canonicalize() - 11333
.map_err(|error| format!("cannot resolve the workspace: {error}"))?; - 11334
// A file not yet in the workspace is a new document, whose Word edits - 11335
// were written clean; the replay must write them the same way. - 11336
let new_file = !confined_path(&root, path).is_some_and(|file| file.is_file()); - 11337
let collect = |log: &vak_session::SessionLog| { - 11338
let calls: std::collections::HashMap<_, _> = - 11339
successful_office_calls(log).into_iter().collect(); - 11340
let agent = log - 11341
.header() - 11342
.and_then(|header| header.agent.as_ref().map(|agent| agent.id.clone())); - 11343
(calls, agent) - 11344
}; - 11345
let live = state.get(session_id).and_then(|handle| { - 11346
handle - 11347
.session - 11348
.lock() - 11349
.unwrap_or_else(std::sync::PoisonError::into_inner) - 11350
.as_ref() - 11351
.map(collect) - 11352
}); - 11353
let (calls, agent) = match live { - 11354
Some(found) => found, - 11355
None => open_historical_session(state, session_id) - 11356
.map(|log| collect(&log)) - 11357
.ok_or("the session ledger cannot be read")?, - 11358
}; - 11359
let relative = |value: &str| -> Option<std::path::PathBuf> { - 11360
confined_path(&root, value)? - 11361
.strip_prefix(&root) - 11362
.ok() - 11363
.map(std::path::Path::to_path_buf) - 11364
}; - 11365
let mut ops = Vec::new(); - 11366
let mut call_id = execution_id.to_string(); - 11367
let mut wanted = std::path::PathBuf::from(path); - 11368
for _ in 0..64 { - 11369
let Some(args) = calls.get(&call_id) else { - 11370
return Err( - 11371
"the draft was not made by a successful office_apply call in this conversation" - 11372
.into(), - 11373
); - 11374
}; - 11375
let target = args - 11376
.get("path") - 11377
.and_then(serde_json::Value::as_str) - 11378
.and_then(relative); - 11379
if target.as_deref() != Some(wanted.as_path()) { - 11380
return Err(format!( - 11381
"office_apply {call_id} did not write {}", - 11382
wanted.display() - 11383
)); - 11384
} - 11385
let mut call_ops: Vec<vak_ooxml::edit::OfficeOp> = args - 11386
.get("ops") - 11387
.cloned() - 11388
.and_then(|ops| serde_json::from_value(ops).ok()) - 11389
.ok_or("a recorded office_apply call has ops this version cannot read")?; - 11390
call_ops.append(&mut ops); - 11391
ops = call_ops; - 11392
let source_text = args - 11393
.get("source") - 11394
.and_then(serde_json::Value::as_str) - 11395
.map(str::trim) - 11396
.filter(|source| !source.is_empty()) - 11397
.or_else(|| args.get("path").and_then(serde_json::Value::as_str)) - 11398
.unwrap_or_default(); - 11399
let source = relative(source_text).ok_or("a draft's source is outside the workspace")?; - 11400
let parts: Vec<String> = source - 11401
.components() - 11402
.map(|part| part.as_os_str().to_string_lossy().into_owned()) - 11403
.collect(); - 11404
match parts.as_slice() { - 11405
[vak, scratch, _agent, earlier, rest @ ..] if vak == ".vak" && scratch == "scratch" => { - 11406
call_id = earlier.clone(); - 11407
wanted = rest.iter().collect(); - 11408
} - 11409
[vak, ..] if vak == ".vak" => { - 11410
return Err("a draft's source is inside .vak but not an earlier draft".into()); - 11411
} - 11412
_ => { - 11413
let base_digest = args - 11414
.get("base_digest") - 11415
.and_then(serde_json::Value::as_str) - 11416
.map(str::trim) - 11417
.unwrap_or_default() - 11418
.to_string(); - 11419
let named_source = args - 11420
.get("source") - 11421
.and_then(serde_json::Value::as_str) - 11422
.is_some_and(|source| !source.trim().is_empty()); - 11423
// A successful call with neither a source nor a digest made - 11424
// its file from the built-in blank ("Creating from scratch"). - 11425
let origin = if !named_source && base_digest.is_empty() { - 11426
vak_tools::broker::OfficeOrigin::Blank - 11427
} else { - 11428
vak_tools::broker::OfficeOrigin::File { - 11429
path: root.join(source), - 11430
base_digest, - 11431
} - 11432
}; - 11433
let from_blank = origin == vak_tools::broker::OfficeOrigin::Blank; - 11434
return Ok(vak_tools::broker::OfficeLineage { - 11435
origin, - 11436
ops, - 11437
author: vak_tools::office_apply::tracked_change_author( - 11438
agent.as_deref().unwrap_or("vak"), - 11439
), - 11440
new_file: new_file || from_blank, - 11441
}); - 11442
} - 11443
} - 11444
} - 11445
Err("the chain of drafts is too long to follow".into()) - 11446
} - 11447
- 11448
/// What an Office file in a candidate changes, compared with the workspace - 11449
/// file it would replace, and the changes a person can keep or leave out - 11450
/// (docs/design/72, P3). Every package is parsed in a worker, never in the - 11451
/// server (invariant 39). - 11452
async fn read_sandbox_candidate_office_review( - 11453
State(state): State<AppState>, - 11454
Path((session_id, candidate_id)): Path<(String, String)>, - 11455
axum::extract::Query(q): axum::extract::Query<FileQuery>, - 11456
) -> axum::response::Response { - 11457
use axum::response::IntoResponse; - 11458
if !vak_ooxml::is_openxml_path(&q.path) { - 11459
return ( - 11460
StatusCode::BAD_REQUEST, - 11461
"not a Word, Excel, PowerPoint or Visio file", - 11462
) - 11463
.into_response();
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.