- 9732
turn: None, - 9733
kind: vak_session::ActivityKind::PresentationProposal, - 9734
status: vak_session::ActivityStatus::Succeeded, - 9735
label: "Presentation proposal previewed".into(), - 9736
detail: Some("immutable disabled revision".into()), - 9737
data, - 9738
}; - 9739
handle - 9740
.activity_buffer - 9741
.lock() - 9742
.unwrap_or_else(std::sync::PoisonError::into_inner) - 9743
.push(activity); - 9744
Json(revision).into_response() - 9745
} - 9746
Err(error) => { - 9747
let mut data = std::collections::BTreeMap::new(); - 9748
data.insert("chain_id".into(), chain_id); - 9749
data.insert("error".into(), error.to_string()); - 9750
handle - 9751
.activity_buffer - 9752
.lock() - 9753
.unwrap_or_else(std::sync::PoisonError::into_inner) - 9754
.push(vak_session::ActivityRecord { - 9755
activity_id: format!("presentation-proposal-{}", uuid::Uuid::now_v7()), - 9756
turn: None, - 9757
kind: vak_session::ActivityKind::PresentationProposal, - 9758
status: vak_session::ActivityStatus::Failed, - 9759
label: "Presentation proposal rejected".into(), - 9760
detail: Some("invalid immutable revision".into()), - 9761
data, - 9762
}); - 9763
( - 9764
StatusCode::BAD_REQUEST, - 9765
Json(serde_json::json!({ "error": error.to_string() })), - 9766
) - 9767
.into_response() - 9768
} - 9769
} - 9770
} - 9771
- 9772
async fn activate_presentation( - 9773
State(state): State<AppState>, - 9774
Path((id, revision)): Path<(String, u64)>, - 9775
Json(body): Json<PresentationScopeBody>, - 9776
) -> axum::response::Response { - 9777
let store = presentation_store(&state); - 9778
let result = store.load().and_then(|mut library| { - 9779
let activation = library - 9780
.activate(&id, revision, body.scope, &body.owner) - 9781
.map_err(|error| { - 9782
vak_store::presentation::PresentationStoreError::Invalid(error.to_string()) - 9783
})?; - 9784
store.save(&library)?; - 9785
Ok(activation) - 9786
}); - 9787
match result { - 9788
Ok(activation) => Json(activation).into_response(), - 9789
Err(error) => ( - 9790
StatusCode::BAD_REQUEST, - 9791
Json(serde_json::json!({ "error": error.to_string() })), - 9792
) - 9793
.into_response(), - 9794
} - 9795
} - 9796
- 9797
async fn deactivate_presentation( - 9798
State(state): State<AppState>, - 9799
Path(id): Path<String>, - 9800
Json(body): Json<PresentationScopeBody>, - 9801
) -> axum::response::Response { - 9802
let store = presentation_store(&state); - 9803
match store.load() { - 9804
Ok(mut library) => { - 9805
library.deactivate(&id, body.scope, &body.owner); - 9806
match store.save(&library) { - 9807
Ok(()) => Json(serde_json::json!({ "deactivated": true })).into_response(), - 9808
Err(error) => ( - 9809
StatusCode::INTERNAL_SERVER_ERROR, - 9810
Json(serde_json::json!({ "error": error.to_string() })), - 9811
) - 9812
.into_response(), - 9813
} - 9814
} - 9815
Err(error) => ( - 9816
StatusCode::INTERNAL_SERVER_ERROR, - 9817
Json(serde_json::json!({ "error": error.to_string() })), - 9818
) - 9819
.into_response(), - 9820
} - 9821
} - 9822
- 9823
async fn activate_all_presentations( - 9824
State(state): State<AppState>, - 9825
Json(body): Json<PresentationScopeBody>, - 9826
) -> axum::response::Response { - 9827
let store = presentation_store(&state); - 9828
let result = store.load().and_then(|mut library| { - 9829
for seed in vak_presentation::seeds::built_in_seed_pack() { - 9830
library.register(seed).map_err(|error| { - 9831
vak_store::presentation::PresentationStoreError::Invalid(error.to_string()) - 9832
})?; - 9833
} - 9834
let mut latest_by_id: std::collections::BTreeMap<String, u64> = - 9835
std::collections::BTreeMap::new(); - 9836
for def in library.definitions() { - 9837
let entry = latest_by_id - 9838
.entry(def.spec.id.clone()) - 9839
.or_insert(def.spec.revision); - 9840
if def.spec.revision > *entry { - 9841
*entry = def.spec.revision; - 9842
} - 9843
} - 9844
let mut activated = 0; - 9845
for (id, rev) in latest_by_id { - 9846
if library.activate(&id, rev, body.scope, &body.owner).is_ok() { - 9847
activated += 1; - 9848
} - 9849
} - 9850
store.save(&library)?; - 9851
Ok(activated) - 9852
}); - 9853
match result { - 9854
Ok(count) => Json(serde_json::json!({ "activated": count })).into_response(), - 9855
Err(error) => ( - 9856
StatusCode::BAD_REQUEST, - 9857
Json(serde_json::json!({ "error": error.to_string() })), - 9858
) - 9859
.into_response(), - 9860
} - 9861
} - 9862
- 9863
async fn deactivate_all_presentations( - 9864
State(state): State<AppState>, - 9865
Json(body): Json<PresentationScopeBody>, - 9866
) -> axum::response::Response { - 9867
let store = presentation_store(&state); - 9868
match store.load() { - 9869
Ok(mut library) => { - 9870
let mut spec_ids: std::collections::BTreeSet<String> = library - 9871
.definitions() - 9872
.map(|record| record.spec.id.clone()) - 9873
.collect(); - 9874
spec_ids.extend( - 9875
vak_presentation::seeds::built_in_seed_pack() - 9876
.into_iter() - 9877
.map(|record| record.spec.id), - 9878
); - 9879
let mut removed = 0; - 9880
for id in spec_ids { - 9881
if !library.is_suppressed(&id, body.scope, &body.owner) { - 9882
removed += 1; - 9883
} - 9884
library.deactivate(&id, body.scope, &body.owner); - 9885
} - 9886
match store.save(&library) { - 9887
Ok(()) => Json(serde_json::json!({ "deactivated": removed })).into_response(), - 9888
Err(error) => ( - 9889
StatusCode::INTERNAL_SERVER_ERROR, - 9890
Json(serde_json::json!({ "error": error.to_string() })), - 9891
) - 9892
.into_response(), - 9893
} - 9894
} - 9895
Err(error) => ( - 9896
StatusCode::INTERNAL_SERVER_ERROR, - 9897
Json(serde_json::json!({ "error": error.to_string() })), - 9898
) - 9899
.into_response(), - 9900
} - 9901
} - 9902
- 9903
async fn reset_presentation( - 9904
State(state): State<AppState>, - 9905
Path(id): Path<String>, - 9906
Json(body): Json<PresentationScopeBody>, - 9907
) -> axum::response::Response { - 9908
let store = presentation_store(&state); - 9909
match store.load() { - 9910
Ok(mut library) => { - 9911
let restored = library.reset(&id, body.scope, &body.owner); - 9912
match store.save(&library) { - 9913
Ok(()) => { - 9914
Json(serde_json::json!({ "reset": true, "restored": restored })).into_response() - 9915
} - 9916
Err(error) => ( - 9917
StatusCode::INTERNAL_SERVER_ERROR, - 9918
Json(serde_json::json!({ "error": error.to_string() })), - 9919
) - 9920
.into_response(), - 9921
} - 9922
} - 9923
Err(error) => ( - 9924
StatusCode::INTERNAL_SERVER_ERROR, - 9925
Json(serde_json::json!({ "error": error.to_string() })), - 9926
) - 9927
.into_response(), - 9928
} - 9929
} - 9930
- 9931
async fn revoke_presentations_plugin( - 9932
State(state): State<AppState>, - 9933
Path(plugin_id): Path<String>, - 9934
) -> axum::response::Response { - 9935
match presentation_store(&state).revoke_plugin(&plugin_id) { - 9936
Ok(removed) => Json(serde_json::json!({ "removed": removed })).into_response(), - 9937
Err(error) => ( - 9938
StatusCode::INTERNAL_SERVER_ERROR, - 9939
Json(serde_json::json!({ "error": error.to_string() })), - 9940
) - 9941
.into_response(), - 9942
} - 9943
} - 9944
- 9945
async fn list_plugins( - 9946
State(state): State<AppState>, - 9947
Query(query): Query<PluginScopeQuery>, - 9948
) -> axum::response::Response { - 9949
use axum::response::IntoResponse; - 9950
let core = scoped_core!(&state, None, query.agent.as_deref()); - 9951
let mut plugins = Vec::new(); - 9952
for scope in requested_plugin_scopes(query.scope) { - 9953
if let Ok(items) = plugin_store(&core, scope).list() { - 9954
let policy = core.effective_plugins(); - 9955
plugins.extend( - 9956
items - 9957
.into_iter() - 9958
.map(|plugin| { - 9959
serde_json::json!({ - 9960
"name": plugin.name, - 9961
"version": plugin.version, - 9962
"digest": plugin.digest, - 9963
"description": plugin.description, - 9964
"format": plugin.format, - 9965
"scope": plugin.scope, - 9966
"enabled": plugin.enabled, - 9967
"network_allowed": policy.is_network_allowed(&plugin.name), - 9968
"network_denied": policy.network_deny.contains(&plugin.name), - 9969
"network_allow": policy.network_allow, - 9970
"trace_id": plugin.trace_id, - 9971
"capabilities": plugin.capabilities, - 9972
"warnings": plugin.warnings, - 9973
}) - 9974
}) - 9975
.collect::<Vec<_>>(), - 9976
); - 9977
} - 9978
} - 9979
Json(serde_json::json!({ "plugins": plugins })).into_response() - 9980
} - 9981
- 9982
async fn plugin_audit( - 9983
State(state): State<AppState>, - 9984
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 9985
) -> axum::response::Response { - 9986
use axum::response::IntoResponse; - 9987
let core = scoped_core!(&state, None, q.agent.as_deref()); - 9988
let mut events = Vec::new(); - 9989
for scope in [InstallScope::User, InstallScope::Workspace] { - 9990
if let Ok(registry) = plugin_store(&core, scope).load() { - 9991
events.extend(registry.audit); - 9992
} - 9993
} - 9994
events.sort_by_key(|event| event.at_unix); - 9995
Json(serde_json::json!({ "audit": events })).into_response() - 9996
} - 9997
- 9998
async fn plugin_invocations( - 9999
State(state): State<AppState>, - 10000
axum::extract::Query(q): axum::extract::Query<AgentScopeQuery>, - 10001
) -> axum::response::Response { - 10002
use axum::response::IntoResponse; - 10003
let core = scoped_core!(&state, None, q.agent.as_deref()); - 10004
let mut events = Vec::new(); - 10005
for scope in [InstallScope::User, InstallScope::Workspace] { - 10006
if let Ok(items) = plugin_store(&core, scope).invocations() { - 10007
events.extend(items); - 10008
} - 10009
} - 10010
events.sort_by_key(|event| event.at_unix); - 10011
Json(serde_json::json!({ "invocations": events })).into_response() - 10012
} - 10013
- 10014
async fn plugin_sources( - 10015
State(state): State<AppState>, - 10016
Query(query): Query<PluginScopeQuery>, - 10017
) -> axum::response::Response { - 10018
use axum::response::IntoResponse; - 10019
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10020
let mut sources = Vec::new(); - 10021
for scope in requested_plugin_scopes(query.scope) { - 10022
if let Ok(items) = plugin_store(&core, scope).list_sources() { - 10023
sources.extend(items); - 10024
} - 10025
} - 10026
Json(serde_json::json!({ "sources": sources })).into_response() - 10027
} - 10028
- 10029
async fn plugin_catalog( - 10030
State(state): State<AppState>, - 10031
Query(query): Query<PluginCatalogQuery>, - 10032
) -> axum::response::Response { - 10033
use axum::response::IntoResponse; - 10034
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10035
let needle = query.q.as_deref().unwrap_or_default().trim().to_lowercase(); - 10036
let mut entries = Vec::new(); - 10037
let mut errors = Vec::new(); - 10038
for scope in requested_plugin_scopes(query.scope) { - 10039
let store = plugin_store(&core, scope); - 10040
let sources = match store.list_sources() { - 10041
Ok(sources) => sources, - 10042
Err(error) => { - 10043
errors.push(serde_json::json!({ "scope": scope, "error": error.to_string() })); - 10044
continue; - 10045
} - 10046
}; - 10047
for source in sources { - 10048
let inspection = match vak_plugin::inspect_catalog(&source.root) { - 10049
Ok(inspection) => inspection, - 10050
Err(error) => { - 10051
errors.push( - 10052
serde_json::json!({ "source_id": source.id, "error": error.to_string() }), - 10053
); - 10054
continue; - 10055
} - 10056
}; - 10057
if inspection.digest != source.catalog_digest { - 10058
errors.push(serde_json::json!({ - 10059
"source_id": source.id, - 10060
"error": "catalog changed since registration", - 10061
})); - 10062
continue; - 10063
} - 10064
for entry in inspection.entries { - 10065
let haystack = format!( - 10066
"{} {}", - 10067
entry.name, - 10068
entry.description.as_deref().unwrap_or_default() - 10069
) - 10070
.to_lowercase(); - 10071
if !needle.is_empty() && !haystack.contains(&needle) { - 10072
continue; - 10073
} - 10074
entries.push(serde_json::json!({ - 10075
"source_id": source.id, - 10076
"source_label": source.label, - 10077
"source_enabled": source.enabled, - 10078
"source_scope": scope, - 10079
"catalog_digest": source.catalog_digest, - 10080
"name": entry.name, - 10081
"version": entry.version, - 10082
"description": entry.description, - 10083
"license": entry.license, - 10084
})); - 10085
} - 10086
} - 10087
} - 10088
entries.sort_by(|a, b| { - 10089
a["name"] - 10090
.as_str() - 10091
.cmp(&b["name"].as_str()) - 10092
.then_with(|| a["source_id"].as_str().cmp(&b["source_id"].as_str())) - 10093
}); - 10094
Json(serde_json::json!({ "entries": entries, "errors": errors })).into_response() - 10095
} - 10096
- 10097
async fn plugin_register_source( - 10098
State(state): State<AppState>, - 10099
Json(request): Json<PluginSourceMutation>, - 10100
) -> axum::response::Response { - 10101
let evidence = match (request.key_id, request.public_key, request.signature) { - 10102
(None, None, None) => None, - 10103
(Some(key_id), Some(public_key), Some(signature)) => Some(SignatureEvidence { - 10104
algorithm: "ed25519".into(), - 10105
key_id, - 10106
public_key, - 10107
signature, - 10108
verified: false, - 10109
revoked: false, - 10110
}), - 10111
_ => { - 10112
return ( - 10113
StatusCode::BAD_REQUEST, - 10114
"key_id, public_key, and signature must be supplied together", - 10115
) - 10116
.into_response(); - 10117
} - 10118
}; - 10119
let scope = request.scope.unwrap_or(InstallScope::Workspace); - 10120
let core = scoped_core!(&state, None, request.agent.as_deref()); - 10121
plugin_result( - 10122
plugin_store(&core, scope).register_catalog_source_with_signature( - 10123
&request.path, - 10124
&request.label, - 10125
request.trust, - 10126
evidence, - 10127
), - 10128
) - 10129
} - 10130
- 10131
async fn plugin_source_enable( - 10132
State(state): State<AppState>, - 10133
Path(id): Path<String>, - 10134
Query(query): Query<PluginScopeQuery>, - 10135
) -> axum::response::Response { - 10136
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10137
plugin_result( - 10138
plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)) - 10139
.set_source_enabled(&id, true), - 10140
) - 10141
} - 10142
- 10143
async fn plugin_source_disable( - 10144
State(state): State<AppState>, - 10145
Path(id): Path<String>, - 10146
Query(query): Query<PluginScopeQuery>, - 10147
) -> axum::response::Response { - 10148
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10149
plugin_result( - 10150
plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)) - 10151
.set_source_enabled(&id, false), - 10152
) - 10153
} - 10154
- 10155
async fn plugin_key_revoke( - 10156
State(state): State<AppState>, - 10157
Path(id): Path<String>, - 10158
Query(query): Query<PluginScopeQuery>, - 10159
) -> axum::response::Response { - 10160
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10161
plugin_result( - 10162
plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)) - 10163
.set_key_revoked(&id, true) - 10164
.map(|_| serde_json::json!({"revoked": id})), - 10165
) - 10166
} - 10167
- 10168
async fn plugin_key_restore( - 10169
State(state): State<AppState>, - 10170
Path(id): Path<String>, - 10171
Query(query): Query<PluginScopeQuery>, - 10172
) -> axum::response::Response { - 10173
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10174
plugin_result( - 10175
plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)) - 10176
.set_key_revoked(&id, false) - 10177
.map(|_| serde_json::json!({"revoked": false, "key_id": id})), - 10178
) - 10179
} - 10180
- 10181
fn plugin_result( - 10182
result: Result<impl serde::Serialize, vak_plugin::PluginError>, - 10183
) -> axum::response::Response { - 10184
match result { - 10185
Ok(value) => (StatusCode::OK, Json(value)).into_response(), - 10186
Err(error) => ( - 10187
StatusCode::BAD_REQUEST, - 10188
Json(serde_json::json!({ "error": error.to_string() })), - 10189
) - 10190
.into_response(), - 10191
} - 10192
} - 10193
- 10194
async fn plugin_install( - 10195
State(state): State<AppState>, - 10196
Json(request): Json<PluginMutation>, - 10197
) -> axum::response::Response { - 10198
let Some(path) = request.path else { - 10199
return (StatusCode::BAD_REQUEST, "path is required").into_response(); - 10200
}; - 10201
let scope = request.scope.unwrap_or(InstallScope::Workspace); - 10202
let core = scoped_core!(&state, None, request.agent.as_deref()); - 10203
plugin_result(plugin_store(&core, scope).install_local( - 10204
&path, - 10205
InstallOptions { - 10206
allow_unlicensed: request.allow_unlicensed, - 10207
scope, - 10208
}, - 10209
)) - 10210
} - 10211
- 10212
async fn plugin_update( - 10213
State(state): State<AppState>, - 10214
Json(request): Json<PluginMutation>, - 10215
) -> axum::response::Response { - 10216
let Some(path) = request.path else { - 10217
return (StatusCode::BAD_REQUEST, "path is required").into_response(); - 10218
}; - 10219
let scope = request.scope.unwrap_or(InstallScope::Workspace); - 10220
let core = scoped_core!(&state, None, request.agent.as_deref()); - 10221
plugin_result(plugin_store(&core, scope).update_local( - 10222
&path, - 10223
InstallOptions { - 10224
allow_unlicensed: request.allow_unlicensed, - 10225
scope, - 10226
}, - 10227
)) - 10228
} - 10229
- 10230
async fn plugin_enable( - 10231
State(state): State<AppState>, - 10232
Path(name): Path<String>, - 10233
Query(query): Query<PluginScopeQuery>, - 10234
) -> axum::response::Response { - 10235
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10236
plugin_result(plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)).enable(&name)) - 10237
} - 10238
- 10239
async fn plugin_disable( - 10240
State(state): State<AppState>, - 10241
Path(name): Path<String>, - 10242
Query(query): Query<PluginScopeQuery>, - 10243
) -> axum::response::Response { - 10244
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10245
let result = plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)).disable(&name); - 10246
match result { - 10247
Ok(value) => match presentation_store(&state).revoke_plugin(&name) { - 10248
Ok(_) => (StatusCode::OK, Json(value)).into_response(), - 10249
Err(error) => ( - 10250
StatusCode::INTERNAL_SERVER_ERROR, - 10251
Json(serde_json::json!({ "error": format!("plugin disabled but presentation revocation failed: {error}") })), - 10252
).into_response(), - 10253
}, - 10254
Err(error) => plugin_result(Err::<serde_json::Value, _>(error)), - 10255
} - 10256
} - 10257
- 10258
async fn plugin_rollback( - 10259
State(state): State<AppState>, - 10260
Path(name): Path<String>, - 10261
Query(query): Query<PluginScopeQuery>, - 10262
) -> axum::response::Response { - 10263
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10264
let result = - 10265
plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)).rollback(&name); - 10266
match result { - 10267
Ok(value) => match presentation_store(&state).revoke_plugin(&name) { - 10268
Ok(_) => (StatusCode::OK, Json(value)).into_response(), - 10269
Err(error) => ( - 10270
StatusCode::INTERNAL_SERVER_ERROR, - 10271
Json(serde_json::json!({ "error": format!("plugin rolled back but presentation revocation failed: {error}") })), - 10272
).into_response(), - 10273
}, - 10274
Err(error) => plugin_result(Err::<serde_json::Value, _>(error)), - 10275
} - 10276
} - 10277
- 10278
async fn plugin_remove( - 10279
State(state): State<AppState>, - 10280
Path(name): Path<String>, - 10281
Query(query): Query<PluginScopeQuery>, - 10282
) -> axum::response::Response { - 10283
let core = scoped_core!(&state, None, query.agent.as_deref()); - 10284
let result = plugin_store(&core, query.scope.unwrap_or(InstallScope::Workspace)).remove(&name); - 10285
match result { - 10286
Ok(value) => match presentation_store(&state).revoke_plugin(&name) { - 10287
Ok(_) => (StatusCode::OK, Json(value)).into_response(), - 10288
Err(error) => ( - 10289
StatusCode::INTERNAL_SERVER_ERROR, - 10290
Json(serde_json::json!({ "error": format!("plugin removed but presentation revocation failed: {error}") })), - 10291
).into_response(), - 10292
}, - 10293
Err(error) => plugin_result(Err::<serde_json::Value, _>(error)), - 10294
} - 10295
} - 10296
- 10297
#[derive(serde::Deserialize)] - 10298
struct FileQuery { - 10299
path: String, - 10300
} - 10301
- 10302
/// Resolve `input` (absolute or cwd-relative) inside the workspace root. - 10303
/// Symlinks are resolved for the existing portion; escapes are rejected. - 10304
fn confined_path(cwd: &std::path::Path, input: &str) -> Option<std::path::PathBuf> { - 10305
let base = cwd.canonicalize().ok()?; - 10306
let raw = std::path::PathBuf::from(input); - 10307
let joined = if raw.is_absolute() { - 10308
raw - 10309
} else { - 10310
base.join(raw) - 10311
}; - 10312
let mut ancestor = joined.as_path(); - 10313
loop { - 10314
match ancestor.canonicalize() { - 10315
Ok(canonical) => { - 10316
let tail = joined - 10317
.strip_prefix(ancestor) - 10318
.unwrap_or(std::path::Path::new("")); - 10319
// join("") would append a trailing separator and break reads. - 10320
let resolved = if tail.as_os_str().is_empty() { - 10321
canonical - 10322
} else { - 10323
canonical.join(tail) - 10324
}; - 10325
return if resolved.starts_with(&base) { - 10326
Some(resolved) - 10327
} else { - 10328
None - 10329
}; - 10330
} - 10331
Err(_) => { - 10332
ancestor = ancestor.parent()?; - 10333
} - 10334
} - 10335
} - 10336
} - 10337
- 10338
fn resolve_confined_file(state: &AppState, input: &str) -> Option<std::path::PathBuf> { - 10339
let clean = input - 10340
.trim() - 10341
.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '\'', '"']); - 10342
let active = state.active_core(); - 10343
- 10344
// 1. Direct workspace check - 10345
if let Some(p) = - 10346
confined_path(active.cwd(), clean).or_else(|| confined_path(state.core.cwd(), clean)) - 10347
&& p.exists() - 10348
{ - 10349
return Some(p); - 10350
} - 10351
- 10352
// 2. Quarantine scratch check: if file is in .vak/scratch/<subdirs> - 10353
for cwd in [active.cwd(), state.core.cwd()] { - 10354
let scratch_dir = cwd.join(".vak").join("scratch"); - 10355
if scratch_dir.is_dir() { - 10356
let rel = clean - 10357
.strip_prefix("./") - 10358
.unwrap_or(clean) - 10359
.strip_prefix(".vak/scratch/") - 10360
.unwrap_or_else(|| clean.strip_prefix("scratch/").unwrap_or(clean)); - 10361
- 10362
let direct = scratch_dir.join(rel); - 10363
if direct.is_file() - 10364
&& let Some(canon) = confined_path(cwd, &direct.display().to_string()) - 10365
{ - 10366
return Some(canon); - 10367
} - 10368
- 10369
// Search execution subdirectories under .vak/scratch (including agent-scoped .vak/scratch/<agent_id>/<exec_id>) - 10370
if let Ok(entries) = std::fs::read_dir(&scratch_dir) { - 10371
for entry in entries.flatten() { - 10372
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - 10373
let sub_path = entry.path().join(rel); - 10374
if sub_path.is_file() - 10375
&& let Some(canon) = confined_path(cwd, &sub_path.display().to_string()) - 10376
{ - 10377
return Some(canon); - 10378
} - 10379
if let Ok(sub_entries) = std::fs::read_dir(entry.path()) { - 10380
for sub in sub_entries.flatten() { - 10381
if sub.file_type().map(|t| t.is_dir()).unwrap_or(false) { - 10382
let nested = sub.path().join(rel); - 10383
if nested.is_file() - 10384
&& let Some(canon) = - 10385
confined_path(cwd, &nested.display().to_string()) - 10386
{ - 10387
return Some(canon); - 10388
} - 10389
if let Some(filename) = std::path::Path::new(rel).file_name() { - 10390
let by_name = sub.path().join(filename); - 10391
if by_name.is_file() - 10392
&& let Some(canon) = - 10393
confined_path(cwd, &by_name.display().to_string()) - 10394
{ - 10395
return Some(canon); - 10396
} - 10397
} - 10398
} - 10399
} - 10400
} - 10401
if let Some(filename) = std::path::Path::new(rel).file_name() { - 10402
let by_name = entry.path().join(filename); - 10403
if by_name.is_file() - 10404
&& let Some(canon) = - 10405
confined_path(cwd, &by_name.display().to_string()) - 10406
{ - 10407
return Some(canon); - 10408
} - 10409
} - 10410
} - 10411
} - 10412
} - 10413
} - 10414
} - 10415
- 10416
// Fallback: standard confined_path even if not yet on disk (needed for write_file) - 10417
confined_path(active.cwd(), clean).or_else(|| confined_path(state.core.cwd(), clean)) - 10418
} - 10419
- 10420
#[derive(Debug, serde::Deserialize)] - 10421
struct OfficeProjectionQuery { - 10422
path: String, - 10423
#[serde(default)] - 10424
from: usize, - 10425
/// `structure` for the Structure view, `facts` for a file card; anything - 10426
/// else is content. - 10427
#[serde(default)] - 10428
view: Option<String>, - 10429
/// A cited anchor: content from the unit it names (docs/design/72, - 10430
/// "Read and cite"). - 10431
#[serde(default)] - 10432
at: Option<String>, - 10433
} - 10434
- 10435
impl OfficeProjectionQuery { - 10436
fn view(&self) -> vak_tools::broker::OfficeView { - 10437
use vak_tools::broker::OfficeView; - 10438
match (self.view.as_deref(), &self.at) { - 10439
(Some("structure"), _) => OfficeView::Structure, - 10440
(Some("facts"), _) => OfficeView::Facts, - 10441
(_, Some(anchor)) if vak_ooxml::is_anchor(anchor) => OfficeView::At { - 10442
anchor: anchor.clone(), - 10443
}, - 10444
_ => OfficeView::Content { from: self.from }, - 10445
} - 10446
} - 10447
} - 10448
- 10449
#[derive(serde::Deserialize)] - 10450
struct InboxUploadQuery { - 10451
name: String, - 10452
} - 10453
- 10454
/// A file a person drops on the conversation (docs/design/72, "File in"): - 10455
/// saved to the workspace inbox exactly as a channel attachment is, whatever - 10456
/// the permission mode, since it is the person's own file arriving. The - 10457
/// reply names the saved path, which a run request then attaches. - 10458
async fn upload_to_inbox( - 10459
State(state): State<AppState>, - 10460
axum::extract::Query(q): axum::extract::Query<InboxUploadQuery>, - 10461
body: axum::body::Bytes, - 10462
) -> axum::response::Response { - 10463
use axum::response::IntoResponse; - 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;
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.