From d77d8ca9a3bb9ef93b7f83980cb42564da42fd1f Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 2 Sep 2026 03:08:05 -0700 Subject: [PATCH] feat(execution): support JavaScript data URL modules --- crates/execution/src/javascript.rs | 72 ++++++++++++++++++- crates/execution/tests/javascript_v8.rs | 16 +++++ crates/execution/tests/module_resolution.rs | 39 ++++++++++ .../tests/language_execution.rs | 35 ++++++++- 4 files changed, 159 insertions(+), 3 deletions(-) diff --git a/crates/execution/src/javascript.rs b/crates/execution/src/javascript.rs index 1b169046e5..eca5ebc1d0 100644 --- a/crates/execution/src/javascript.rs +++ b/crates/execution/src/javascript.rs @@ -13,6 +13,8 @@ use crate::v8_runtime; use agentos_bridge::queue_tracker::{register_queue, TrackedLimit}; use agentos_runtime::RuntimeContext; use agentos_v8_runtime::runtime_protocol::{RuntimeCommand, WarmSessionHint}; +use base64::engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD}; +use base64::Engine as _; use flume::{Receiver as EventReceiver, Sender as EventSender}; use getrandom::getrandom; use serde::Deserialize; @@ -1510,6 +1512,10 @@ impl ModuleResolutionTestHarness { .module_format(path) .map(LocalResolvedModuleFormat::as_str) } + + pub fn load_module(&mut self, path: &str) -> Option { + self.local_bridge.load_file(path) + } } #[doc(hidden)] @@ -4727,7 +4733,8 @@ impl LocalBridgeState { || specifier == "." || specifier == ".." || specifier.starts_with('/') - || specifier.starts_with("file:"); + || specifier.starts_with("file:") + || specifier.starts_with("data:"); match self.module_resolution { GuestModuleResolution::Node => false, // Relative permits local files only; bare specifiers and package @@ -4862,6 +4869,17 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> { from_dir: &str, mode: ModuleResolveMode, ) -> Option { + // A data URL is already an absolute module identity. Keep its fragment + // intact (callers use it as a cache-busting identity), and do not retain + // it in the per-VM path cache: a stream of unique inline modules must not + // grow that cache without bound. + if specifier.starts_with("data:") { + return (mode == ModuleResolveMode::Import + && data_javascript_module_parts(specifier).is_some()) + .then(|| specifier.to_owned()); + } + + let data_referrer = from_dir.starts_with("data:"); let normalized_from_path = self .reader .canonical_guest_path(from_dir) @@ -4883,6 +4901,10 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> { .and_then(|file_path| self.resolve_path(&file_path, mode)) } else if specifier.starts_with('/') { self.resolve_path(specifier, mode) + } else if data_referrer { + // Like Node, data: modules have no hierarchical base for relative, + // package-import, self-reference, or bare-package resolution. + None } else if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." @@ -4905,6 +4927,10 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> { } pub fn load_file(&mut self, path: &str) -> Option { + if path.starts_with("data:") { + return data_javascript_module_source(path); + } + let bare = path.trim_start_matches("node:"); if is_builtin_specifier(path) { return Some(build_builtin_module_wrapper(bare)); @@ -4924,6 +4950,12 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> { } pub fn module_format(&mut self, path: &str) -> Option { + // Do not cache caller-generated data URLs; their fragments are commonly + // unique per execution and the MIME type is cheap to inspect. + if path.starts_with("data:") { + return data_javascript_module_parts(path).map(|_| LocalResolvedModuleFormat::Module); + } + if let Some(cached) = self.cache.module_format_results.get(path) { return *cached; } @@ -5236,7 +5268,43 @@ fn guest_path_from_file_url(specifier: &str) -> Option { Some(normalize_guest_path(&percent_decode(pathname)?)) } +fn data_javascript_module_parts(specifier: &str) -> Option<(bool, &str)> { + let raw = specifier.strip_prefix("data:")?; + let without_fragment = raw.split_once('#').map_or(raw, |value| value.0); + let (metadata, payload) = without_fragment.split_once(',')?; + let mut metadata_parts = metadata.split(';'); + let media_type = metadata_parts.next()?.trim(); + if !media_type.eq_ignore_ascii_case("text/javascript") + && !media_type.eq_ignore_ascii_case("application/javascript") + { + return None; + } + let base64 = metadata_parts.any(|part| part.trim().eq_ignore_ascii_case("base64")); + Some((base64, payload)) +} + +fn data_javascript_module_source(specifier: &str) -> Option { + let (base64, payload) = data_javascript_module_parts(specifier)?; + let mut decoded_payload = percent_decode_bytes(payload); + let source = if base64 { + // WHATWG forgiving-base64 decoding ignores ASCII whitespace. This also + // matches Node for both literal and percent-encoded line wrapping. + decoded_payload.retain(|byte| !byte.is_ascii_whitespace()); + BASE64 + .decode(&decoded_payload) + .or_else(|_| BASE64_NO_PAD.decode(&decoded_payload)) + .ok()? + } else { + decoded_payload + }; + String::from_utf8(source).ok() +} + fn percent_decode(raw: &str) -> Option { + String::from_utf8(percent_decode_bytes(raw)).ok() +} + +fn percent_decode_bytes(raw: &str) -> Vec { let bytes = raw.as_bytes(); let mut index = 0; let mut decoded = Vec::with_capacity(bytes.len()); @@ -5260,7 +5328,7 @@ fn percent_decode(raw: &str) -> Option { } } } - String::from_utf8(decoded).ok() + decoded } fn hex_digit(byte: u8) -> Option { diff --git a/crates/execution/tests/javascript_v8.rs b/crates/execution/tests/javascript_v8.rs index df4c26e216..7f228f9bb1 100644 --- a/crates/execution/tests/javascript_v8.rs +++ b/crates/execution/tests/javascript_v8.rs @@ -6175,6 +6175,21 @@ fn js_runtime_node_platform_keeps_full_node_surface() { ); } +fn javascript_execution_v8_dynamic_import_accepts_data_urls() { + assert_js_runtime_guest_ok( + BTreeMap::new(), + r#" + const source = "globalThis.dataModuleRuns = (globalThis.dataModuleRuns ?? 0) + 1; export const run = globalThis.dataModuleRuns;"; + const encoded = Buffer.from(source, "utf8").toString("base64"); + const first = await import(`data:text/javascript;base64,${encoded}#request-1`); + const second = await import(`data:text/javascript;base64,${encoded}#request-2`); + if (first.run !== 1 || second.run !== 2) { + throw new Error(`unexpected data module runs: ${first.run}, ${second.run}`); + } + "#, + ); +} + fn js_runtime_bare_platform_strips_all_host_globals() { // Pentest: nothing host-provided survives, and it cannot be reconstructed via // constructors / property-name tricks. Language + WebAssembly remain. @@ -7217,6 +7232,7 @@ fn javascript_v8_suite() { javascript_execution_v8_net_socket_backpressure_stops_and_resumes_transport_reads(); javascript_execution_v8_net_close_connect_and_accept_wakes_match_node_ordering(); javascript_execution_v8_dynamic_import_accepts_file_urls(); + javascript_execution_v8_dynamic_import_accepts_data_urls(); javascript_execution_v8_import_meta_resolve_uses_guest_module_resolution(); javascript_execution_v8_wasm_instantiate_streaming_never_hangs(); javascript_execution_v8_structured_clone_rebinds_to_sandbox_realm(); diff --git a/crates/execution/tests/module_resolution.rs b/crates/execution/tests/module_resolution.rs index e91eb4b2d1..96063ad57c 100644 --- a/crates/execution/tests/module_resolution.rs +++ b/crates/execution/tests/module_resolution.rs @@ -171,6 +171,45 @@ fn builtin_subpath_normalizes_to_node_prefix() { ); } +#[test] +fn data_javascript_imports_preserve_identity_and_decode_source() { + let fixture = Fixture::new(); + let mut resolver = fixture.resolver(); + let specifier = "data:text/javascript;base64,ZXhwb3J0IGNvbnN0IHkgPSAxOw==#request-7"; + + assert_eq!( + resolver.resolve_import(specifier, "/root/project/index.js"), + Some(specifier.to_owned()) + ); + assert_eq!( + resolver.resolve_require(specifier, "/root/project/index.js"), + None + ); + assert_eq!(resolver.module_format(specifier), Some("module")); + assert_eq!( + resolver.load_module(specifier).as_deref(), + Some("export const y = 1;") + ); + + let encoded = "data:application/javascript;charset=utf-8,export%20default%20%22agentOS%22"; + assert_eq!(resolver.module_format(encoded), Some("module")); + assert_eq!( + resolver.load_module(encoded).as_deref(), + Some("export default \"agentOS\"") + ); + + assert_eq!( + resolver.resolve_import("./relative.mjs", specifier), + None, + "data URLs have no hierarchical base" + ); + assert_eq!( + resolver.resolve_import("package-from-node-modules", specifier), + None, + "data URLs cannot resolve bare packages" + ); +} + #[test] fn relative_import_probes_js_extension() { let fixture = Fixture::new(); diff --git a/crates/native-sidecar/tests/language_execution.rs b/crates/native-sidecar/tests/language_execution.rs index 3d79a9199d..4fda43fee6 100644 --- a/crates/native-sidecar/tests/language_execution.rs +++ b/crates/native-sidecar/tests/language_execution.rs @@ -665,7 +665,7 @@ fn javascript_execution_reuses_retained_context() { } #[test] -fn javascript_module_execution_accepts_inline_exports_in_a_context() { +fn javascript_module_execution_accepts_inline_exports_and_data_imports_in_a_context() { let mut sidecar = new_sidecar("language-execution-inline-esm"); let connection_id = authenticate_wire(&mut sidecar, "language-execution-inline-esm"); let session_id = open_session_wire(&mut sidecar, 2, &connection_id); @@ -763,6 +763,39 @@ fn javascript_module_execution_accepts_inline_exports_in_a_context() { assert_eq!(evaluation_result.outcome, wire::ExecutionOutcome::Succeeded); assert_eq!(evaluation_result.evaluation_value.as_deref(), Some("2")); + let data_import = sidecar + .dispatch_wire_blocking(wire_request( + 7, + wire_vm(&connection_id, &session_id, &vm_id), + wire::RequestPayload::JavaScriptExecutionRequest(wire::JavaScriptExecutionRequest { + process: context_process_options("module-context"), + source: String::from( + r#" +const source = Buffer.from("export const value = 42;", "utf8").toString("base64"); +const imported = await import(`data:text/javascript;base64,${source}#request-7`); +if (imported.value !== 42) throw new Error(`unexpected data import: ${imported.value}`); +"#, + ), + format: Some(wire::JavaScriptModuleFormat::Module), + file_path: None, + inputs: None, + }), + )) + .expect("import a base64 JavaScript data URL"); + assert_eq!(accepted_execution_id(data_import), execution_id); + let data_import_result = wait_for_execution( + &mut sidecar, + &connection_id, + &session_id, + &vm_id, + &execution_id, + ); + assert_eq!( + data_import_result.outcome, + wire::ExecutionOutcome::Succeeded + ); + assert_eq!(data_import_result.exit_code, Some(0)); + reset_execution( &mut sidecar, &connection_id,