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#[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 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 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}