Skip to main content

semifold_resolver/plugin/
runtime.rs

1use std::cell::RefCell;
2use std::collections::BTreeSet;
3use std::fmt;
4use std::rc::Rc;
5use std::sync::{Arc, Mutex};
6
7use boa_engine::module::IdleModuleLoader;
8use boa_engine::object::builtins::JsPromise;
9use boa_engine::object::{IntegrityLevel, ObjectInitializer};
10use boa_engine::{
11    Context, Finalize, JsArgs, JsData, JsError, JsResult, JsString, JsValue, Module,
12    NativeFunction, Source, Trace, js_string,
13};
14use boa_runtime::fetch::Fetcher;
15use boa_runtime::fetch::request::JsRequest;
16use boa_runtime::fetch::response::JsResponse;
17use semifold_core::EcosystemId;
18
19use super::file::{
20    DenyPluginFileClient, MAX_FILE_BYTES, MAX_OPERATION_FILE_BYTES, MAX_OPERATION_PATHS,
21    PluginFileClient, PluginFileError, matches_pattern, validate_file_path, validate_pattern_path,
22};
23#[cfg(test)]
24use super::http::PluginHttpFuture;
25use super::http::{
26    BudgetedPluginHttpClient, BudgetedPluginHttpTransport, DenyPluginHttpClient, PluginHttpClient,
27    PluginHttpLimits, PluginHttpOrigin, PluginHttpRequest, PluginHttpResponse, PluginHttpTransport,
28    ScopedPluginHttpClient,
29};
30use super::protocol::{PluginMetadataV1, PluginProtocolError, PluginRequestV1, PluginResponseV1};
31
32pub(crate) const MAX_SOURCE_BYTES: usize = 1024 * 1024;
33const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
34const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
35const MAX_LOOP_ITERATIONS: u64 = 10_000_000;
36const MAX_RECURSION_DEPTH: usize = 256;
37const MAX_VM_STACK_VALUES: usize = 10_240;
38
39#[derive(Clone, Trace, Finalize, JsData)]
40struct BoaFetcher {
41    #[unsafe_ignore_trace]
42    client: Arc<dyn PluginHttpClient>,
43}
44
45#[derive(Clone)]
46enum BoaHttpBackend {
47    Client(Arc<dyn PluginHttpClient>),
48    Transport {
49        allowed_origins: BTreeSet<PluginHttpOrigin>,
50        transport: Arc<dyn PluginHttpTransport>,
51    },
52}
53
54impl fmt::Debug for BoaHttpBackend {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Client(_) => formatter.write_str("BoaHttpBackend::Client(..)"),
58            Self::Transport {
59                allowed_origins, ..
60            } => formatter
61                .debug_struct("BoaHttpBackend::Transport")
62                .field("allowed_origins", allowed_origins)
63                .finish_non_exhaustive(),
64        }
65    }
66}
67
68impl fmt::Debug for BoaFetcher {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.debug_struct("BoaFetcher").finish_non_exhaustive()
71    }
72}
73
74impl Fetcher for BoaFetcher {
75    async fn fetch(
76        self: Rc<Self>,
77        request: JsRequest,
78        _context: &std::cell::RefCell<&mut Context>,
79    ) -> JsResult<JsResponse> {
80        let request = request.into_inner();
81        let plugin_request = PluginHttpRequest {
82            method: request.method().to_string(),
83            url: request.uri().to_string(),
84            headers: request
85                .headers()
86                .iter()
87                .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_vec()))
88                .collect(),
89            body: request.body().clone(),
90        };
91        let response = self
92            .client
93            .send(plugin_request)
94            .await
95            .map_err(JsError::from_rust)?;
96        let PluginHttpResponse {
97            url,
98            status,
99            headers,
100            body,
101        } = response;
102        let mut builder = http::Response::builder().status(status);
103        for (name, value) in headers {
104            builder = builder.header(name, value);
105        }
106        let response = builder.body(body).map_err(JsError::from_rust)?;
107        Ok(JsResponse::basic(JsString::from(url), response))
108    }
109}
110
111#[derive(Clone, Debug, Default)]
112struct PluginFileBudget {
113    returned_paths: usize,
114    returned_bytes: usize,
115}
116
117#[derive(Clone, Trace, Finalize, JsData)]
118struct BoaFileHost {
119    #[unsafe_ignore_trace]
120    client: Arc<dyn PluginFileClient>,
121    #[unsafe_ignore_trace]
122    budget: Arc<Mutex<PluginFileBudget>>,
123    max_file_bytes: usize,
124    max_operation_file_bytes: usize,
125    max_operation_paths: usize,
126}
127
128impl fmt::Debug for BoaFileHost {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter
131            .debug_struct("BoaFileHost")
132            .finish_non_exhaustive()
133    }
134}
135
136impl BoaFileHost {
137    fn charge_paths(&self, count: usize) -> Result<(), PluginFileError> {
138        let mut budget = self
139            .budget
140            .lock()
141            .map_err(|_| PluginFileError::BudgetStateUnavailable)?;
142        let actual = budget.returned_paths.saturating_add(count);
143        if actual > self.max_operation_paths {
144            return Err(PluginFileError::TooManyPaths {
145                actual,
146                maximum: self.max_operation_paths,
147            });
148        }
149        budget.returned_paths = actual;
150        Ok(())
151    }
152
153    fn charge_bytes(&self, path: &str, count: usize) -> Result<(), PluginFileError> {
154        if count > self.max_file_bytes {
155            return Err(PluginFileError::FileTooLarge {
156                path: path.to_owned(),
157                actual: count,
158                maximum: self.max_file_bytes,
159            });
160        }
161        let mut budget = self
162            .budget
163            .lock()
164            .map_err(|_| PluginFileError::BudgetStateUnavailable)?;
165        let actual = budget.returned_bytes.saturating_add(count);
166        if actual > self.max_operation_file_bytes {
167            return Err(PluginFileError::OperationBytesExceeded {
168                actual,
169                maximum: self.max_operation_file_bytes,
170            });
171        }
172        budget.returned_bytes = actual;
173        Ok(())
174    }
175}
176
177async fn boa_list_files(
178    _this: &JsValue,
179    arguments: &[JsValue],
180    context: &RefCell<&mut Context>,
181) -> JsResult<JsValue> {
182    let (pattern, host) = {
183        let mut context = context.borrow_mut();
184        let pattern = string_argument(arguments, 0, "listFiles", &mut context)?;
185        validate_pattern_path(&pattern).map_err(JsError::from_rust)?;
186        let host = context
187            .get_data::<BoaFileHost>()
188            .cloned()
189            .ok_or(PluginFileError::HostUnavailable)
190            .map_err(JsError::from_rust)?;
191        (pattern, host)
192    };
193
194    let mut paths = host
195        .client
196        .list_files(&pattern)
197        .await
198        .map_err(JsError::from_rust)?;
199    for path in &paths {
200        validate_file_path(path).map_err(JsError::from_rust)?;
201        if !matches_pattern(&pattern, path).map_err(JsError::from_rust)? {
202            return Err(JsError::from_rust(
203                PluginFileError::ReturnedPathDoesNotMatch {
204                    path: path.clone(),
205                    pattern,
206                },
207            ));
208        }
209    }
210    paths.sort();
211    paths.dedup();
212    host.charge_paths(paths.len()).map_err(JsError::from_rust)?;
213    let value =
214        serde_json::Value::Array(paths.into_iter().map(serde_json::Value::String).collect());
215    JsValue::from_json(&value, &mut context.borrow_mut())
216}
217
218async fn boa_read_text(
219    _this: &JsValue,
220    arguments: &[JsValue],
221    context: &RefCell<&mut Context>,
222) -> JsResult<JsValue> {
223    let (path, host) = {
224        let mut context = context.borrow_mut();
225        let path = string_argument(arguments, 0, "readText", &mut context)?;
226        validate_file_path(&path).map_err(JsError::from_rust)?;
227        let host = context
228            .get_data::<BoaFileHost>()
229            .cloned()
230            .ok_or(PluginFileError::HostUnavailable)
231            .map_err(JsError::from_rust)?;
232        (path, host)
233    };
234
235    let content = host
236        .client
237        .read_text(&path)
238        .await
239        .map_err(JsError::from_rust)?;
240    host.charge_bytes(&path, content.len())
241        .map_err(JsError::from_rust)?;
242    Ok(JsString::from(content).into())
243}
244
245fn string_argument(
246    arguments: &[JsValue],
247    index: usize,
248    method: &'static str,
249    context: &mut Context,
250) -> JsResult<String> {
251    let value = arguments.get_or_undefined(index);
252    if !value.is_string() {
253        return Err(JsError::from_rust(PluginFileError::InvalidArgument {
254            method,
255        }));
256    }
257    value
258        .to_string(context)?
259        .to_std_string()
260        .map_err(JsError::from_rust)
261}
262
263#[derive(Clone, Copy, Debug)]
264struct BoaLimits {
265    max_source_bytes: usize,
266    max_request_bytes: usize,
267    max_response_bytes: usize,
268    max_loop_iterations: u64,
269    max_recursion_depth: usize,
270    max_vm_stack_values: usize,
271    max_file_bytes: usize,
272    max_operation_file_bytes: usize,
273    max_operation_paths: usize,
274    http: PluginHttpLimits,
275}
276
277impl Default for BoaLimits {
278    fn default() -> Self {
279        Self {
280            max_source_bytes: MAX_SOURCE_BYTES,
281            max_request_bytes: MAX_REQUEST_BYTES,
282            max_response_bytes: MAX_RESPONSE_BYTES,
283            max_loop_iterations: MAX_LOOP_ITERATIONS,
284            max_recursion_depth: MAX_RECURSION_DEPTH,
285            max_vm_stack_values: MAX_VM_STACK_VALUES,
286            max_file_bytes: MAX_FILE_BYTES,
287            max_operation_file_bytes: MAX_OPERATION_FILE_BYTES,
288            max_operation_paths: MAX_OPERATION_PATHS,
289            http: PluginHttpLimits::default(),
290        }
291    }
292}
293
294/// Embedded Boa host for the schema-versioned Semifold plugin protocol.
295#[derive(Clone)]
296pub struct BoaPluginRuntime {
297    http_backend: BoaHttpBackend,
298    file_client: Arc<dyn PluginFileClient>,
299    limits: BoaLimits,
300}
301
302impl fmt::Debug for BoaPluginRuntime {
303    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304        formatter
305            .debug_struct("BoaPluginRuntime")
306            .finish_non_exhaustive()
307    }
308}
309
310impl Default for BoaPluginRuntime {
311    fn default() -> Self {
312        Self::new(DenyPluginHttpClient)
313    }
314}
315
316impl BoaPluginRuntime {
317    #[must_use]
318    pub fn new(http_client: impl PluginHttpClient) -> Self {
319        Self {
320            http_backend: BoaHttpBackend::Client(Arc::new(http_client)),
321            file_client: Arc::new(DenyPluginFileClient),
322            limits: BoaLimits::default(),
323        }
324    }
325
326    #[must_use]
327    pub fn with_file_client(mut self, file_client: impl PluginFileClient) -> Self {
328        self.file_client = Arc::new(file_client);
329        self
330    }
331
332    #[must_use]
333    pub fn with_http_transport(
334        mut self,
335        allowed_origins: impl IntoIterator<Item = PluginHttpOrigin>,
336        transport: impl PluginHttpTransport,
337    ) -> Self {
338        self.http_backend = BoaHttpBackend::Transport {
339            allowed_origins: allowed_origins.into_iter().collect(),
340            transport: Arc::new(transport),
341        };
342        self
343    }
344
345    pub(crate) fn with_shared_http_transport(
346        mut self,
347        allowed_origins: BTreeSet<PluginHttpOrigin>,
348        transport: Arc<dyn PluginHttpTransport>,
349    ) -> Self {
350        self.http_backend = BoaHttpBackend::Transport {
351            allowed_origins,
352            transport,
353        };
354        self
355    }
356
357    pub(crate) fn with_shared_http_client(mut self, client: Arc<dyn PluginHttpClient>) -> Self {
358        self.http_backend = BoaHttpBackend::Client(client);
359        self
360    }
361
362    /// Loads and validates the named `metadata` export in a fresh Boa context.
363    pub fn metadata(&self, source: &str) -> Result<PluginMetadataV1, PluginRuntimeError> {
364        let (mut context, module) = self.load_module(source, Arc::new(DenyPluginHttpClient))?;
365        let value = module
366            .get_value(js_string!("metadata"), &mut context)
367            .map_err(|error| PluginRuntimeError::MissingMetadata(error.to_string()))?;
368        let value = value
369            .to_json(&mut context)
370            .map_err(|error| PluginRuntimeError::InvalidMetadata(error.to_string()))?
371            .ok_or(PluginRuntimeError::MetadataNotJson)?;
372        let metadata = serde_json::from_value::<PluginMetadataV1>(value)
373            .map_err(PluginRuntimeError::MetadataDeserialization)?;
374        metadata.validate()?;
375        for pattern in &metadata.read_patterns {
376            validate_pattern_path(pattern)?;
377        }
378        Ok(metadata)
379    }
380
381    /// Executes the module's default export with a validated protocol request.
382    pub fn execute(
383        &self,
384        source: &str,
385        request: &PluginRequestV1,
386        plugin: &EcosystemId,
387    ) -> Result<PluginResponseV1, PluginRuntimeError> {
388        request.validate()?;
389        let request_json =
390            serde_json::to_value(request).map_err(PluginRuntimeError::RequestSerialization)?;
391        let request_bytes =
392            serde_json::to_vec(&request_json).map_err(PluginRuntimeError::RequestSerialization)?;
393        if request_bytes.len() > self.limits.max_request_bytes {
394            return Err(PluginRuntimeError::RequestTooLarge {
395                actual: request_bytes.len(),
396                maximum: self.limits.max_request_bytes,
397            });
398        }
399
400        let (mut context, module) = self.load_module(source, self.operation_http_client())?;
401        let entrypoint = module
402            .get_value(js_string!("default"), &mut context)
403            .map_err(|error| PluginRuntimeError::MissingEntrypoint(error.to_string()))?;
404        let Some(entrypoint) = entrypoint.as_callable() else {
405            return Err(PluginRuntimeError::EntrypointNotCallable);
406        };
407        let request_value = JsValue::from_json(&request_json, &mut context)
408            .map_err(|error| PluginRuntimeError::RequestConversion(error.to_string()))?;
409        let host_value = self.file_host(&mut context)?;
410        let result = entrypoint
411            .call(
412                &JsValue::undefined(),
413                &[request_value, host_value],
414                &mut context,
415            )
416            .map_err(|error| PluginRuntimeError::EntrypointInvocation(error.to_string()))?;
417        let result = JsPromise::resolve(result, &mut context)
418            .await_blocking(&mut context)
419            .map_err(|error| PluginRuntimeError::EntrypointInvocation(error.to_string()))?;
420        let result = result
421            .to_json(&mut context)
422            .map_err(|error| PluginRuntimeError::ResponseConversion(error.to_string()))?
423            .ok_or(PluginRuntimeError::ResponseNotJson)?;
424        let response_bytes =
425            serde_json::to_vec(&result).map_err(PluginRuntimeError::ResponseSerialization)?;
426        if response_bytes.len() > self.limits.max_response_bytes {
427            return Err(PluginRuntimeError::ResponseTooLarge {
428                actual: response_bytes.len(),
429                maximum: self.limits.max_response_bytes,
430            });
431        }
432        let response = serde_json::from_value::<PluginResponseV1>(result)
433            .map_err(PluginRuntimeError::ResponseDeserialization)?;
434        response.validate_for(request, plugin)?;
435        Ok(response)
436    }
437
438    fn file_host(&self, context: &mut Context) -> Result<JsValue, PluginRuntimeError> {
439        context.insert_data(BoaFileHost {
440            client: self.file_client.clone(),
441            budget: Arc::new(Mutex::new(PluginFileBudget::default())),
442            max_file_bytes: self.limits.max_file_bytes,
443            max_operation_file_bytes: self.limits.max_operation_file_bytes,
444            max_operation_paths: self.limits.max_operation_paths,
445        });
446        let host = ObjectInitializer::new(context)
447            .function(
448                NativeFunction::from_async_fn(boa_list_files),
449                js_string!("listFiles"),
450                1,
451            )
452            .function(
453                NativeFunction::from_async_fn(boa_read_text),
454                js_string!("readText"),
455                1,
456            )
457            .build();
458        let frozen = host
459            .set_integrity_level(IntegrityLevel::Frozen, context)
460            .map_err(|error| PluginRuntimeError::RuntimeInitialization(error.to_string()))?;
461        if !frozen {
462            return Err(PluginRuntimeError::HostInitialization);
463        }
464        Ok(host.into())
465    }
466
467    fn operation_http_client(&self) -> Arc<dyn PluginHttpClient> {
468        match &self.http_backend {
469            BoaHttpBackend::Client(client) => Arc::new(BudgetedPluginHttpClient::new(
470                client.clone(),
471                self.limits.http,
472            )),
473            BoaHttpBackend::Transport {
474                allowed_origins,
475                transport,
476            } => {
477                let transport: Arc<dyn PluginHttpTransport> = Arc::new(
478                    BudgetedPluginHttpTransport::new(transport.clone(), self.limits.http),
479                );
480                Arc::new(ScopedPluginHttpClient::from_shared(
481                    allowed_origins.clone(),
482                    transport,
483                ))
484            }
485        }
486    }
487
488    fn load_module(
489        &self,
490        source: &str,
491        http_client: Arc<dyn PluginHttpClient>,
492    ) -> Result<(Context, Module), PluginRuntimeError> {
493        if source.len() > self.limits.max_source_bytes {
494            return Err(PluginRuntimeError::SourceTooLarge {
495                actual: source.len(),
496                maximum: self.limits.max_source_bytes,
497            });
498        }
499        let mut context = Context::builder()
500            .module_loader(Rc::new(IdleModuleLoader))
501            .build()
502            .map_err(|error| PluginRuntimeError::RuntimeInitialization(error.to_string()))?;
503        {
504            let limits = context.runtime_limits_mut();
505            limits.set_loop_iteration_limit(self.limits.max_loop_iterations);
506            limits.set_recursion_limit(self.limits.max_recursion_depth);
507            limits.set_stack_size_limit(self.limits.max_vm_stack_values);
508        }
509        boa_runtime::fetch::register(
510            BoaFetcher {
511                client: http_client,
512            },
513            None,
514            &mut context,
515        )
516        .map_err(|error| PluginRuntimeError::RuntimeInitialization(error.to_string()))?;
517        boa_runtime::url::Url::register(None, &mut context)
518            .map_err(|error| PluginRuntimeError::RuntimeInitialization(error.to_string()))?;
519
520        let module = Module::parse(Source::from_bytes(source.as_bytes()), None, &mut context)
521            .map_err(|error| PluginRuntimeError::ModuleParsing(error.to_string()))?;
522        module
523            .load_link_evaluate(&mut context)
524            .await_blocking(&mut context)
525            .map_err(|error| PluginRuntimeError::ModuleEvaluation(error.to_string()))?;
526        Ok((context, module))
527    }
528
529    #[cfg(test)]
530    fn with_loop_limit(mut self, maximum: u64) -> Self {
531        self.limits.max_loop_iterations = maximum;
532        self
533    }
534
535    #[cfg(test)]
536    fn with_file_limits(
537        mut self,
538        max_file_bytes: usize,
539        max_operation_file_bytes: usize,
540        max_operation_paths: usize,
541    ) -> Self {
542        self.limits.max_file_bytes = max_file_bytes;
543        self.limits.max_operation_file_bytes = max_operation_file_bytes;
544        self.limits.max_operation_paths = max_operation_paths;
545        self
546    }
547
548    #[cfg(test)]
549    fn with_http_limits(mut self, limits: PluginHttpLimits) -> Self {
550        self.limits.http = limits;
551        self
552    }
553}
554
555#[derive(Debug, thiserror::Error)]
556pub enum PluginRuntimeError {
557    #[error("plugin source contains {actual} bytes; maximum is {maximum}")]
558    SourceTooLarge { actual: usize, maximum: usize },
559    #[error("plugin request contains {actual} bytes; maximum is {maximum}")]
560    RequestTooLarge { actual: usize, maximum: usize },
561    #[error("plugin response contains {actual} bytes; maximum is {maximum}")]
562    ResponseTooLarge { actual: usize, maximum: usize },
563    #[error("failed to initialize Boa plugin runtime: {0}")]
564    RuntimeInitialization(String),
565    #[error("failed to freeze the Boa plugin capability host")]
566    HostInitialization,
567    #[error("failed to parse plugin module: {0}")]
568    ModuleParsing(String),
569    #[error("failed to evaluate plugin module: {0}")]
570    ModuleEvaluation(String),
571    #[error("plugin module does not export metadata: {0}")]
572    MissingMetadata(String),
573    #[error("plugin metadata is not JSON-compatible: {0}")]
574    InvalidMetadata(String),
575    #[error("plugin metadata resolved to undefined")]
576    MetadataNotJson,
577    #[error("failed to deserialize plugin metadata: {0}")]
578    MetadataDeserialization(#[source] serde_json::Error),
579    #[error("plugin module does not have a default export: {0}")]
580    MissingEntrypoint(String),
581    #[error("plugin module default export must be a function")]
582    EntrypointNotCallable,
583    #[error("failed to serialize plugin request: {0}")]
584    RequestSerialization(#[source] serde_json::Error),
585    #[error("failed to convert plugin request to JavaScript: {0}")]
586    RequestConversion(String),
587    #[error("plugin entrypoint failed: {0}")]
588    EntrypointInvocation(String),
589    #[error("plugin response is not JSON-compatible: {0}")]
590    ResponseConversion(String),
591    #[error("plugin response resolved to undefined")]
592    ResponseNotJson,
593    #[error("failed to serialize plugin response: {0}")]
594    ResponseSerialization(#[source] serde_json::Error),
595    #[error("failed to deserialize plugin response: {0}")]
596    ResponseDeserialization(#[source] serde_json::Error),
597    #[error(transparent)]
598    Protocol(#[from] PluginProtocolError),
599    #[error(transparent)]
600    FileCapability(#[from] PluginFileError),
601}
602
603#[cfg(test)]
604mod tests {
605    use std::sync::Mutex;
606
607    use serde_json::json;
608
609    use super::*;
610    use crate::plugin::protocol::{PluginCallV1, PluginDiscoverInputV1};
611
612    const PLUGIN_ID: &str = "com.example.engine";
613
614    fn plugin_id() -> EcosystemId {
615        EcosystemId::new(PLUGIN_ID).unwrap()
616    }
617
618    fn request() -> PluginRequestV1 {
619        PluginRequestV1::new(PluginCallV1::Discover(PluginDiscoverInputV1 {
620            project_root: ".".to_owned(),
621        }))
622    }
623
624    fn module_source(body: &str) -> String {
625        format!(
626            r#"
627            export const metadata = {{
628                "schema-version": 1,
629                ecosystem: "{PLUGIN_ID}",
630                "plugin-version": "1.0.0",
631                operations: ["discover", "inspect", "plan-edits"]
632            }};
633            export default {body};
634            "#
635        )
636    }
637
638    #[test]
639    fn loads_metadata_and_executes_async_default_export() {
640        let source = module_source(
641            r#"async function(request) {
642                await Promise.resolve();
643                return {
644                    "schema-version": request["schema-version"],
645                    diagnostics: [],
646                    status: "success",
647                    output: {
648                        operation: request.operation,
649                        output: { packages: [] }
650                    }
651                };
652            }"#,
653        );
654        let runtime = BoaPluginRuntime::default();
655
656        let metadata = runtime.metadata(&source).unwrap();
657        assert_eq!(metadata.ecosystem, plugin_id());
658        let response = runtime.execute(&source, &request(), &plugin_id()).unwrap();
659        assert_eq!(response.diagnostics, Vec::new());
660    }
661
662    #[test]
663    fn rejects_imports_in_single_file_modules() {
664        let source = format!(
665            "import value from './other.js'; {}",
666            module_source("function() { return value; }")
667        );
668        assert!(matches!(
669            BoaPluginRuntime::default().metadata(&source),
670            Err(PluginRuntimeError::ModuleEvaluation(message))
671                if message.contains("module resolution is disabled")
672        ));
673    }
674
675    #[test]
676    fn stops_plugins_that_exceed_the_loop_budget() {
677        let source =
678            module_source("function() { for (let i = 0; i < 20; i += 1) {} return undefined; }");
679        let runtime = BoaPluginRuntime::default().with_loop_limit(10);
680        let error = runtime
681            .execute(&source, &request(), &plugin_id())
682            .unwrap_err();
683        assert!(
684            matches!(
685                &error,
686                PluginRuntimeError::EntrypointInvocation(message)
687                    if message.to_ascii_lowercase().contains("loop iteration limit")
688            ),
689            "{error:?}"
690        );
691    }
692
693    #[derive(Clone, Debug)]
694    struct RecordingHttpClient {
695        requests: Arc<Mutex<Vec<PluginHttpRequest>>>,
696    }
697
698    impl PluginHttpClient for RecordingHttpClient {
699        fn send(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
700            Box::pin(async move {
701                self.requests.lock().unwrap().push(request.clone());
702                let response = json!({
703                    "schema-version": 1,
704                    "diagnostics": [],
705                    "status": "success",
706                    "output": {
707                        "operation": "discover",
708                        "output": { "packages": [] }
709                    }
710                });
711                Ok(PluginHttpResponse {
712                    url: request.url,
713                    status: 200,
714                    headers: vec![("content-type".to_owned(), b"application/json".to_vec())],
715                    body: serde_json::to_vec(&response).unwrap(),
716                })
717            })
718        }
719    }
720
721    #[derive(Clone, Debug)]
722    struct RecordingHttpTransport {
723        requests: Arc<Mutex<Vec<PluginHttpRequest>>>,
724    }
725
726    impl PluginHttpTransport for RecordingHttpTransport {
727        fn send_once(&self, request: PluginHttpRequest) -> PluginHttpFuture<'_> {
728            Box::pin(async move {
729                self.requests.lock().unwrap().push(request.clone());
730                let response = json!({
731                    "schema-version": 1,
732                    "diagnostics": [],
733                    "status": "success",
734                    "output": {
735                        "operation": "discover",
736                        "output": { "packages": [] }
737                    }
738                });
739                Ok(PluginHttpResponse {
740                    url: request.url,
741                    status: 200,
742                    headers: vec![("content-type".to_owned(), b"application/json".to_vec())],
743                    body: serde_json::to_vec(&response).unwrap(),
744                })
745            })
746        }
747    }
748
749    #[derive(Clone, Debug)]
750    struct RecordingFileClient {
751        paths: Vec<String>,
752        content: String,
753        calls: Arc<Mutex<Vec<String>>>,
754    }
755
756    impl PluginFileClient for RecordingFileClient {
757        fn list_files(
758            &self,
759            pattern: &str,
760        ) -> super::super::file::PluginFileFuture<'_, Vec<String>> {
761            let pattern = pattern.to_owned();
762            Box::pin(async move {
763                self.calls.lock().unwrap().push(format!("list:{pattern}"));
764                Ok(self.paths.clone())
765            })
766        }
767
768        fn read_text(&self, path: &str) -> super::super::file::PluginFileFuture<'_, String> {
769            let path = path.to_owned();
770            Box::pin(async move {
771                self.calls.lock().unwrap().push(format!("read:{path}"));
772                Ok(self.content.clone())
773            })
774        }
775    }
776
777    #[test]
778    fn exposes_a_frozen_async_file_host_with_deterministic_listings() {
779        let calls = Arc::new(Mutex::new(Vec::new()));
780        let runtime = BoaPluginRuntime::default().with_file_client(RecordingFileClient {
781            paths: vec![
782                "packages/zeta/package.json".to_owned(),
783                "packages/alpha/package.json".to_owned(),
784                "packages/alpha/package.json".to_owned(),
785            ],
786            content: "alpha".to_owned(),
787            calls: calls.clone(),
788        });
789        let source = module_source(
790            r#"async function(request, host) {
791                if (!Object.isFrozen(host)) {
792                    throw new Error("host must be frozen");
793                }
794                const files = await host.listFiles("packages/**/package.json");
795                if (files.join(",") !== "packages/alpha/package.json,packages/zeta/package.json") {
796                    throw new Error(`unexpected files: ${files.join(",")}`);
797                }
798                const content = await host.readText(files[0]);
799                if (content !== "alpha") {
800                    throw new Error(`unexpected content: ${content}`);
801                }
802                return {
803                    "schema-version": request["schema-version"],
804                    diagnostics: [],
805                    status: "success",
806                    output: {
807                        operation: request.operation,
808                        output: { packages: [] }
809                    }
810                };
811            }"#,
812        );
813
814        runtime.execute(&source, &request(), &plugin_id()).unwrap();
815        assert_eq!(
816            *calls.lock().unwrap(),
817            vec![
818                "list:packages/**/package.json".to_owned(),
819                "read:packages/alpha/package.json".to_owned()
820            ]
821        );
822    }
823
824    #[test]
825    fn file_access_is_denied_without_an_injected_backend() {
826        let source = module_source(
827            r#"async function(_request, host) {
828                await host.listFiles("packages/**/package.json");
829            }"#,
830        );
831
832        assert!(matches!(
833            BoaPluginRuntime::default().execute(&source, &request(), &plugin_id()),
834            Err(PluginRuntimeError::EntrypointInvocation(message))
835                if message.contains("file access is not configured")
836        ));
837    }
838
839    #[test]
840    fn validates_declared_read_patterns_while_loading_metadata() {
841        let source = module_source("function() {}").replace(
842            "operations: [\"discover\", \"inspect\", \"plan-edits\"]",
843            "operations: [\"discover\", \"inspect\", \"plan-edits\"],\n                \"read-patterns\": [\"../secret.txt\"]",
844        );
845
846        assert!(matches!(
847            BoaPluginRuntime::default().metadata(&source),
848            Err(PluginRuntimeError::FileCapability(
849                PluginFileError::InvalidPattern { .. }
850            ))
851        ));
852    }
853
854    #[test]
855    fn enforces_cumulative_file_capability_budgets_per_operation() {
856        let calls = Arc::new(Mutex::new(Vec::new()));
857        let runtime = BoaPluginRuntime::default()
858            .with_file_client(RecordingFileClient {
859                paths: vec!["alpha.json".to_owned(), "zeta.json".to_owned()],
860                content: "123456".to_owned(),
861                calls,
862            })
863            .with_file_limits(8, 10, 3);
864        let path_source = module_source(
865            r#"async function(_request, host) {
866                await host.listFiles("*.json");
867                await host.listFiles("*.json");
868            }"#,
869        );
870        let byte_source = module_source(
871            r#"async function(_request, host) {
872                await host.readText("alpha.json");
873                await host.readText("zeta.json");
874            }"#,
875        );
876
877        assert!(matches!(
878            runtime.execute(&path_source, &request(), &plugin_id()),
879            Err(PluginRuntimeError::EntrypointInvocation(message))
880                if message.contains("returned 4 paths") && message.contains("maximum is 3")
881        ));
882        assert!(matches!(
883            runtime.execute(&byte_source, &request(), &plugin_id()),
884            Err(PluginRuntimeError::EntrypointInvocation(message))
885                if message.contains("returned 12 bytes") && message.contains("maximum is 10")
886        ));
887    }
888
889    #[test]
890    fn metadata_loading_never_uses_the_configured_network_backend() {
891        let requests = Arc::new(Mutex::new(Vec::new()));
892        let runtime = BoaPluginRuntime::new(RecordingHttpClient {
893            requests: requests.clone(),
894        });
895        let source = r#"
896            const response = await fetch("https://api.example.test/metadata");
897            export const metadata = await response.json();
898            export default function() {};
899        "#;
900
901        assert!(matches!(
902            runtime.metadata(source),
903            Err(PluginRuntimeError::ModuleEvaluation(message))
904                if message.contains("network access is not configured")
905        ));
906        assert!(requests.lock().unwrap().is_empty());
907    }
908
909    #[test]
910    fn routes_fetch_through_the_injected_host_backend() {
911        let requests = Arc::new(Mutex::new(Vec::new()));
912        let runtime = BoaPluginRuntime::new(RecordingHttpClient {
913            requests: requests.clone(),
914        });
915        let source = module_source(
916            r#"async function() {
917                const response = await fetch("https://api.example.test/plugin");
918                return await response.json();
919            }"#,
920        );
921
922        runtime.execute(&source, &request(), &plugin_id()).unwrap();
923        let requests = requests.lock().unwrap();
924        assert_eq!(requests.len(), 1);
925        assert_eq!(requests[0].url, "https://api.example.test/plugin");
926    }
927
928    #[test]
929    fn routes_fetch_through_scoped_origins_and_denies_unlisted_targets() {
930        let requests = Arc::new(Mutex::new(Vec::new()));
931        let runtime = BoaPluginRuntime::default().with_http_transport(
932            [PluginHttpOrigin::parse("https://api.example.test").unwrap()],
933            RecordingHttpTransport {
934                requests: requests.clone(),
935            },
936        );
937        let allowed_source = module_source(
938            r#"async function() {
939                const response = await fetch("https://api.example.test/plugin");
940                return await response.json();
941            }"#,
942        );
943        let blocked_source = module_source(
944            r#"async function() {
945                await fetch("https://blocked.example.test/plugin");
946            }"#,
947        );
948
949        runtime
950            .execute(&allowed_source, &request(), &plugin_id())
951            .unwrap();
952        assert!(matches!(
953            runtime.execute(&blocked_source, &request(), &plugin_id()),
954            Err(PluginRuntimeError::EntrypointInvocation(message))
955                if message.contains("origin is not allowed")
956        ));
957        let requests = requests.lock().unwrap();
958        assert_eq!(requests.len(), 1);
959        assert_eq!(requests[0].url, "https://api.example.test/plugin");
960    }
961
962    #[test]
963    fn applies_fresh_http_request_budgets_to_each_operation() {
964        let requests = Arc::new(Mutex::new(Vec::new()));
965        let limits = PluginHttpLimits {
966            max_operation_requests: 1,
967            ..PluginHttpLimits::default()
968        };
969        let runtime = BoaPluginRuntime::new(RecordingHttpClient {
970            requests: requests.clone(),
971        })
972        .with_http_limits(limits);
973        let one_fetch = module_source(
974            r#"async function() {
975                const response = await fetch("https://api.example.test/one");
976                return await response.json();
977            }"#,
978        );
979        let two_fetches = module_source(
980            r#"async function() {
981                await fetch("https://api.example.test/one");
982                await fetch("https://api.example.test/two");
983            }"#,
984        );
985
986        runtime
987            .execute(&one_fetch, &request(), &plugin_id())
988            .unwrap();
989        runtime
990            .execute(&one_fetch, &request(), &plugin_id())
991            .unwrap();
992        assert!(matches!(
993            runtime.execute(&two_fetches, &request(), &plugin_id()),
994            Err(PluginRuntimeError::EntrypointInvocation(message))
995                if message.contains("attempted 2 requests") && message.contains("maximum is 1")
996        ));
997    }
998
999    #[test]
1000    fn network_is_denied_without_an_injected_backend() {
1001        let source = module_source(
1002            r#"async function() {
1003                await fetch("https://api.example.test/plugin");
1004            }"#,
1005        );
1006        assert!(matches!(
1007            BoaPluginRuntime::default().execute(&source, &request(), &plugin_id()),
1008            Err(PluginRuntimeError::EntrypointInvocation(message))
1009                if message.contains("network access is not configured")
1010        ));
1011    }
1012
1013    #[test]
1014    fn runtime_handle_is_send_and_sync() {
1015        fn assert_send_and_sync<T: Send + Sync>() {}
1016        assert_send_and_sync::<BoaPluginRuntime>();
1017    }
1018}