Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions crates/execution/src/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1510,6 +1512,10 @@ impl ModuleResolutionTestHarness {
.module_format(path)
.map(LocalResolvedModuleFormat::as_str)
}

pub fn load_module(&mut self, path: &str) -> Option<String> {
self.local_bridge.load_file(path)
}
}

#[doc(hidden)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4862,6 +4869,17 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> {
from_dir: &str,
mode: ModuleResolveMode,
) -> Option<String> {
// 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)
Expand All @@ -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 == "."
Expand All @@ -4905,6 +4927,10 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> {
}

pub fn load_file(&mut self, path: &str) -> Option<String> {
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));
Expand All @@ -4924,6 +4950,12 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> {
}

pub fn module_format(&mut self, path: &str) -> Option<LocalResolvedModuleFormat> {
// 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;
}
Expand Down Expand Up @@ -5236,7 +5268,43 @@ fn guest_path_from_file_url(specifier: &str) -> Option<String> {
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<String> {
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> {
String::from_utf8(percent_decode_bytes(raw)).ok()
}

fn percent_decode_bytes(raw: &str) -> Vec<u8> {
let bytes = raw.as_bytes();
let mut index = 0;
let mut decoded = Vec::with_capacity(bytes.len());
Expand All @@ -5260,7 +5328,7 @@ fn percent_decode(raw: &str) -> Option<String> {
}
}
}
String::from_utf8(decoded).ok()
decoded
}

fn hex_digit(byte: u8) -> Option<u8> {
Expand Down
16 changes: 16 additions & 0 deletions crates/execution/tests/javascript_v8.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions crates/execution/tests/module_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
35 changes: 34 additions & 1 deletion crates/native-sidecar/tests/language_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down