use lex_extension_host::{
Capability as TrustCapability, Source as TrustSource, Transport as TrustTransport,
TrustDecision, TrustPromptContext, TrustPromptHandler,
};
use serde::{Deserialize, Serialize};
use tower_lsp::async_trait;
use tower_lsp::jsonrpc::Result as JsonRpcResult;
use tower_lsp::lsp_types::request::Request;
pub enum LexTrustRequest {}
impl Request for LexTrustRequest {
type Params = TrustRequestParams;
type Result = TrustResponse;
const METHOD: &'static str = "lex/trustRequest";
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustRequestParams {
pub namespace: String,
pub command_string: String,
pub source: TrustRequestSource,
pub capability: String,
pub transport: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TrustRequestSource {
LexToml { name: String },
LocalFile { path: String },
CacheOnly { uri: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct TrustResponse {
pub decision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[async_trait]
pub trait LspTrustRequester: Send + Sync + 'static {
async fn send_trust_request(&self, params: TrustRequestParams) -> JsonRpcResult<TrustResponse>;
}
#[async_trait]
impl LspTrustRequester for tower_lsp::Client {
async fn send_trust_request(&self, params: TrustRequestParams) -> JsonRpcResult<TrustResponse> {
self.send_request::<LexTrustRequest>(params).await
}
}
fn params_from_ctx(ctx: &TrustPromptContext) -> TrustRequestParams {
let source = match &ctx.source {
TrustSource::LexTomlNamespace { name } => {
TrustRequestSource::LexToml { name: name.clone() }
}
TrustSource::LocalFile { path } => TrustRequestSource::LocalFile {
path: path.display().to_string(),
},
TrustSource::CacheOnly { uri } => TrustRequestSource::CacheOnly { uri: uri.clone() },
};
let capability = match ctx.capability {
TrustCapability::Pure => "pure",
TrustCapability::Full => "full",
}
.to_string();
let transport = "subprocess".to_string();
TrustRequestParams {
namespace: ctx.namespace.clone(),
command_string: ctx.command_string.clone(),
source,
capability,
transport,
}
}
const PROMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
pub struct LspPromptHandler<R: LspTrustRequester> {
requester: std::sync::Arc<R>,
runtime_handle: tokio::runtime::Handle,
timeout: std::time::Duration,
}
impl<R: LspTrustRequester> LspPromptHandler<R> {
pub fn new(requester: std::sync::Arc<R>, runtime_handle: tokio::runtime::Handle) -> Self {
Self {
requester,
runtime_handle,
timeout: PROMPT_TIMEOUT,
}
}
#[cfg(test)]
pub fn with_timeout(
requester: std::sync::Arc<R>,
runtime_handle: tokio::runtime::Handle,
timeout: std::time::Duration,
) -> Self {
Self {
requester,
runtime_handle,
timeout,
}
}
}
impl<R: LspTrustRequester> TrustPromptHandler for LspPromptHandler<R> {
fn prompt(&self, ctx: &TrustPromptContext) -> TrustDecision {
let params = params_from_ctx(ctx);
let requester = std::sync::Arc::clone(&self.requester);
let timeout = self.timeout;
let response = self.runtime_handle.block_on(async move {
tokio::time::timeout(timeout, requester.send_trust_request(params)).await
});
match response {
Ok(Ok(resp)) => match resp.decision.as_str() {
"trusted" => TrustDecision::Trusted,
_ => {
TrustDecision::Denied {
reason: resp.reason.unwrap_or_else(|| {
format!(
"subprocess handler `{}` was not trusted by the editor",
ctx.namespace
)
}),
}
}
},
Ok(Err(e)) => TrustDecision::Denied {
reason: format!(
"subprocess handler `{}` denied: trust request to editor failed ({e})",
ctx.namespace
),
},
Err(_elapsed) => TrustDecision::Denied {
reason: format!(
"subprocess handler `{}` denied: trust request to editor timed out after {}s",
ctx.namespace,
timeout.as_secs()
),
},
}
}
}
#[allow(dead_code)]
pub(crate) fn transport_string(t: TrustTransport) -> &'static str {
match t {
TrustTransport::Native => "native",
TrustTransport::Subprocess => "subprocess",
TrustTransport::Wasm => "wasm",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Mutex;
struct MockRequester {
captured: Mutex<Vec<TrustRequestParams>>,
response: Mutex<JsonRpcResult<TrustResponse>>,
call_count: AtomicUsize,
}
impl MockRequester {
fn new(response: TrustResponse) -> Self {
Self {
captured: Mutex::new(Vec::new()),
response: Mutex::new(Ok(response)),
call_count: AtomicUsize::new(0),
}
}
fn with_error() -> Self {
Self {
captured: Mutex::new(Vec::new()),
response: Mutex::new(Err(tower_lsp::jsonrpc::Error::internal_error())),
call_count: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl LspTrustRequester for MockRequester {
async fn send_trust_request(
&self,
params: TrustRequestParams,
) -> JsonRpcResult<TrustResponse> {
self.captured.lock().await.push(params);
self.call_count.fetch_add(1, Ordering::SeqCst);
let r = self.response.lock().await;
match &*r {
Ok(resp) => Ok(resp.clone()),
Err(e) => Err(e.clone()),
}
}
}
fn ctx() -> TrustPromptContext {
TrustPromptContext {
namespace: "acme".into(),
command_string: "/usr/local/bin/acme-handler".into(),
source: TrustSource::LexTomlNamespace {
name: "acme".into(),
},
capability: TrustCapability::Full,
}
}
#[test]
fn params_round_trip_through_serde() {
let p = TrustRequestParams {
namespace: "acme".into(),
command_string: "acme-bin".into(),
source: TrustRequestSource::LexToml {
name: "acme".into(),
},
capability: "full".into(),
transport: "subprocess".into(),
};
let s = serde_json::to_string(&p).unwrap();
let back: TrustRequestParams = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
}
#[test]
fn params_from_ctx_local_file_source() {
let mut c = ctx();
c.source = TrustSource::LocalFile {
path: PathBuf::from("/tmp/schemas/acme"),
};
let p = params_from_ctx(&c);
match p.source {
TrustRequestSource::LocalFile { path } => {
assert!(path.contains("acme"));
}
_ => panic!("expected LocalFile"),
}
}
#[test]
fn prompt_handler_translates_trusted_response() {
let rt = tokio::runtime::Runtime::new().unwrap();
let requester = std::sync::Arc::new(MockRequester::new(TrustResponse {
decision: "trusted".into(),
reason: None,
}));
let handler = LspPromptHandler::new(std::sync::Arc::clone(&requester), rt.handle().clone());
let decision = rt.block_on(async {
tokio::task::spawn_blocking(move || handler.prompt(&ctx()))
.await
.unwrap()
});
assert!(matches!(decision, TrustDecision::Trusted));
assert_eq!(requester.call_count.load(Ordering::SeqCst), 1);
}
#[test]
fn prompt_handler_surfaces_denied_reason() {
let rt = tokio::runtime::Runtime::new().unwrap();
let requester = std::sync::Arc::new(MockRequester::new(TrustResponse {
decision: "denied".into(),
reason: Some("user-clicked-deny".into()),
}));
let handler = LspPromptHandler::new(std::sync::Arc::clone(&requester), rt.handle().clone());
let decision = rt.block_on(async {
tokio::task::spawn_blocking(move || handler.prompt(&ctx()))
.await
.unwrap()
});
match decision {
TrustDecision::Denied { reason } => {
assert!(reason.contains("user-clicked-deny"), "got: {reason}");
}
other => panic!("expected Denied, got {other:?}"),
}
}
#[test]
fn prompt_handler_treats_unknown_decision_as_denied() {
let rt = tokio::runtime::Runtime::new().unwrap();
let requester = std::sync::Arc::new(MockRequester::new(TrustResponse {
decision: "trusted_once".into(),
reason: None,
}));
let handler = LspPromptHandler::new(std::sync::Arc::clone(&requester), rt.handle().clone());
let decision = rt.block_on(async {
tokio::task::spawn_blocking(move || handler.prompt(&ctx()))
.await
.unwrap()
});
assert!(matches!(decision, TrustDecision::Denied { .. }));
}
struct StuckRequester;
#[async_trait]
impl LspTrustRequester for StuckRequester {
async fn send_trust_request(&self, _: TrustRequestParams) -> JsonRpcResult<TrustResponse> {
std::future::pending().await
}
}
#[test]
fn prompt_handler_times_out_when_editor_never_responds() {
let rt = tokio::runtime::Runtime::new().unwrap();
let requester = std::sync::Arc::new(StuckRequester);
let handler = LspPromptHandler::with_timeout(
std::sync::Arc::clone(&requester),
rt.handle().clone(),
std::time::Duration::from_millis(50),
);
let decision = rt.block_on(async {
tokio::task::spawn_blocking(move || handler.prompt(&ctx()))
.await
.unwrap()
});
match decision {
TrustDecision::Denied { reason } => {
assert!(
reason.contains("timed out"),
"expected 'timed out' diagnostic, got: {reason}"
);
}
other => panic!("expected Denied (timeout), got {other:?}"),
}
}
#[test]
fn prompt_handler_surfaces_request_error_as_denied() {
let rt = tokio::runtime::Runtime::new().unwrap();
let requester = std::sync::Arc::new(MockRequester::with_error());
let handler = LspPromptHandler::new(std::sync::Arc::clone(&requester), rt.handle().clone());
let decision = rt.block_on(async {
tokio::task::spawn_blocking(move || handler.prompt(&ctx()))
.await
.unwrap()
});
match decision {
TrustDecision::Denied { reason } => {
assert!(reason.contains("trust request"), "got: {reason}");
}
other => panic!("expected Denied, got {other:?}"),
}
}
}