Skip to main content

agentos_execution/
javascript.rs

1use crate::common::stable_hash64;
2use crate::node_import_cache::{
3    NodeImportCache, NodeImportCacheCleanup, NODE_IMPORT_CACHE_ASSET_ROOT_ENV,
4};
5use crate::runtime_support::{
6    NODE_COMPILE_CACHE_ENV, NODE_DISABLE_COMPILE_CACHE_ENV, NODE_FROZEN_TIME_ENV,
7    NODE_SANDBOX_ROOT_ENV,
8};
9use crate::signal::NodeSignalHandlerRegistration;
10use crate::v8_host::{V8RuntimeHost, V8SessionFrameReceiver, V8SessionHandle};
11use crate::v8_ipc::BinaryFrame;
12use crate::v8_runtime;
13use agentos_bridge::queue_tracker::{register_queue, TrackedLimit};
14use agentos_runtime::RuntimeContext;
15use agentos_v8_runtime::runtime_protocol::{RuntimeCommand, WarmSessionHint};
16use flume::{Receiver as EventReceiver, Sender as EventSender};
17use getrandom::getrandom;
18use serde::Deserialize;
19use serde::Serialize;
20use serde_json::{json, Value};
21use std::cmp::Reverse;
22use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet, VecDeque};
23use std::fmt;
24use std::fs::{self, File};
25use std::io::{BufRead, BufReader, BufWriter, Write};
26use std::os::fd::OwnedFd;
27use std::panic::{catch_unwind, AssertUnwindSafe};
28use std::path::{Path, PathBuf};
29use std::sync::{
30    atomic::{AtomicBool, AtomicU64, Ordering},
31    mpsc::{self, Receiver, SyncSender, TrySendError},
32    Arc, Condvar, Mutex, OnceLock,
33};
34use std::thread;
35use std::time::{Duration, Instant};
36use tokio::sync::Notify;
37use tokio::time;
38
39const NODE_ENTRYPOINT_ENV: &str = "AGENTOS_ENTRYPOINT";
40const NODE_BOOTSTRAP_ENV: &str = "AGENTOS_BOOTSTRAP_MODULE";
41const NODE_GUEST_ARGV_ENV: &str = "AGENTOS_GUEST_ARGV";
42const NODE_PREWARM_IMPORTS_ENV: &str = "AGENTOS_NODE_PREWARM_IMPORTS";
43const NODE_IMPORT_COMPILE_CACHE_NAMESPACE_VERSION: &str = "3";
44const NODE_IMPORT_CACHE_LOADER_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_LOADER_PATH";
45const NODE_IMPORT_CACHE_PATH_ENV: &str = "AGENTOS_NODE_IMPORT_CACHE_PATH";
46const NODE_KEEP_STDIN_OPEN_ENV: &str = "AGENTOS_KEEP_STDIN_OPEN";
47const NODE_GUEST_ENTRYPOINT_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT";
48const NODE_GUEST_ENTRYPOINT_MODULE_MODE_ENV: &str = "AGENTOS_GUEST_ENTRYPOINT_MODULE_MODE";
49const NODE_GUEST_PATH_MAPPINGS_ENV: &str = "AGENTOS_GUEST_PATH_MAPPINGS";
50const NODE_VIRTUAL_PROCESS_EXEC_PATH_ENV: &str = "AGENTOS_VIRTUAL_PROCESS_EXEC_PATH";
51const NODE_VIRTUAL_PROCESS_PID_ENV: &str = "AGENTOS_VIRTUAL_PROCESS_PID";
52const NODE_VIRTUAL_PROCESS_PPID_ENV: &str = "AGENTOS_VIRTUAL_PROCESS_PPID";
53const NODE_VIRTUAL_PROCESS_UID_ENV: &str = "AGENTOS_VIRTUAL_PROCESS_UID";
54const NODE_VIRTUAL_PROCESS_GID_ENV: &str = "AGENTOS_VIRTUAL_PROCESS_GID";
55const NODE_PARENT_ALLOW_CHILD_PROCESS_ENV: &str = "AGENTOS_PARENT_NODE_ALLOW_CHILD_PROCESS";
56const NODE_PARENT_ALLOW_WORKER_ENV: &str = "AGENTOS_PARENT_NODE_ALLOW_WORKER";
57const NODE_EXTRA_FS_READ_PATHS_ENV: &str = "AGENTOS_EXTRA_FS_READ_PATHS";
58const NODE_EXTRA_FS_WRITE_PATHS_ENV: &str = "AGENTOS_EXTRA_FS_WRITE_PATHS";
59const NODE_ALLOWED_BUILTINS_ENV: &str = "AGENTOS_ALLOWED_NODE_BUILTINS";
60const NODE_LOOPBACK_EXEMPT_PORTS_ENV: &str = "AGENTOS_LOOPBACK_EXEMPT_PORTS";
61const NODE_SYNC_RPC_ENABLE_ENV: &str = "AGENTOS_NODE_SYNC_RPC_ENABLE";
62const NODE_SYNC_RPC_REQUEST_FD_ENV: &str = "AGENTOS_NODE_SYNC_RPC_REQUEST_FD";
63const NODE_SYNC_RPC_RESPONSE_FD_ENV: &str = "AGENTOS_NODE_SYNC_RPC_RESPONSE_FD";
64const NODE_SYNC_RPC_DATA_BYTES_ENV: &str = "AGENTOS_NODE_SYNC_RPC_DATA_BYTES";
65const NODE_SYNC_RPC_WAIT_TIMEOUT_MS_ENV: &str = "AGENTOS_NODE_SYNC_RPC_WAIT_TIMEOUT_MS";
66static NEXT_V8_SESSION_ID: AtomicU64 = AtomicU64::new(1);
67static JAVASCRIPT_TIMER_WHEEL: OnceLock<Arc<TimerWheel>> = OnceLock::new();
68static JAVASCRIPT_TIMER_WHEEL_INIT: Mutex<()> = Mutex::new(());
69
70#[derive(Default)]
71struct JsStartPhaseStats {
72    calls: u64,
73    total_ns: u128,
74    max_ns: u128,
75}
76
77static JS_START_PHASES: OnceLock<Mutex<BTreeMap<String, JsStartPhaseStats>>> = OnceLock::new();
78static JS_EVENT_PHASES: OnceLock<Mutex<BTreeMap<String, JsStartPhaseStats>>> = OnceLock::new();
79
80fn js_start_phases_enabled() -> bool {
81    std::env::var("AGENTOS_JS_START_PHASES").as_deref() == Ok("1")
82}
83
84fn js_event_phases_enabled() -> bool {
85    std::env::var("AGENTOS_JS_EVENT_PHASES").as_deref() == Ok("1")
86}
87
88fn record_js_start_phase(stage: &str, elapsed: Duration) {
89    if !js_start_phases_enabled() {
90        return;
91    }
92    record_js_phase_stats(
93        &JS_START_PHASES,
94        "AGENTOS_JS_START_PHASES_FILE",
95        stage,
96        elapsed,
97    );
98}
99
100fn record_js_event_phase(stage: &str, elapsed: Duration) {
101    if !js_event_phases_enabled() {
102        return;
103    }
104    record_js_phase_stats(
105        &JS_EVENT_PHASES,
106        "AGENTOS_JS_EVENT_PHASES_FILE",
107        stage,
108        elapsed,
109    );
110}
111
112fn record_js_phase_stats(
113    phases: &OnceLock<Mutex<BTreeMap<String, JsStartPhaseStats>>>,
114    path_env: &str,
115    stage: &str,
116    elapsed: Duration,
117) {
118    let phases = phases.get_or_init(|| Mutex::new(BTreeMap::new()));
119    let Ok(mut phases) = phases.lock() else {
120        return;
121    };
122    let stats = phases.entry(stage.to_string()).or_default();
123    stats.calls += 1;
124    let elapsed_ns = elapsed.as_nanos();
125    stats.total_ns += elapsed_ns;
126    stats.max_ns = stats.max_ns.max(elapsed_ns);
127
128    let Some(path) = std::env::var_os(path_env) else {
129        return;
130    };
131    let mut output = String::new();
132    for (stage, stats) in phases.iter() {
133        let total_us = stats.total_ns / 1_000;
134        let avg_us = if stats.calls == 0 {
135            0
136        } else {
137            total_us / u128::from(stats.calls)
138        };
139        let max_us = stats.max_ns / 1_000;
140        output.push_str(&format!(
141            "stage={stage} calls={} total_us={total_us} avg_us={avg_us} max_us={max_us}\n",
142            stats.calls
143        ));
144    }
145    let _ = fs::write(path, output);
146}
147
148const DEFAULT_V8_CPU_TIME_LIMIT_MS: u32 = 30_000;
149const DEFAULT_V8_WALL_CLOCK_LIMIT_MS: u32 = 0;
150const DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS: u64 = 30_000;
151const NODE_SYNC_RPC_DEFAULT_DATA_BYTES: usize = 4 * 1024 * 1024;
152const NODE_SYNC_RPC_DEFAULT_WAIT_TIMEOUT_MS: u64 = 30_000;
153const NODE_SYNC_RPC_RESPONSE_QUEUE_CAPACITY: usize = 1;
154const FORWARD_KERNEL_STDIN_RPC_ENV: &str = "AGENTOS_FORWARD_KERNEL_STDIN_RPC";
155// Defense-in-depth headroom: a transient burst of guest events (e.g. a chatty
156// tool/skill turn) should be absorbed by the buffer, so the producer only ever
157// hits backpressure under a genuinely stuck consumer rather than on every spike.
158const JAVASCRIPT_EVENT_CHANNEL_CAPACITY: usize = 512;
159const JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES: usize = 1024 * 1024;
160const JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES: usize = 16 * 1024 * 1024;
161const KERNEL_STDIN_BUFFER_LIMIT_BYTES: usize = 16 * 1024 * 1024;
162const NODE_WARMUP_MARKER_VERSION: &str = "1";
163const NODE_WARMUP_SPECIFIERS: &[&str] = &[
164    "secure-exec:builtin/path",
165    "secure-exec:builtin/url",
166    "secure-exec:builtin/fs-promises",
167    "secure-exec:polyfill/path",
168];
169
170#[derive(Debug, Default, Clone)]
171struct SyncBridgePhaseStats {
172    calls: u64,
173    total_us: u64,
174    max_us: u64,
175}
176
177static SYNC_BRIDGE_PHASES: OnceLock<Mutex<BTreeMap<String, SyncBridgePhaseStats>>> =
178    OnceLock::new();
179static SYNC_BRIDGE_REQUEST_ENQUEUED: OnceLock<Mutex<HashMap<u64, (String, Instant)>>> =
180    OnceLock::new();
181
182fn sync_bridge_phases_enabled() -> bool {
183    std::env::var("AGENTOS_SYNC_BRIDGE_PHASES").as_deref() == Ok("1")
184}
185
186fn record_sync_bridge_phase(method: &str, stage: &str, elapsed: Duration) {
187    if !sync_bridge_phases_enabled() {
188        return;
189    }
190    let stats = SYNC_BRIDGE_PHASES.get_or_init(|| Mutex::new(BTreeMap::new()));
191    let Ok(mut stats) = stats.lock() else {
192        return;
193    };
194    let elapsed_us = elapsed.as_micros() as u64;
195    let key = format!("{method}:{stage}");
196    let entry = stats.entry(key).or_default();
197    entry.calls += 1;
198    entry.total_us = entry.total_us.wrapping_add(elapsed_us);
199    entry.max_us = entry.max_us.max(elapsed_us);
200
201    if let Ok(path) = std::env::var("AGENTOS_SYNC_BRIDGE_PHASES_FILE") {
202        let mut lines = String::new();
203        for (key, value) in stats.iter() {
204            let Some((method, stage)) = key.split_once(':') else {
205                continue;
206            };
207            let avg_us = value.total_us.checked_div(value.calls).unwrap_or(0);
208            lines.push_str(&format!(
209                "method={method} stage={stage} calls={} total_us={} avg_us={} max_us={}\n",
210                value.calls, value.total_us, avg_us, value.max_us
211            ));
212        }
213        let _ = fs::write(path, lines);
214    }
215}
216
217pub fn record_sync_bridge_request_enqueued(call_id: u64, method: &str) {
218    if !sync_bridge_phases_enabled() {
219        return;
220    }
221    let requests = SYNC_BRIDGE_REQUEST_ENQUEUED.get_or_init(|| Mutex::new(HashMap::new()));
222    let Ok(mut requests) = requests.lock() else {
223        return;
224    };
225    if requests.len() > 4096 {
226        requests.clear();
227    }
228    requests.insert(call_id, (method.to_owned(), Instant::now()));
229}
230
231pub fn record_sync_bridge_request_observed(call_id: u64, fallback_method: &str) {
232    if !sync_bridge_phases_enabled() {
233        return;
234    }
235    let Some(requests) = SYNC_BRIDGE_REQUEST_ENQUEUED.get() else {
236        return;
237    };
238    let Ok(mut requests) = requests.lock() else {
239        return;
240    };
241    let Some((method, started)) = requests.remove(&call_id) else {
242        return;
243    };
244    let method = if method.is_empty() {
245        fallback_method
246    } else {
247        method.as_str()
248    };
249    record_sync_bridge_phase(method, "request_service_observed", started.elapsed());
250}
251const CONTROLLED_STDERR_PREFIXES: &[&str] =
252    &[crate::node_import_cache::NODE_IMPORT_CACHE_METRICS_PREFIX];
253const RESERVED_NODE_ENV_KEYS: &[&str] = &[
254    NODE_BOOTSTRAP_ENV,
255    NODE_COMPILE_CACHE_ENV,
256    NODE_DISABLE_COMPILE_CACHE_ENV,
257    NODE_ENTRYPOINT_ENV,
258    NODE_EXTRA_FS_READ_PATHS_ENV,
259    NODE_EXTRA_FS_WRITE_PATHS_ENV,
260    NODE_SANDBOX_ROOT_ENV,
261    NODE_FROZEN_TIME_ENV,
262    NODE_GUEST_ENTRYPOINT_ENV,
263    NODE_GUEST_ENTRYPOINT_MODULE_MODE_ENV,
264    NODE_GUEST_ARGV_ENV,
265    NODE_GUEST_PATH_MAPPINGS_ENV,
266    NODE_VIRTUAL_PROCESS_EXEC_PATH_ENV,
267    NODE_VIRTUAL_PROCESS_PID_ENV,
268    NODE_VIRTUAL_PROCESS_PPID_ENV,
269    NODE_VIRTUAL_PROCESS_UID_ENV,
270    NODE_VIRTUAL_PROCESS_GID_ENV,
271    NODE_PARENT_ALLOW_CHILD_PROCESS_ENV,
272    NODE_PARENT_ALLOW_WORKER_ENV,
273    NODE_IMPORT_CACHE_ASSET_ROOT_ENV,
274    NODE_IMPORT_CACHE_LOADER_PATH_ENV,
275    NODE_IMPORT_CACHE_PATH_ENV,
276    NODE_KEEP_STDIN_OPEN_ENV,
277    NODE_ALLOWED_BUILTINS_ENV,
278    NODE_LOOPBACK_EXEMPT_PORTS_ENV,
279    NODE_SYNC_RPC_ENABLE_ENV,
280    NODE_SYNC_RPC_REQUEST_FD_ENV,
281    NODE_SYNC_RPC_RESPONSE_FD_ENV,
282    NODE_SYNC_RPC_DATA_BYTES_ENV,
283    NODE_SYNC_RPC_WAIT_TIMEOUT_MS_ENV,
284];
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(tag = "type", rename_all = "snake_case")]
288enum NodeControlMessage {
289    NodeImportCacheMetrics {
290        metrics: serde_json::Value,
291    },
292    PythonExit {
293        #[serde(rename = "exitCode")]
294        exit_code: i32,
295    },
296    SignalState {
297        signal: u32,
298        registration: NodeSignalHandlerRegistration,
299    },
300}
301
302#[derive(Debug, Default)]
303struct LinePrefixFilter {
304    pending: Vec<u8>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct JavascriptSyncRpcRequest {
309    pub id: u64,
310    pub method: String,
311    pub args: Vec<Value>,
312    pub raw_bytes_args: HashMap<usize, Vec<u8>>,
313}
314
315#[derive(Debug, Deserialize)]
316struct JavascriptSyncRpcRequestWire {
317    id: u64,
318    method: String,
319    #[serde(default)]
320    args: Vec<Value>,
321}
322
323impl LinePrefixFilter {
324    fn filter_chunk(&mut self, chunk: &[u8], prefixes: &[&str]) -> Vec<u8> {
325        self.pending.extend_from_slice(chunk);
326        let mut filtered = Vec::new();
327
328        while let Some(newline_index) = self.pending.iter().position(|byte| *byte == b'\n') {
329            let line = self.pending.drain(..=newline_index).collect::<Vec<_>>();
330            if !has_control_prefix(&line, prefixes) {
331                filtered.extend_from_slice(&line);
332            }
333        }
334
335        filtered
336    }
337}
338
339fn has_control_prefix(line: &[u8], prefixes: &[&str]) -> bool {
340    let text = String::from_utf8_lossy(line);
341    let trimmed = text.trim_end_matches(['\r', '\n']);
342    prefixes.iter().any(|prefix| trimmed.starts_with(prefix))
343}
344
345#[cfg(test)]
346#[derive(Debug)]
347struct JavascriptSyncRpcResponseWriter {
348    sender: SyncSender<Vec<u8>>,
349    timeout: Duration,
350}
351
352#[cfg(test)]
353impl JavascriptSyncRpcResponseWriter {
354    fn new(writer: File, timeout: Duration) -> Self {
355        let (sender, receiver) = mpsc::sync_channel(NODE_SYNC_RPC_RESPONSE_QUEUE_CAPACITY);
356        spawn_javascript_sync_rpc_response_writer(writer, receiver);
357        Self { sender, timeout }
358    }
359
360    fn send(&self, payload: Vec<u8>) -> Result<(), JavascriptExecutionError> {
361        let started = Instant::now();
362        let mut payload = Some(payload);
363
364        loop {
365            match self
366                .sender
367                .try_send(payload.take().expect("payload should be present"))
368            {
369                Ok(()) => return Ok(()),
370                Err(TrySendError::Disconnected(_)) => {
371                    return Err(JavascriptExecutionError::RpcResponse(String::from(
372                        "JavaScript sync RPC response channel closed unexpectedly",
373                    )));
374                }
375                Err(TrySendError::Full(returned_payload)) => {
376                    if started.elapsed() >= self.timeout {
377                        return Err(JavascriptExecutionError::RpcResponse(format!(
378                            "timed out after {}ms while queueing JavaScript sync RPC response",
379                            self.timeout.as_millis()
380                        )));
381                    }
382                    payload = Some(returned_payload);
383                    thread::sleep(Duration::from_millis(5));
384                }
385            }
386        }
387    }
388}
389
390#[cfg(test)]
391impl Clone for JavascriptSyncRpcResponseWriter {
392    fn clone(&self) -> Self {
393        Self {
394            sender: self.sender.clone(),
395            timeout: self.timeout,
396        }
397    }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401enum PendingSyncRpcState {
402    Pending(u64),
403    TimedOut(u64),
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407enum PendingSyncRpcResolution {
408    Pending,
409    TimedOut,
410    Missing,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct CreateJavascriptContextRequest {
415    pub vm_id: String,
416    pub bootstrap_module: Option<String>,
417    pub compile_cache_root: Option<PathBuf>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct JavascriptContext {
422    pub context_id: String,
423    pub vm_id: String,
424    pub bootstrap_module: Option<String>,
425    pub compile_cache_dir: Option<PathBuf>,
426}
427
428/// Per-execution JavaScript runtime limits, carried as typed fields on the
429/// execution request rather than `AGENTOS_*` env vars. The sidecar populates
430/// these from the per-VM `VmLimits` (which originate from `CreateVmConfig` on
431/// the BARE wire); `None` selects the engine default. See the env-vs-wire rule
432/// in `crates/sidecar/CLAUDE.md`.
433#[derive(Debug, Clone, Default, PartialEq, Eq)]
434pub struct JavascriptExecutionLimits {
435    /// V8 heap cap in MB. `None`/`Some(0)` keeps the engine default heap.
436    pub v8_heap_limit_mb: Option<u32>,
437    /// Sync-RPC blocking-wait ceiling in ms. `None` keeps the engine default.
438    pub sync_rpc_wait_timeout_ms: Option<u64>,
439    /// Active JavaScript CPU-time budget in ms. `None` keeps the engine default;
440    /// `Some(0)` disables the CPU watchdog.
441    pub cpu_time_limit_ms: Option<u32>,
442    /// JavaScript wall-clock backstop in ms. `None` keeps the engine default;
443    /// `Some(0)` disables the wall-clock watchdog.
444    pub wall_clock_limit_ms: Option<u32>,
445    /// Timeout for materializing the per-VM Node import cache.
446    pub import_cache_materialize_timeout_ms: Option<u64>,
447    /// Maximum live JavaScript timers in this execution. `None` keeps the
448    /// engine default. The sidecar supplies the VM-scoped configured value.
449    pub max_timers: Option<usize>,
450    /// Maximum readiness identities delivered in one V8 turn. Sidecar VM
451    /// execution must supply `limits.reactor.workQuantum` here.
452    pub reactor_work_quantum: Option<usize>,
453    /// Per-call host bridge deadline. Sidecar VMs supply
454    /// `limits.reactor.operationDeadlineMs`; zero is invalid.
455    pub bridge_call_timeout_ms: Option<u64>,
456}
457
458/// Per-execution guest-runtime config carried as typed fields rather than
459/// `AGENTOS_*` env vars. The sidecar populates these from kernel state
460/// (`user_profile()`, `resource_limits()`) and `CreateVmConfig`; the runtime
461/// shim interpolates them into a `_processConfig` object the guest reads, so the
462/// guest's virtual identity no longer rides the ambient env channel. `None`
463/// keeps the guest-runtime default. See the env-vs-wire rule in
464/// `crates/sidecar/CLAUDE.md`.
465#[derive(Debug, Clone, Default, PartialEq, Eq)]
466pub struct GuestRuntimeConfig {
467    /// Virtual `process.pid`.
468    pub virtual_pid: Option<u64>,
469    /// Virtual `process.ppid`.
470    pub virtual_ppid: Option<u64>,
471    /// Virtual `process.uid` / `process.euid`.
472    pub virtual_uid: Option<u64>,
473    /// Virtual `process.gid` / `process.egid` / `process.groups`.
474    pub virtual_gid: Option<u64>,
475    /// Virtual `process.execPath`.
476    pub virtual_exec_path: Option<String>,
477    /// `os.cpus().length`.
478    pub os_cpu_count: Option<u64>,
479    /// `os.totalmem()` in bytes.
480    pub os_totalmem: Option<u64>,
481    /// `os.freemem()` in bytes.
482    pub os_freemem: Option<u64>,
483    /// `os.homedir()`.
484    pub os_homedir: Option<String>,
485    /// `os.hostname()`.
486    pub os_hostname: Option<String>,
487    /// `os.tmpdir()`.
488    pub os_tmpdir: Option<String>,
489    /// `os.type()`.
490    pub os_type: Option<String>,
491    /// `os.release()`.
492    pub os_release: Option<String>,
493    /// `os.version()`.
494    pub os_version: Option<String>,
495    /// `os.machine()`.
496    pub os_machine: Option<String>,
497    /// Default login shell.
498    pub os_shell: Option<String>,
499    /// `os.userInfo().username`.
500    pub os_user: Option<String>,
501    /// Opt-in high-resolution monotonic guest clock. Default false preserves
502    /// the security-oriented coarse clock.
503    pub high_resolution_time: bool,
504    /// Optional agent-SDK bundle (esbuild IIFE) to evaluate into the per-sidecar
505    /// V8 snapshot alongside the bridge, so the SDK is loaded once per sidecar and
506    /// reused across sessions instead of re-imported on every execution. `None`
507    /// keeps the bridge-only snapshot (unchanged behavior). The runtime caches the
508    /// snapshot process-wide keyed by sha256(bridge_code + this bundle).
509    pub snapshot_userland_code: Option<String>,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct StartJavascriptExecutionRequest {
514    pub vm_id: String,
515    pub context_id: String,
516    pub argv: Vec<String>,
517    /// Explicit process argv[0]. `Some("")` is distinct from `None` and must be
518    /// preserved for Node child_process compatibility.
519    pub argv0: Option<String>,
520    pub env: BTreeMap<String, String>,
521    pub cwd: PathBuf,
522    /// Per-execution runtime limits (see [`JavascriptExecutionLimits`]).
523    pub limits: JavascriptExecutionLimits,
524    /// Per-execution guest-runtime config (see [`GuestRuntimeConfig`]).
525    pub guest_runtime: GuestRuntimeConfig,
526    /// Optional inline JavaScript code supplied by the sidecar.
527    /// Eval entrypoints always execute this source directly. Module-mode file
528    /// entrypoints may also use it so the isolate can evaluate the original
529    /// source without re-reading through the host. CommonJS file entrypoints
530    /// still go through the normal require() wrapper so Node-style globals such
531    /// as __filename and __dirname are initialized correctly.
532    pub inline_code: Option<String>,
533    /// Optional raw WASM module bytes to expose to the runner isolate for this
534    /// execution.
535    pub wasm_module_bytes: Option<Arc<Vec<u8>>>,
536}
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub enum JavascriptExecutionEvent {
540    Stdout(Vec<u8>),
541    Stderr(Vec<u8>),
542    SyncRpcRequest(JavascriptSyncRpcRequest),
543    SignalState {
544        signal: u32,
545        registration: NodeSignalHandlerRegistration,
546    },
547    Exited(i32),
548}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
551enum JavascriptProcessEvent {
552    Stdout(Vec<u8>),
553    RawStderr(Vec<u8>),
554    SyncRpcRequest(JavascriptSyncRpcRequest),
555    Control(NodeControlMessage),
556    Exited(i32),
557}
558
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct JavascriptExecutionResult {
561    pub execution_id: String,
562    pub exit_code: i32,
563    pub stdout: Vec<u8>,
564    pub stderr: Vec<u8>,
565}
566
567#[derive(Debug, Clone, PartialEq, Eq)]
568struct GuestPathMapping {
569    guest_path: String,
570    host_path: PathBuf,
571}
572
573#[derive(Debug, Deserialize)]
574struct GuestPathMappingWire {
575    #[serde(rename = "guestPath")]
576    guest_path: String,
577    #[serde(rename = "hostPath")]
578    host_path: String,
579}
580
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
582pub enum ModuleResolveMode {
583    Require,
584    Import,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub enum LocalResolvedModuleFormat {
589    Module,
590    Commonjs,
591    Json,
592}
593
594impl LocalResolvedModuleFormat {
595    pub fn as_str(self) -> &'static str {
596        match self {
597            Self::Module => "module",
598            Self::Commonjs => "commonjs",
599            Self::Json => "json",
600        }
601    }
602}
603
604#[derive(Debug, Clone, Default)]
605pub struct LocalModuleResolutionCache {
606    resolve_results: HashMap<(String, String, ModuleResolveMode), Option<String>>,
607    module_format_results: HashMap<String, Option<LocalResolvedModuleFormat>>,
608    package_json_results: HashMap<String, Option<LocalPackageJson>>,
609    exists_results: HashMap<String, bool>,
610    stat_results: HashMap<String, Option<bool>>,
611}
612
613/// Read-only filesystem primitives the module resolver needs. The resolution
614/// algorithm itself is pure path algebra over these four operations; pointing
615/// it at a different backing store (host files vs. the kernel VFS) is purely a
616/// matter of supplying a different `ModuleFsReader`.
617///
618/// All paths are guest paths (e.g. `/root/node_modules/foo/index.js`). Symlink
619/// following is the reader's responsibility: `canonical_guest_path` must return
620/// the fully-resolved guest path (realpath), and `path_is_dir`/`path_exists`
621/// must follow symlinks the way real Node's `fs.stat`/`fs.existsSync` do.
622pub trait ModuleFsReader {
623    /// Realpath of `guest_path`, expressed as a guest path. `None` if the path
624    /// does not resolve (does not exist / escapes the addressable tree).
625    fn canonical_guest_path(&mut self, guest_path: &str) -> Option<String>;
626
627    /// Read the file at `guest_path` as a UTF-8 string, following symlinks.
628    fn read_to_string(&mut self, guest_path: &str) -> Option<String>;
629
630    /// `Some(true)` if `guest_path` is a directory, `Some(false)` if it exists
631    /// but is not a directory, `None` if it does not exist. Follows symlinks.
632    fn path_is_dir(&mut self, guest_path: &str) -> Option<bool>;
633
634    /// Whether `guest_path` exists, following symlinks.
635    fn path_exists(&mut self, guest_path: &str) -> bool;
636}
637
638/// Guest JavaScript module-resolution mode (the `moduleResolution` axis of
639/// `jsRuntime`). Defaults to full Node.js resolution.
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
641enum GuestModuleResolution {
642    /// node_modules ancestor-walk + exports/conditions + realpath.
643    #[default]
644    Node,
645    /// Relative/absolute ESM only; bare specifiers do not resolve.
646    Relative,
647    /// No resolution at all; every specifier (relative included) is denied.
648    None,
649}
650
651impl GuestModuleResolution {
652    fn from_env(env: &BTreeMap<String, String>) -> Self {
653        match env.get("AGENTOS_JS_MODULE_RESOLUTION").map(String::as_str) {
654            Some("relative") => Self::Relative,
655            Some("none") => Self::None,
656            _ => Self::Node,
657        }
658    }
659}
660
661struct LocalBridgeState {
662    runtime: Option<RuntimeContext>,
663    timer_resources: Option<Arc<agentos_runtime::accounting::ResourceLedger>>,
664    max_timers: usize,
665    translator: GuestPathTranslator,
666    resolution_cache: LocalModuleResolutionCache,
667    /// jsRuntime module-resolution mode for this execution.
668    module_resolution: GuestModuleResolution,
669    handle_descriptions: HashMap<String, String>,
670    next_timer_id: u64,
671    timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>>,
672    kernel_stdin: Arc<LocalKernelStdinBridge>,
673    forward_kernel_stdin_rpc: bool,
674    v8_session: Option<V8SessionHandle>,
675    /// Optional read-only reader over the mounted `node_modules` VFS, supplied by
676    /// the sidecar. When present, the bridge thread resolves module-resolution
677    /// RPCs (`_resolveModule` / `_loadFile` / `_moduleFormat` /
678    /// `_batchResolveModules`) inline against this reader, concurrently with the
679    /// service loop — so a large cold-start module graph does not serialize
680    /// behind / starve the ACP bootstrap on the single service-loop thread.
681    /// `None` means "route module resolution to the service loop" (the kernel-VFS
682    /// fallback for callers that supply no reader).
683    module_reader: Option<Box<dyn ModuleFsReader + Send>>,
684}
685
686impl Default for LocalBridgeState {
687    fn default() -> Self {
688        let runtime = default_test_runtime_context();
689        let timer_resources = runtime
690            .as_ref()
691            .map(|runtime| Arc::clone(runtime.resources()));
692        Self {
693            runtime,
694            timer_resources,
695            max_timers: MAX_TIMERS_PER_EXECUTION,
696            translator: GuestPathTranslator::default(),
697            resolution_cache: LocalModuleResolutionCache::default(),
698            module_resolution: GuestModuleResolution::default(),
699            handle_descriptions: HashMap::new(),
700            next_timer_id: 0,
701            timers: Arc::new(Mutex::new(HashMap::new())),
702            kernel_stdin: Arc::new(LocalKernelStdinBridge::default()),
703            forward_kernel_stdin_rpc: false,
704            v8_session: None,
705            module_reader: None,
706        }
707    }
708}
709
710#[cfg(test)]
711fn default_test_runtime_context() -> Option<RuntimeContext> {
712    agentos_runtime::SidecarRuntime::process(&agentos_runtime::RuntimeConfig::default())
713        .ok()
714        .map(agentos_runtime::SidecarRuntime::context)
715}
716
717#[cfg(not(test))]
718fn default_test_runtime_context() -> Option<RuntimeContext> {
719    None
720}
721
722impl Drop for LocalBridgeState {
723    /// Tear down all tracked timers when the bridge state is dropped (which
724    /// happens when the event-bridge service loop exits on session termination —
725    /// success, error, or shutdown). Clearing the shared `timers` map cancels both
726    /// kernel and bridge timers: any in-flight wheel action that wakes afterwards
727    /// finds its entry gone and suppresses its callback via `timer_should_fire`,
728    /// so a destroyed session's timers do not fire after the fact.
729    fn drop(&mut self) {
730        if let Some(wheel) = JAVASCRIPT_TIMER_WHEEL.get() {
731            wheel.cancel_registry(&self.timers);
732        }
733        if let Ok(mut timers) = self.timers.lock() {
734            timers.clear();
735        }
736    }
737}
738
739#[derive(Debug, Default)]
740struct LocalKernelStdinBridge {
741    state: Mutex<LocalKernelStdinState>,
742    ready: Condvar,
743}
744
745#[derive(Debug, Default)]
746struct LocalKernelStdinState {
747    bytes: VecDeque<u8>,
748    closed: bool,
749}
750
751#[derive(Debug, Clone, Default)]
752struct GuestPathTranslator {
753    implicit_guest_cwd: String,
754    implicit_host_cwd: PathBuf,
755    sandbox_root: Option<PathBuf>,
756    mappings: Vec<GuestPathMapping>,
757}
758
759#[derive(Debug, Clone, Deserialize, Default)]
760struct LocalPackageJson {
761    #[serde(default)]
762    name: Option<String>,
763    #[serde(default)]
764    main: Option<String>,
765    #[serde(default)]
766    #[serde(rename = "type")]
767    package_type: Option<String>,
768    #[serde(default)]
769    exports: Option<Value>,
770    #[serde(default)]
771    imports: Option<Value>,
772}
773
774#[derive(Debug)]
775struct LocalTimerEntry {
776    delay_ms: u64,
777    generation: u64,
778    repeat: bool,
779    _reservation: Option<agentos_runtime::accounting::Reservation>,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
783#[serde(rename_all = "kebab-case")]
784enum PolyfillSourceKind {
785    NodeStdlibBrowser,
786    CustomBridge,
787    Denied,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
791#[serde(rename_all = "camelCase")]
792struct PolyfillRegistryGroup {
793    source: PolyfillSourceKind,
794    #[serde(default)]
795    error_code: Option<String>,
796    names: Vec<String>,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
800#[serde(rename_all = "camelCase")]
801struct PolyfillRegistry {
802    version: u32,
803    groups: Vec<PolyfillRegistryGroup>,
804}
805
806static POLYFILL_REGISTRY: OnceLock<PolyfillRegistry> = OnceLock::new();
807
808fn polyfill_registry() -> &'static PolyfillRegistry {
809    POLYFILL_REGISTRY.get_or_init(|| {
810        serde_json::from_str(include_str!("../assets/polyfill-registry.json"))
811            .expect("polyfill-registry.json must be valid")
812    })
813}
814
815#[derive(Debug, Clone, PartialEq)]
816enum LocalBridgeCallResult {
817    Immediate(Value),
818    Deferred,
819}
820
821/// Upper bound on guest-supplied timer delays, matching the JS `TIMEOUT_MAX`
822/// ceiling (`2**31 - 1` ms, ~24.8 days). Guest code can pass a delay up to
823/// `u64::MAX` ms; clamping keeps the timer wheel's deadline math and session
824/// handle lifetime within Node-compatible bounds.
825const MAX_TIMER_DELAY_MS: u64 = 2_147_483_647;
826const MAX_TIMERS_PER_EXECUTION: usize = 4_096;
827const MAX_TIMER_ACTIONS_PER_TURN: usize = 1_024;
828
829fn timer_delay_ms(value: Option<&Value>) -> u64 {
830    let delay = match value {
831        Some(Value::Number(number)) => number.as_f64().unwrap_or(0.0),
832        Some(Value::String(text)) => text.parse::<f64>().unwrap_or(0.0),
833        _ => 0.0,
834    };
835
836    if !delay.is_finite() || delay <= 0.0 {
837        0
838    } else {
839        delay.floor().min(MAX_TIMER_DELAY_MS as f64) as u64
840    }
841}
842
843fn timer_dispatch_error(message: String) -> Value {
844    json!({
845        "__bd_error": {
846            "name": "Error",
847            "code": message.split(':').next().unwrap_or("ERR_AGENTOS_JAVASCRIPT_TIMER"),
848            "message": message,
849        }
850    })
851}
852
853/// Decide whether a woken timer action should fire, and reclaim its tracking
854/// entry. Returns `false` (suppressing the callback) when the timer is gone from
855/// the map (cleared, or wiped on session teardown) or its generation no longer
856/// matches the one captured at scheduling time (re-armed/cancelled). A one-shot
857/// (`repeat == false`) timer that does fire is removed from the map so its id is
858/// reclaimed. Shared by the kernel-timer and bridge-timer paths so both honor the
859/// same cancellation semantics.
860fn timer_should_fire(
861    timers: &Arc<Mutex<HashMap<u64, LocalTimerEntry>>>,
862    timer_id: u64,
863    generation: u64,
864) -> bool {
865    timers
866        .lock()
867        .ok()
868        .and_then(|mut timers| {
869            let (current_generation, repeat) = timers
870                .get(&timer_id)
871                .map(|entry| (entry.generation, entry.repeat))?;
872            if current_generation != generation {
873                return Some(false);
874            }
875            if !repeat {
876                timers.remove(&timer_id);
877            }
878            Some(true)
879        })
880        .unwrap_or(false)
881}
882
883struct TimerWheel {
884    state: Mutex<TimerWheelState>,
885    ready: Notify,
886}
887
888#[derive(Default)]
889struct TimerWheelState {
890    heap: BinaryHeap<Reverse<(Instant, u64)>>,
891    entries: HashMap<u64, ScheduledTimerAction>,
892    timer_index: HashMap<(usize, u64), u64>,
893    next_seq: u64,
894}
895
896struct ScheduledTimerAction {
897    deadline: Instant,
898    action: TimerAction,
899}
900
901enum TimerAction {
902    StreamEvent {
903        session: V8SessionHandle,
904        timer_id: u64,
905        generation: u64,
906        timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>>,
907    },
908    BridgeResponse {
909        session: V8SessionHandle,
910        call_id: u64,
911        timer_id: u64,
912        generation: u64,
913        timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>>,
914    },
915}
916
917fn settle_timer_bridge_response(
918    session: &V8SessionHandle,
919    call_id: u64,
920    status: u8,
921    payload: Vec<u8>,
922) {
923    if let Err(error) = session.send_bridge_response(call_id, status, payload) {
924        tracing::warn!(
925            call_id,
926            error = %error,
927            "timer bridge response caller stopped waiting"
928        );
929    }
930}
931
932impl TimerAction {
933    fn timer_key(&self) -> (usize, u64) {
934        match self {
935            Self::StreamEvent {
936                timer_id, timers, ..
937            }
938            | Self::BridgeResponse {
939                timer_id, timers, ..
940            } => (Arc::as_ptr(timers) as usize, *timer_id),
941        }
942    }
943
944    fn execute(self) {
945        match self {
946            Self::StreamEvent {
947                session,
948                timer_id,
949                generation,
950                timers,
951            } => {
952                if !timer_should_fire(&timers, timer_id, generation) {
953                    return;
954                }
955
956                if let Err(error) = session.publish_timer(timer_id) {
957                    tracing::warn!(
958                        timer_id,
959                        error = %error,
960                        "could not publish durable JavaScript timer readiness"
961                    );
962                }
963            }
964            Self::BridgeResponse {
965                session,
966                call_id,
967                timer_id,
968                generation,
969                timers,
970            } => {
971                if !timer_should_fire(&timers, timer_id, generation) {
972                    return;
973                }
974                settle_timer_bridge_response(&session, call_id, 0, Vec::new());
975            }
976        }
977    }
978}
979
980impl TimerWheel {
981    fn get(runtime: &RuntimeContext) -> Result<&'static Arc<Self>, String> {
982        if let Some(wheel) = JAVASCRIPT_TIMER_WHEEL.get() {
983            return Ok(wheel);
984        }
985
986        let _initializing = JAVASCRIPT_TIMER_WHEEL_INIT.lock().map_err(|_| {
987            String::from(
988                "ERR_AGENTOS_JAVASCRIPT_TIMER_WHEEL_INIT: timer wheel initialization lock poisoned",
989            )
990        })?;
991        if let Some(wheel) = JAVASCRIPT_TIMER_WHEEL.get() {
992            return Ok(wheel);
993        }
994
995        let wheel = Self::start(runtime.clone())?;
996        let _ = JAVASCRIPT_TIMER_WHEEL.set(wheel);
997        JAVASCRIPT_TIMER_WHEEL.get().ok_or_else(|| {
998            String::from("ERR_AGENTOS_JAVASCRIPT_TIMER_WHEEL_INIT: timer wheel was not installed")
999        })
1000    }
1001
1002    fn start(runtime: RuntimeContext) -> Result<Arc<Self>, String> {
1003        let wheel = Arc::new(Self {
1004            state: Mutex::new(TimerWheelState::default()),
1005            ready: Notify::new(),
1006        });
1007        let worker = Arc::clone(&wheel);
1008        runtime
1009            .spawn(agentos_runtime::TaskClass::Timer, async move {
1010                worker.run().await
1011            })
1012            .map_err(|error| {
1013                format!("ERR_AGENTOS_TASK_LIMIT: failed to start JavaScript timer wheel: {error}")
1014            })?;
1015        Ok(wheel)
1016    }
1017
1018    fn schedule(&self, delay_ms: u64, action: TimerAction) -> Result<(), String> {
1019        let now = Instant::now();
1020        let deadline = now
1021            .checked_add(Duration::from_millis(delay_ms))
1022            .unwrap_or(now);
1023        let mut state = self.lock_state();
1024        let timer_key = action.timer_key();
1025        if let Some(previous_seq) = state.timer_index.remove(&timer_key) {
1026            state.entries.remove(&previous_seq);
1027        }
1028        let old_earliest = state.heap.peek().map(|Reverse((deadline, _))| *deadline);
1029        let seq = state.next_seq;
1030        state.next_seq = state.next_seq.checked_add(1).ok_or_else(|| {
1031            String::from(
1032                "ERR_AGENTOS_JAVASCRIPT_TIMER_SEQUENCE_EXHAUSTED: process timer sequence exhausted",
1033            )
1034        })?;
1035        state.heap.push(Reverse((deadline, seq)));
1036        state
1037            .entries
1038            .insert(seq, ScheduledTimerAction { deadline, action });
1039        state.timer_index.insert(timer_key, seq);
1040        Self::compact_heap_if_needed(&mut state);
1041        if old_earliest.is_none_or(|old| deadline < old) {
1042            self.ready.notify_one();
1043        }
1044        Ok(())
1045    }
1046
1047    fn cancel(&self, timers: &Arc<Mutex<HashMap<u64, LocalTimerEntry>>>, timer_id: u64) {
1048        let key = (Arc::as_ptr(timers) as usize, timer_id);
1049        let mut state = self.lock_state();
1050        if let Some(seq) = state.timer_index.remove(&key) {
1051            state.entries.remove(&seq);
1052            Self::compact_heap_if_needed(&mut state);
1053        }
1054    }
1055
1056    fn cancel_registry(&self, timers: &Arc<Mutex<HashMap<u64, LocalTimerEntry>>>) {
1057        let registry = Arc::as_ptr(timers) as usize;
1058        let mut state = self.lock_state();
1059        let keys = state
1060            .timer_index
1061            .keys()
1062            .filter(|(candidate, _)| *candidate == registry)
1063            .copied()
1064            .collect::<Vec<_>>();
1065        for key in keys {
1066            if let Some(seq) = state.timer_index.remove(&key) {
1067                state.entries.remove(&seq);
1068            }
1069        }
1070        Self::compact_heap_if_needed(&mut state);
1071    }
1072
1073    async fn run(&self) {
1074        loop {
1075            // Create the notification future before reading the heap so a
1076            // concurrently inserted earlier deadline cannot be lost.
1077            let notified = self.ready.notified();
1078            let next_deadline = self
1079                .lock_state()
1080                .heap
1081                .peek()
1082                .map(|Reverse((deadline, _))| *deadline);
1083            match next_deadline {
1084                Some(deadline) if deadline > Instant::now() => {
1085                    tokio::select! {
1086                        _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {}
1087                        _ = notified => continue,
1088                    }
1089                }
1090                Some(_) => {}
1091                None => {
1092                    notified.await;
1093                    continue;
1094                }
1095            }
1096
1097            let due = {
1098                let mut state = self.lock_state();
1099                let now = Instant::now();
1100                let mut due = Vec::with_capacity(MAX_TIMER_ACTIONS_PER_TURN);
1101                while let Some(Reverse((deadline, seq))) = state.heap.peek().copied() {
1102                    if deadline > now || due.len() >= MAX_TIMER_ACTIONS_PER_TURN {
1103                        break;
1104                    }
1105                    state.heap.pop();
1106                    if let Some(scheduled) = state.entries.remove(&seq) {
1107                        let timer_key = scheduled.action.timer_key();
1108                        if state.timer_index.get(&timer_key) == Some(&seq) {
1109                            state.timer_index.remove(&timer_key);
1110                        }
1111                        due.push(scheduled.action);
1112                    }
1113                }
1114                due
1115            };
1116
1117            for action in due {
1118                if catch_unwind(AssertUnwindSafe(|| action.execute())).is_err() {
1119                    tracing::warn!("JavaScript timer wheel action panicked");
1120                }
1121            }
1122            tokio::task::yield_now().await;
1123        }
1124    }
1125
1126    fn compact_heap_if_needed(state: &mut TimerWheelState) {
1127        let compact_above = state.entries.len().saturating_mul(2).saturating_add(1_024);
1128        if state.heap.len() <= compact_above {
1129            return;
1130        }
1131        state.heap = state
1132            .entries
1133            .iter()
1134            .map(|(&seq, scheduled)| Reverse((scheduled.deadline, seq)))
1135            .collect();
1136    }
1137
1138    fn lock_state(&self) -> std::sync::MutexGuard<'_, TimerWheelState> {
1139        match self.state.lock() {
1140            Ok(state) => state,
1141            Err(poisoned) => poisoned.into_inner(),
1142        }
1143    }
1144}
1145
1146impl GuestPathTranslator {
1147    fn from_host_context(
1148        env: &BTreeMap<String, String>,
1149        host_cwd: PathBuf,
1150        guest_cwd: String,
1151    ) -> Self {
1152        let mut mappings = parse_guest_path_mappings_from_env(env)
1153            .into_iter()
1154            .filter(|mapping| mapping.guest_path.starts_with('/'))
1155            .collect::<Vec<_>>();
1156
1157        if !mappings
1158            .iter()
1159            .any(|mapping| mapping.guest_path == guest_cwd && mapping.host_path == host_cwd)
1160        {
1161            mappings.push(GuestPathMapping {
1162                guest_path: guest_cwd.clone(),
1163                host_path: host_cwd.clone(),
1164            });
1165        }
1166
1167        sort_guest_path_mappings(&mut mappings);
1168
1169        Self {
1170            implicit_guest_cwd: guest_cwd,
1171            implicit_host_cwd: host_cwd,
1172            sandbox_root: env
1173                .get(NODE_SANDBOX_ROOT_ENV)
1174                .filter(|value| Path::new(value.as_str()).is_absolute())
1175                .map(PathBuf::from),
1176            mappings,
1177        }
1178    }
1179
1180    fn is_known_host_path(&self, host_path: &Path) -> bool {
1181        if host_path.starts_with(&self.implicit_host_cwd) {
1182            return true;
1183        }
1184
1185        if let Some(sandbox_root) = &self.sandbox_root {
1186            if host_path.starts_with(sandbox_root) {
1187                return true;
1188            }
1189        }
1190
1191        self.mappings.iter().any(|mapping| {
1192            host_path.starts_with(&mapping.host_path)
1193                || fs::canonicalize(&mapping.host_path)
1194                    .map(|real_path| host_path.starts_with(real_path))
1195                    .unwrap_or(false)
1196        })
1197    }
1198
1199    fn from_request(request: &StartJavascriptExecutionRequest) -> Self {
1200        let implicit_guest_cwd = request
1201            .env
1202            .get("PWD")
1203            .filter(|value| value.starts_with('/'))
1204            .cloned()
1205            .or_else(|| {
1206                request
1207                    .env
1208                    .get("HOME")
1209                    .filter(|value| value.starts_with('/'))
1210                    .cloned()
1211            })
1212            .unwrap_or_else(|| String::from("/root"));
1213        let mut translator = Self::from_host_context(
1214            &request.env,
1215            request.cwd.clone(),
1216            implicit_guest_cwd.clone(),
1217        );
1218        translator.mappings.sort_by(|left, right| {
1219            let left_is_implicit =
1220                left.guest_path == implicit_guest_cwd && left.host_path == request.cwd;
1221            let right_is_implicit =
1222                right.guest_path == implicit_guest_cwd && right.host_path == request.cwd;
1223            right
1224                .guest_path
1225                .len()
1226                .cmp(&left.guest_path.len())
1227                .then_with(|| right_is_implicit.cmp(&left_is_implicit))
1228                .then_with(|| {
1229                    right
1230                        .host_path
1231                        .components()
1232                        .count()
1233                        .cmp(&left.host_path.components().count())
1234                })
1235        });
1236        translator
1237    }
1238
1239    fn guest_cwd(&self) -> &str {
1240        &self.implicit_guest_cwd
1241    }
1242
1243    fn resolve_host_entrypoint(&self, cwd: &Path, entrypoint: &str) -> PathBuf {
1244        if entrypoint == "-e" || entrypoint == "--eval" {
1245            return PathBuf::from(entrypoint);
1246        }
1247
1248        let path = Path::new(entrypoint);
1249        if path.is_absolute() {
1250            if self.is_known_host_path(path) {
1251                return path.to_path_buf();
1252            }
1253            self.guest_to_host(entrypoint)
1254                .unwrap_or_else(|| path.to_path_buf())
1255        } else {
1256            cwd.join(path)
1257        }
1258    }
1259
1260    fn host_to_guest_string(&self, host_path: &Path) -> String {
1261        if !host_path.is_absolute() {
1262            return normalize_guest_path(&host_path.to_string_lossy());
1263        }
1264
1265        for mapping in &self.mappings {
1266            if let Ok(stripped) = host_path.strip_prefix(&mapping.host_path) {
1267                return join_guest_path(
1268                    &mapping.guest_path,
1269                    &stripped.to_string_lossy().replace('\\', "/"),
1270                );
1271            }
1272            if let Ok(real_mapping_path) = fs::canonicalize(&mapping.host_path) {
1273                if let Ok(stripped) = host_path.strip_prefix(&real_mapping_path) {
1274                    return join_guest_path(
1275                        &mapping.guest_path,
1276                        &stripped.to_string_lossy().replace('\\', "/"),
1277                    );
1278                }
1279            }
1280        }
1281
1282        if let Ok(stripped) = host_path.strip_prefix(&self.implicit_host_cwd) {
1283            return join_guest_path(
1284                &self.implicit_guest_cwd,
1285                &stripped.to_string_lossy().replace('\\', "/"),
1286            );
1287        }
1288
1289        if let Some(sandbox_root) = &self.sandbox_root {
1290            if let Ok(stripped) = host_path.strip_prefix(sandbox_root) {
1291                return join_guest_path("/", &stripped.to_string_lossy().replace('\\', "/"));
1292            }
1293        }
1294
1295        let basename = host_path
1296            .file_name()
1297            .and_then(|value| value.to_str())
1298            .unwrap_or("unknown");
1299        join_guest_path("/unknown", basename)
1300    }
1301
1302    fn guest_to_host(&self, guest_path: &str) -> Option<PathBuf> {
1303        let normalized = normalize_guest_path(guest_path);
1304        let mut fallback_candidate = None;
1305
1306        for mapping in &self.mappings {
1307            if let Some(suffix) = strip_guest_prefix(&normalized, &mapping.guest_path) {
1308                let candidate = join_host_path(&mapping.host_path, suffix);
1309                if candidate.exists() {
1310                    return self.confine_host_path(candidate);
1311                }
1312                if let Ok(real_mapping_path) = fs::canonicalize(&mapping.host_path) {
1313                    let real_candidate = join_host_path(&real_mapping_path, suffix);
1314                    if real_candidate.exists() {
1315                        return self.confine_host_path(real_candidate);
1316                    }
1317                    if let Some(sibling_candidate) =
1318                        resolve_pnpm_sibling_host_path(&real_mapping_path, suffix)
1319                    {
1320                        return self.confine_host_path(sibling_candidate);
1321                    }
1322                }
1323                fallback_candidate.get_or_insert(candidate);
1324            }
1325        }
1326        if let Some(suffix) = strip_guest_prefix(&normalized, &self.implicit_guest_cwd) {
1327            return self.confine_host_path(join_host_path(&self.implicit_host_cwd, suffix));
1328        }
1329
1330        if let Some(candidate) = fallback_candidate {
1331            return self.confine_host_path(candidate);
1332        }
1333
1334        if let Some(sandbox_root) = &self.sandbox_root {
1335            return self.confine_host_path(join_host_path(
1336                sandbox_root,
1337                normalized.trim_start_matches('/'),
1338            ));
1339        }
1340
1341        None
1342    }
1343
1344    fn confine_host_path(&self, host_path: PathBuf) -> Option<PathBuf> {
1345        let allowed_roots = self.allowed_canonical_host_roots();
1346        if allowed_roots.is_empty() {
1347            return None;
1348        }
1349
1350        if let Ok(canonical_path) = fs::canonicalize(&host_path) {
1351            return canonical_path_is_allowed(&canonical_path, &allowed_roots).then_some(host_path);
1352        }
1353
1354        let existing_ancestor = nearest_existing_host_ancestor(&host_path)?;
1355        let canonical_ancestor = fs::canonicalize(existing_ancestor).ok()?;
1356        canonical_path_is_allowed(&canonical_ancestor, &allowed_roots).then_some(host_path)
1357    }
1358
1359    fn allowed_canonical_host_roots(&self) -> Vec<PathBuf> {
1360        let mut roots = Vec::new();
1361        for root in self
1362            .mappings
1363            .iter()
1364            .map(|mapping| mapping.host_path.as_path())
1365            .chain(std::iter::once(self.implicit_host_cwd.as_path()))
1366            .chain(self.sandbox_root.as_deref())
1367        {
1368            if let Ok(canonical_root) = fs::canonicalize(root) {
1369                if !roots.iter().any(|existing| existing == &canonical_root) {
1370                    roots.push(canonical_root);
1371                }
1372            }
1373        }
1374        roots
1375    }
1376
1377    fn canonical_guest_path(&self, guest_path: &str) -> Option<String> {
1378        let host_path = self.guest_to_host(guest_path)?;
1379        let canonical = fs::canonicalize(host_path).ok()?;
1380        for mapping in &self.mappings {
1381            if strip_guest_prefix(guest_path, &mapping.guest_path).is_none() {
1382                continue;
1383            }
1384            if let Ok(stripped) = canonical.strip_prefix(&mapping.host_path) {
1385                return Some(join_guest_path(
1386                    &mapping.guest_path,
1387                    &stripped.to_string_lossy().replace('\\', "/"),
1388                ));
1389            }
1390            if let Ok(real_mapping_path) = fs::canonicalize(&mapping.host_path) {
1391                if let Ok(stripped) = canonical.strip_prefix(&real_mapping_path) {
1392                    return Some(join_guest_path(
1393                        &mapping.guest_path,
1394                        &stripped.to_string_lossy().replace('\\', "/"),
1395                    ));
1396                }
1397            }
1398        }
1399        if let Some(node_modules_root) = self
1400            .mappings
1401            .iter()
1402            .find(|mapping| mapping.guest_path == "/root/node_modules")
1403        {
1404            if let Ok(stripped) = canonical.strip_prefix(&node_modules_root.host_path) {
1405                return Some(join_guest_path(
1406                    &node_modules_root.guest_path,
1407                    &stripped.to_string_lossy().replace('\\', "/"),
1408                ));
1409            }
1410            if let Ok(real_root) = fs::canonicalize(&node_modules_root.host_path) {
1411                if let Ok(stripped) = canonical.strip_prefix(&real_root) {
1412                    return Some(join_guest_path(
1413                        &node_modules_root.guest_path,
1414                        &stripped.to_string_lossy().replace('\\', "/"),
1415                    ));
1416                }
1417            }
1418        }
1419        let guest = self.host_to_guest_string(&canonical);
1420        (!guest.starts_with("/unknown/")).then_some(normalize_guest_path(&guest))
1421    }
1422}
1423
1424fn sort_guest_path_mappings(mappings: &mut [GuestPathMapping]) {
1425    mappings.sort_by(|left, right| {
1426        right
1427            .guest_path
1428            .len()
1429            .cmp(&left.guest_path.len())
1430            .then_with(|| {
1431                right
1432                    .host_path
1433                    .components()
1434                    .count()
1435                    .cmp(&left.host_path.components().count())
1436            })
1437    });
1438}
1439
1440fn canonical_path_is_allowed(path: &Path, allowed_roots: &[PathBuf]) -> bool {
1441    allowed_roots
1442        .iter()
1443        .any(|root| path == root || path.starts_with(root))
1444}
1445
1446fn nearest_existing_host_ancestor(path: &Path) -> Option<&Path> {
1447    let mut candidate = Some(path);
1448    while let Some(current) = candidate {
1449        if fs::symlink_metadata(current).is_ok() {
1450            return Some(current);
1451        }
1452        candidate = current.parent();
1453    }
1454    None
1455}
1456
1457#[doc(hidden)]
1458pub struct ModuleResolutionTestHarness {
1459    local_bridge: LocalBridgeState,
1460}
1461
1462impl ModuleResolutionTestHarness {
1463    pub fn new(host_root: impl Into<PathBuf>) -> Self {
1464        let host_root = host_root.into();
1465        let mut mappings = vec![
1466            GuestPathMapping {
1467                guest_path: String::from("/root/node_modules"),
1468                host_path: host_root.join("node_modules"),
1469            },
1470            GuestPathMapping {
1471                guest_path: String::from("/root"),
1472                host_path: host_root.clone(),
1473            },
1474        ];
1475        sort_guest_path_mappings(&mut mappings);
1476
1477        // Build via default + in-place assignment rather than `..default()`:
1478        // LocalBridgeState implements Drop (to cancel timers on session teardown),
1479        // and functional-record-update would move fields out of a Drop type (E0509).
1480        let mut local_bridge = LocalBridgeState::default();
1481        local_bridge.translator = GuestPathTranslator {
1482            implicit_guest_cwd: String::from("/root"),
1483            implicit_host_cwd: host_root,
1484            sandbox_root: None,
1485            mappings,
1486        };
1487        Self { local_bridge }
1488    }
1489
1490    pub fn resolve_import(&mut self, specifier: &str, from_path: &str) -> Option<String> {
1491        self.local_bridge
1492            .resolve_module(specifier, from_path, ModuleResolveMode::Import)
1493    }
1494
1495    pub fn resolve_require(&mut self, specifier: &str, from_path: &str) -> Option<String> {
1496        self.local_bridge
1497            .resolve_module(specifier, from_path, ModuleResolveMode::Require)
1498    }
1499
1500    pub fn module_format(&mut self, path: &str) -> Option<&'static str> {
1501        self.local_bridge
1502            .module_format(path)
1503            .map(LocalResolvedModuleFormat::as_str)
1504    }
1505}
1506
1507#[doc(hidden)]
1508pub fn handle_internal_bridge_call_from_host_context(
1509    host_cwd: &Path,
1510    guest_cwd: &str,
1511    env: &BTreeMap<String, String>,
1512    method: &str,
1513    args: &[Value],
1514) -> Option<Value> {
1515    // default + in-place assign: LocalBridgeState is Drop, so `..default()` (E0509)
1516    // is not allowed.
1517    let mut local_bridge = LocalBridgeState::default();
1518    local_bridge.translator =
1519        GuestPathTranslator::from_host_context(env, host_cwd.to_path_buf(), guest_cwd.to_owned());
1520
1521    match local_bridge.handle_internal_bridge_call(0, method, args) {
1522        Some(LocalBridgeCallResult::Immediate(value)) => Some(value),
1523        _ => None,
1524    }
1525}
1526
1527fn resolve_pnpm_sibling_host_path(real_mapping_path: &Path, suffix: &str) -> Option<PathBuf> {
1528    let trimmed = suffix.strip_prefix("node_modules/")?;
1529    let mut current = Some(real_mapping_path);
1530    while let Some(path) = current {
1531        if path.file_name().and_then(|name| name.to_str()) == Some("node_modules") {
1532            let candidate = join_host_path(path, trimmed);
1533            if candidate.exists() {
1534                return Some(candidate);
1535            }
1536            break;
1537        }
1538        current = path.parent();
1539    }
1540    None
1541}
1542
1543fn parse_guest_path_mappings(request: &StartJavascriptExecutionRequest) -> Vec<GuestPathMapping> {
1544    parse_guest_path_mappings_from_env(&request.env)
1545}
1546
1547fn parse_guest_path_mappings_from_env(env: &BTreeMap<String, String>) -> Vec<GuestPathMapping> {
1548    env.get(NODE_GUEST_PATH_MAPPINGS_ENV)
1549        .and_then(|value| serde_json::from_str::<Vec<GuestPathMappingWire>>(value).ok())
1550        .into_iter()
1551        .flatten()
1552        .map(|mapping| GuestPathMapping {
1553            guest_path: normalize_guest_path(&mapping.guest_path),
1554            host_path: PathBuf::from(mapping.host_path),
1555        })
1556        .collect()
1557}
1558
1559fn normalize_guest_path(path: &str) -> String {
1560    let mut segments = Vec::new();
1561    let absolute = path.starts_with('/');
1562    for segment in path.split('/') {
1563        match segment {
1564            "" | "." => {}
1565            ".." => {
1566                segments.pop();
1567            }
1568            other => segments.push(other),
1569        }
1570    }
1571    if !absolute {
1572        return segments.join("/");
1573    }
1574    if segments.is_empty() {
1575        String::from("/")
1576    } else {
1577        format!("/{}", segments.join("/"))
1578    }
1579}
1580
1581fn join_guest_path(base: &str, suffix: &str) -> String {
1582    if suffix.is_empty() || suffix == "." {
1583        return normalize_guest_path(base);
1584    }
1585    let trimmed = suffix.trim_start_matches('/');
1586    normalize_guest_path(&format!("{}/{}", base.trim_end_matches('/'), trimmed))
1587}
1588
1589fn strip_guest_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
1590    if prefix == "/" {
1591        return path.strip_prefix('/');
1592    }
1593    if path == prefix {
1594        return Some("");
1595    }
1596    path.strip_prefix(prefix)
1597        .and_then(|suffix| suffix.strip_prefix('/'))
1598}
1599
1600fn join_host_path(base: &Path, suffix: &str) -> PathBuf {
1601    if suffix.is_empty() {
1602        return base.to_path_buf();
1603    }
1604    let mut joined = base.to_path_buf();
1605    for segment in suffix.split('/') {
1606        if segment.is_empty() || segment == "." {
1607            continue;
1608        }
1609        if segment == ".." {
1610            joined.pop();
1611        } else {
1612            joined.push(segment);
1613        }
1614    }
1615    joined
1616}
1617
1618fn translate_v8_bridge_value_to_legacy(value: &Value) -> Value {
1619    match value {
1620        Value::Array(values) => Value::Array(
1621            values
1622                .iter()
1623                .map(translate_v8_bridge_value_to_legacy)
1624                .collect(),
1625        ),
1626        Value::Object(map) if map.get("__type").and_then(Value::as_str) == Some("Buffer") => {
1627            json!({
1628                "__agentOSType": "bytes",
1629                "base64": map.get("data").cloned().unwrap_or(Value::String(String::new())),
1630            })
1631        }
1632        Value::Object(map) => Value::Object(
1633            map.iter()
1634                .map(|(key, value)| (key.clone(), translate_v8_bridge_value_to_legacy(value)))
1635                .collect(),
1636        ),
1637        other => other.clone(),
1638    }
1639}
1640
1641fn translate_request_args_for_legacy(method: &str, args: &[Value]) -> Vec<Value> {
1642    let mut translated = args
1643        .iter()
1644        .map(translate_v8_bridge_value_to_legacy)
1645        .collect::<Vec<_>>();
1646
1647    if matches!(method, "fs.writeFileSync" | "fs.promises.writeFile") {
1648        if let Some(Value::String(data)) = translated.get(1) {
1649            translated[1] = json!({
1650                "__agentOSType": "bytes",
1651                "base64": v8_runtime::base64_encode_pub(data.as_bytes()),
1652            });
1653        }
1654    }
1655
1656    translated
1657}
1658
1659fn translate_legacy_bridge_value_to_v8(value: &Value) -> Value {
1660    match value {
1661        Value::Array(values) => Value::Array(
1662            values
1663                .iter()
1664                .map(translate_legacy_bridge_value_to_v8)
1665                .collect(),
1666        ),
1667        Value::Object(map) if map.get("__agentOSType").and_then(Value::as_str) == Some("bytes") => {
1668            json!({
1669                "__type": "Buffer",
1670                "data": map.get("base64").cloned().unwrap_or(Value::String(String::new())),
1671            })
1672        }
1673        Value::Object(map) => Value::Object(
1674            map.iter()
1675                .map(|(key, value)| (key.clone(), translate_legacy_bridge_value_to_v8(value)))
1676                .collect(),
1677        ),
1678        other => other.clone(),
1679    }
1680}
1681
1682fn decode_bridge_output_arg(value: &Value) -> Vec<u8> {
1683    match value {
1684        Value::String(s) => s.as_bytes().to_vec(),
1685        Value::Object(map)
1686            if map.get("__type").and_then(Value::as_str) == Some("Buffer")
1687                || map.get("__agentOSType").and_then(Value::as_str) == Some("bytes") =>
1688        {
1689            let base64_value = map
1690                .get("data")
1691                .or_else(|| map.get("base64"))
1692                .and_then(Value::as_str);
1693            if let Some(base64_value) = base64_value {
1694                if let Some(bytes) = v8_runtime::base64_decode_pub(base64_value) {
1695                    return bytes;
1696                }
1697            }
1698            value.to_string().into_bytes()
1699        }
1700        other => other.to_string().into_bytes(),
1701    }
1702}
1703
1704fn decode_bridge_output_args(args: &[Value]) -> Vec<u8> {
1705    let mut output = Vec::new();
1706    for (index, arg) in args.iter().enumerate() {
1707        if index > 0 {
1708            output.push(b' ');
1709        }
1710        output.extend(decode_bridge_output_arg(arg));
1711    }
1712    output
1713}
1714
1715#[derive(Debug)]
1716pub enum JavascriptExecutionError {
1717    EmptyArgv,
1718    InvalidLimit(String),
1719    MissingContext(String),
1720    VmMismatch { expected: String, found: String },
1721    PrepareImportCache(std::io::Error),
1722    Spawn(std::io::Error),
1723    PendingSyncRpcRequest(u64),
1724    ExpiredSyncRpcRequest(u64),
1725    RpcResponse(String),
1726    Terminate(std::io::Error),
1727    Control(std::io::Error),
1728    StdinClosed,
1729    Stdin(std::io::Error),
1730    OutputBufferExceeded { stream: &'static str, limit: usize },
1731    EventChannelClosed,
1732}
1733
1734impl fmt::Display for JavascriptExecutionError {
1735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1736        match self {
1737            Self::EmptyArgv => f.write_str("guest JavaScript execution requires argv[0]"),
1738            Self::InvalidLimit(message) => write!(f, "invalid JavaScript limit: {message}"),
1739            Self::MissingContext(context_id) => {
1740                write!(f, "unknown guest JavaScript context: {context_id}")
1741            }
1742            Self::VmMismatch { expected, found } => {
1743                write!(
1744                    f,
1745                    "guest JavaScript context belongs to vm {expected}, not {found}"
1746                )
1747            }
1748            Self::PrepareImportCache(err) => {
1749                write!(
1750                    f,
1751                    "failed to prepare sidecar-scoped Node import cache: {err}"
1752                )
1753            }
1754            Self::Spawn(err) => write!(f, "failed to start guest JavaScript runtime: {err}"),
1755            Self::PendingSyncRpcRequest(id) => {
1756                write!(
1757                    f,
1758                    "guest JavaScript execution requires servicing pending sync RPC request {id}"
1759                )
1760            }
1761            Self::ExpiredSyncRpcRequest(id) => {
1762                write!(f, "sync RPC request {id} is no longer pending")
1763            }
1764            Self::RpcResponse(message) => {
1765                write!(
1766                    f,
1767                    "failed to reply to guest JavaScript sync RPC request: {message}"
1768                )
1769            }
1770            Self::Terminate(err) => {
1771                write!(f, "failed to terminate guest JavaScript runtime: {err}")
1772            }
1773            Self::Control(err) => write!(f, "failed to control guest JavaScript runtime: {err}"),
1774            Self::StdinClosed => f.write_str("guest JavaScript stdin is already closed"),
1775            Self::Stdin(err) => write!(f, "failed to write guest stdin: {err}"),
1776            Self::OutputBufferExceeded { stream, limit } => {
1777                write!(
1778                    f,
1779                    "guest JavaScript {stream} exceeded the captured output limit of {limit} bytes"
1780                )
1781            }
1782            Self::EventChannelClosed => {
1783                f.write_str("guest JavaScript event channel closed unexpectedly")
1784            }
1785        }
1786    }
1787}
1788
1789impl std::error::Error for JavascriptExecutionError {}
1790
1791#[derive(Debug)]
1792pub struct JavascriptExecution {
1793    execution_id: String,
1794    child_pid: u32,
1795    // One bounded mailbox supports both the async sidecar pump and standalone
1796    // blocking consumers. Using a runtime-specific receiver here previously
1797    // forced blocking compatibility paths through Handle::block_on, which
1798    // panicked whenever those paths were reached from the unified runtime.
1799    events: EventReceiver<JavascriptExecutionEvent>,
1800    pending_sync_rpc: Arc<Mutex<Option<PendingSyncRpcState>>>,
1801    exited: Arc<AtomicBool>,
1802    kernel_stdin: Arc<LocalKernelStdinBridge>,
1803    _import_cache_guard: Arc<NodeImportCacheCleanup>,
1804    v8_session: V8SessionHandle,
1805    /// Fully prepared V8 execute request. Cross-runtime execve prepares the
1806    /// replacement isolate and its bridge before committing kernel process
1807    /// state, but must not enqueue guest code until that commit is complete.
1808    prepared_execute: Option<PreparedJavascriptExecute>,
1809    _event_bridge_task: tokio::task::JoinHandle<()>,
1810    /// Host-direct module resolver state, used ONLY by the standalone `wait()`
1811    /// loop. The real VM runtime resolves modules against the kernel VFS on the
1812    /// sidecar service loop and never reaches this; but `wait()` runs without a
1813    /// kernel (dev/test harness), so it services module-resolution sync RPCs
1814    /// host-directly from the request's path translator.
1815    module_resolution: Mutex<(GuestPathTranslator, LocalModuleResolutionCache)>,
1816}
1817
1818#[derive(Debug)]
1819struct PreparedJavascriptExecute {
1820    mode: u8,
1821    file_path: String,
1822    bridge_code: String,
1823    post_restore_script: String,
1824    userland_code: String,
1825    high_resolution_time: bool,
1826    user_code: String,
1827    wasm_module_bytes: Option<Arc<Vec<u8>>>,
1828}
1829
1830impl JavascriptExecution {
1831    pub fn execution_id(&self) -> &str {
1832        &self.execution_id
1833    }
1834
1835    pub fn child_pid(&self) -> u32 {
1836        self.child_pid
1837    }
1838
1839    pub fn v8_session_handle(&self) -> V8SessionHandle {
1840        self.v8_session.clone()
1841    }
1842
1843    pub fn uses_shared_v8_runtime(&self) -> bool {
1844        true
1845    }
1846
1847    pub fn has_exited(&self) -> bool {
1848        self.exited.load(Ordering::Acquire)
1849    }
1850
1851    /// Enqueue a replacement image that was fully prepared without running
1852    /// guest code. This is the final step of an atomic cross-runtime execve and
1853    /// must only be called after the kernel and sidecar process state commit.
1854    pub fn start_prepared(&mut self) -> Result<(), JavascriptExecutionError> {
1855        let prepared = self.prepared_execute.take().ok_or_else(|| {
1856            JavascriptExecutionError::Spawn(std::io::Error::new(
1857                std::io::ErrorKind::InvalidInput,
1858                "JavaScript execution is not awaiting a prepared start",
1859            ))
1860        })?;
1861        self.v8_session
1862            .execute(
1863                prepared.mode,
1864                prepared.file_path,
1865                prepared.bridge_code,
1866                prepared.post_restore_script,
1867                prepared.userland_code,
1868                prepared.high_resolution_time,
1869                prepared.user_code,
1870                prepared.wasm_module_bytes,
1871            )
1872            .map_err(JavascriptExecutionError::Spawn)
1873    }
1874
1875    #[doc(hidden)]
1876    pub fn is_prepared_for_start(&self) -> bool {
1877        self.prepared_execute.is_some()
1878    }
1879
1880    pub fn write_stdin(&mut self, chunk: &[u8]) -> Result<(), JavascriptExecutionError> {
1881        self.kernel_stdin.write(chunk)?;
1882        let payload = v8_runtime::json_to_cbor_payload(&json!({
1883            "dataBase64": v8_runtime::base64_encode_pub(chunk),
1884        }))
1885        .map_err(JavascriptExecutionError::Stdin)?;
1886        self.v8_session
1887            .send_stream_event("stdin", payload)
1888            .map_err(JavascriptExecutionError::Stdin)
1889    }
1890
1891    pub fn close_stdin(&mut self) -> Result<(), JavascriptExecutionError> {
1892        self.kernel_stdin.close();
1893        self.v8_session
1894            .send_stream_event("stdin_end", vec![])
1895            .map_err(JavascriptExecutionError::Stdin)
1896    }
1897
1898    pub(crate) fn write_kernel_stdin_only(
1899        &mut self,
1900        chunk: &[u8],
1901    ) -> Result<(), JavascriptExecutionError> {
1902        self.kernel_stdin.write(chunk)
1903    }
1904
1905    pub(crate) fn close_kernel_stdin_only(&mut self) {
1906        self.kernel_stdin.close();
1907    }
1908
1909    pub fn read_kernel_stdin_sync_rpc(
1910        &self,
1911        request: &JavascriptSyncRpcRequest,
1912    ) -> Result<Value, JavascriptExecutionError> {
1913        if request.method != "__kernel_stdin_read" {
1914            return Ok(Value::Null);
1915        }
1916
1917        Ok(self.kernel_stdin.read(&request.args))
1918    }
1919
1920    pub(crate) fn handle_kernel_stdin_sync_rpc(
1921        &mut self,
1922        request: &JavascriptSyncRpcRequest,
1923    ) -> Result<bool, JavascriptExecutionError> {
1924        if request.method != "__kernel_stdin_read" {
1925            return Ok(false);
1926        }
1927
1928        let response = self.kernel_stdin.read(&request.args);
1929        self.respond_sync_rpc_success(request.id, response)?;
1930        Ok(true)
1931    }
1932
1933    pub fn terminate(&self) -> Result<(), JavascriptExecutionError> {
1934        // Completion may race an idempotent child-process cleanup kill. Once
1935        // the terminal frame is published, preserve that result and avoid
1936        // enqueueing TerminateExecution into an already-completed V8 session.
1937        if self.has_exited() {
1938            return Ok(());
1939        }
1940        self.v8_session
1941            .terminate()
1942            .map_err(JavascriptExecutionError::Terminate)
1943    }
1944
1945    pub fn pause(&self) -> Result<(), JavascriptExecutionError> {
1946        self.v8_session
1947            .pause()
1948            .map_err(JavascriptExecutionError::Control)
1949    }
1950
1951    pub fn resume(&self) -> Result<(), JavascriptExecutionError> {
1952        self.v8_session
1953            .resume()
1954            .map_err(JavascriptExecutionError::Control)
1955    }
1956
1957    pub fn send_stream_event(
1958        &self,
1959        event_type: &str,
1960        payload: Value,
1961    ) -> Result<(), JavascriptExecutionError> {
1962        let payload = v8_runtime::json_to_cbor_payload(&payload)
1963            .map_err(|error| JavascriptExecutionError::RpcResponse(error.to_string()))?;
1964        self.v8_session
1965            .send_stream_event(event_type, payload)
1966            .map_err(|error| JavascriptExecutionError::RpcResponse(error.to_string()))
1967    }
1968
1969    pub fn respond_sync_rpc_success(
1970        &mut self,
1971        id: u64,
1972        result: Value,
1973    ) -> Result<(), JavascriptExecutionError> {
1974        let phase_start = Instant::now();
1975        match self.clear_pending_sync_rpc(id)? {
1976            PendingSyncRpcResolution::Pending => {}
1977            PendingSyncRpcResolution::TimedOut => {
1978                return Err(JavascriptExecutionError::ExpiredSyncRpcRequest(id));
1979            }
1980            PendingSyncRpcResolution::Missing => {}
1981        }
1982        record_sync_bridge_phase(
1983            "sync_rpc_response",
1984            "response_clear_pending",
1985            phase_start.elapsed(),
1986        );
1987
1988        self.respond_claimed_sync_rpc_success(id, result)
1989    }
1990
1991    /// Atomically claim the exact pending sync RPC before a caller performs a
1992    /// destructive operation on its behalf. A timed-out or replaced request
1993    /// must not consume bytes that belong to the guest's next retry.
1994    pub fn claim_sync_rpc_response(&mut self, id: u64) -> Result<bool, JavascriptExecutionError> {
1995        match self.clear_pending_sync_rpc(id)? {
1996            PendingSyncRpcResolution::Pending => Ok(true),
1997            PendingSyncRpcResolution::TimedOut | PendingSyncRpcResolution::Missing => Ok(false),
1998        }
1999    }
2000
2001    pub fn respond_claimed_sync_rpc_success(
2002        &mut self,
2003        id: u64,
2004        result: Value,
2005    ) -> Result<(), JavascriptExecutionError> {
2006        let phase_start = Instant::now();
2007        let payload = translate_legacy_bridge_value_to_v8(&result);
2008        record_sync_bridge_phase(
2009            "sync_rpc_response",
2010            "response_translate_value",
2011            phase_start.elapsed(),
2012        );
2013        let phase_start = Instant::now();
2014        let payload = v8_runtime::json_to_cbor_payload(&payload)
2015            .map_err(|e| JavascriptExecutionError::RpcResponse(e.to_string()))?;
2016        record_sync_bridge_phase(
2017            "sync_rpc_response",
2018            "response_encode_cbor",
2019            phase_start.elapsed(),
2020        );
2021        let phase_start = Instant::now();
2022        let result = self
2023            .v8_session
2024            .send_bridge_response(id, 0, payload)
2025            .map_err(|e| JavascriptExecutionError::RpcResponse(e.to_string()));
2026        record_sync_bridge_phase("sync_rpc_response", "response_send", phase_start.elapsed());
2027        result
2028    }
2029
2030    pub fn respond_sync_rpc_raw_success(
2031        &mut self,
2032        id: u64,
2033        payload: Vec<u8>,
2034    ) -> Result<(), JavascriptExecutionError> {
2035        let phase_start = Instant::now();
2036        match self.clear_pending_sync_rpc(id)? {
2037            PendingSyncRpcResolution::Pending => {}
2038            PendingSyncRpcResolution::TimedOut => {
2039                return Err(JavascriptExecutionError::ExpiredSyncRpcRequest(id));
2040            }
2041            PendingSyncRpcResolution::Missing => {}
2042        }
2043        record_sync_bridge_phase(
2044            "sync_rpc_raw_response",
2045            "response_clear_pending",
2046            phase_start.elapsed(),
2047        );
2048
2049        let phase_start = Instant::now();
2050        let result = self
2051            .v8_session
2052            .send_bridge_response(id, 2, payload)
2053            .map_err(|e| JavascriptExecutionError::RpcResponse(e.to_string()));
2054        record_sync_bridge_phase(
2055            "sync_rpc_raw_response",
2056            "response_send",
2057            phase_start.elapsed(),
2058        );
2059        result
2060    }
2061
2062    pub fn respond_sync_rpc_error(
2063        &mut self,
2064        id: u64,
2065        code: impl Into<String>,
2066        message: impl Into<String>,
2067    ) -> Result<(), JavascriptExecutionError> {
2068        match self.clear_pending_sync_rpc(id)? {
2069            PendingSyncRpcResolution::Pending => {}
2070            PendingSyncRpcResolution::TimedOut => {
2071                return Err(JavascriptExecutionError::ExpiredSyncRpcRequest(id));
2072            }
2073            PendingSyncRpcResolution::Missing => {}
2074        }
2075
2076        self.respond_claimed_sync_rpc_error(id, code, message)
2077    }
2078
2079    pub fn respond_claimed_sync_rpc_error(
2080        &mut self,
2081        id: u64,
2082        code: impl Into<String>,
2083        message: impl Into<String>,
2084    ) -> Result<(), JavascriptExecutionError> {
2085        let error_msg = format!("{}: {}", code.into(), message.into());
2086        self.v8_session
2087            .send_bridge_response(id, 1, error_msg.into_bytes())
2088            .map_err(|e| JavascriptExecutionError::RpcResponse(e.to_string()))
2089    }
2090
2091    pub async fn poll_event(
2092        &self,
2093        timeout: Duration,
2094    ) -> Result<Option<JavascriptExecutionEvent>, JavascriptExecutionError> {
2095        self.poll_event_until(Some(timeout)).await
2096    }
2097
2098    /// Probe the durable event queue without registering or discarding a
2099    /// waker. The sidecar calls this after the execution engine has notified
2100    /// its coalesced process-event broker.
2101    pub fn try_poll_event(
2102        &self,
2103    ) -> Result<Option<JavascriptExecutionEvent>, JavascriptExecutionError> {
2104        match self.events.try_recv() {
2105            Ok(event) => Ok(Some(event)),
2106            Err(flume::TryRecvError::Empty) => Ok(None),
2107            Err(flume::TryRecvError::Disconnected) => {
2108                Err(JavascriptExecutionError::EventChannelClosed)
2109            }
2110        }
2111    }
2112
2113    /// Wait for one event until an optional operation deadline. `None` is a
2114    /// true readiness wait; it does not install a recurring adapter timer.
2115    pub async fn poll_event_until(
2116        &self,
2117        timeout: Option<Duration>,
2118    ) -> Result<Option<JavascriptExecutionEvent>, JavascriptExecutionError> {
2119        if timeout.is_some_and(|timeout| timeout.is_zero()) {
2120            return match self.events.try_recv() {
2121                Ok(event) => Ok(Some(event)),
2122                Err(flume::TryRecvError::Empty) => Ok(None),
2123                Err(flume::TryRecvError::Disconnected) => {
2124                    Err(JavascriptExecutionError::EventChannelClosed)
2125                }
2126            };
2127        }
2128
2129        match timeout {
2130            Some(timeout) => match time::timeout(timeout, self.events.recv_async()).await {
2131                Ok(Ok(event)) => Ok(Some(event)),
2132                Ok(Err(_closed)) => Err(JavascriptExecutionError::EventChannelClosed),
2133                Err(_) => Ok(None),
2134            },
2135            None => self
2136                .events
2137                .recv_async()
2138                .await
2139                .map(Some)
2140                .map_err(|_| JavascriptExecutionError::EventChannelClosed),
2141        }
2142    }
2143
2144    pub fn poll_event_blocking(
2145        &self,
2146        timeout: Duration,
2147    ) -> Result<Option<JavascriptExecutionEvent>, JavascriptExecutionError> {
2148        match self.events.recv_timeout(timeout) {
2149            Ok(event) => Ok(Some(event)),
2150            Err(flume::RecvTimeoutError::Timeout) => Ok(None),
2151            Err(flume::RecvTimeoutError::Disconnected) => {
2152                Err(JavascriptExecutionError::EventChannelClosed)
2153            }
2154        }
2155    }
2156
2157    /// Block until the next execution event without a recurring timeout poll.
2158    /// Adapters that have no deadline use this path so an idle guest consumes
2159    /// no scheduler turns while it waits for readiness or completion.
2160    pub(crate) fn next_event_blocking(
2161        &self,
2162    ) -> Result<JavascriptExecutionEvent, JavascriptExecutionError> {
2163        self.events
2164            .recv()
2165            .map_err(|_| JavascriptExecutionError::EventChannelClosed)
2166    }
2167
2168    pub fn wait(mut self) -> Result<JavascriptExecutionResult, JavascriptExecutionError> {
2169        self.close_stdin()?;
2170        let execution_id = std::mem::take(&mut self.execution_id);
2171
2172        let mut stdout = Vec::new();
2173        let mut stderr = Vec::new();
2174
2175        loop {
2176            match self.events.recv() {
2177                Ok(JavascriptExecutionEvent::Stdout(chunk)) => {
2178                    append_captured_output(&mut stdout, chunk, "stdout")?;
2179                }
2180                Ok(JavascriptExecutionEvent::Stderr(chunk)) => {
2181                    append_captured_output(&mut stderr, chunk, "stderr")?;
2182                }
2183                Ok(JavascriptExecutionEvent::SyncRpcRequest(request)) => {
2184                    // The standalone engine has no kernel/service loop. Service
2185                    // module-resolution RPCs host-directly (the only FS source
2186                    // available here) so `wait()` does not deadlock; everything
2187                    // else is unsupported off the VM path.
2188                    if self.try_service_standalone_module_sync_rpc(&request)? {
2189                        continue;
2190                    }
2191                    return Err(JavascriptExecutionError::PendingSyncRpcRequest(request.id));
2192                }
2193                Ok(JavascriptExecutionEvent::SignalState { .. }) => {}
2194                Ok(JavascriptExecutionEvent::Exited(exit_code)) => {
2195                    // Join the V8 executor while this method still owns the
2196                    // event receiver. That keeps terminal diagnostics
2197                    // drainable; waiting for Drop would close this local
2198                    // receiver first during return-value teardown.
2199                    self.v8_session
2200                        .destroy()
2201                        .map_err(JavascriptExecutionError::Terminate)?;
2202                    return Ok(JavascriptExecutionResult {
2203                        execution_id,
2204                        exit_code,
2205                        stdout,
2206                        stderr,
2207                    });
2208                }
2209                Err(_closed) => return Err(JavascriptExecutionError::EventChannelClosed),
2210            }
2211        }
2212    }
2213
2214    /// Service a module-resolution sync RPC host-directly, for consumers that
2215    /// drive the V8 bridge without a kernel/service loop (the standalone
2216    /// `wait()` loop and the Python/WASM prewarm loops). Uses this execution's
2217    /// own path translator (captured at start, including any runtime path
2218    /// mappings) and a persistent cache. Returns `Ok(true)` if the request was a
2219    /// module method and was answered, `Ok(false)` if it should fall through.
2220    ///
2221    /// The real VM runtime resolves modules against the kernel VFS on the
2222    /// sidecar service loop and never calls this.
2223    pub fn try_service_standalone_module_sync_rpc(
2224        &mut self,
2225        request: &JavascriptSyncRpcRequest,
2226    ) -> Result<bool, JavascriptExecutionError> {
2227        let result = {
2228            let mut guard = self.module_resolution.lock().map_err(|_| {
2229                JavascriptExecutionError::RpcResponse(String::from(
2230                    "standalone module resolution state poisoned",
2231                ))
2232            })?;
2233            let (translator, cache) = &mut *guard;
2234            let mut resolver = ModuleResolver::new(translator, cache);
2235            match request.method.as_str() {
2236                "__resolve_module" | "_resolveModule" | "_resolveModuleSync" => {
2237                    let specifier = request.args.first().and_then(Value::as_str).unwrap_or("");
2238                    let parent = request.args.get(1).and_then(Value::as_str).unwrap_or("/");
2239                    let mode = match request.args.get(2).and_then(Value::as_str) {
2240                        Some("import") => ModuleResolveMode::Import,
2241                        Some("require") => ModuleResolveMode::Require,
2242                        _ if request.method == "_resolveModuleSync" => ModuleResolveMode::Require,
2243                        _ => ModuleResolveMode::Import,
2244                    };
2245                    resolver
2246                        .resolve_module(specifier, parent, mode)
2247                        .map(Value::String)
2248                        .unwrap_or(Value::Null)
2249                }
2250                "__load_file" | "_loadFile" | "_loadFileSync" => resolver
2251                    .load_file(request.args.first().and_then(Value::as_str).unwrap_or(""))
2252                    .map(Value::String)
2253                    .unwrap_or(Value::Null),
2254                "__module_format" | "_moduleFormat" => resolver
2255                    .module_format(request.args.first().and_then(Value::as_str).unwrap_or(""))
2256                    .map(|format| Value::String(String::from(format.as_str())))
2257                    .unwrap_or(Value::Null),
2258                "__batch_resolve_modules" | "_batchResolveModules" => {
2259                    resolver.batch_resolve_modules(&request.args)
2260                }
2261                _ => return Ok(false),
2262            }
2263        };
2264        self.respond_sync_rpc_success(request.id, result)?;
2265        Ok(true)
2266    }
2267
2268    fn clear_pending_sync_rpc(
2269        &self,
2270        id: u64,
2271    ) -> Result<PendingSyncRpcResolution, JavascriptExecutionError> {
2272        let mut pending = self.pending_sync_rpc.lock().map_err(|_| {
2273            JavascriptExecutionError::RpcResponse(String::from(
2274                "sync RPC pending-request state lock poisoned",
2275            ))
2276        })?;
2277        match *pending {
2278            Some(PendingSyncRpcState::Pending(current)) if current == id => {
2279                *pending = None;
2280                Ok(PendingSyncRpcResolution::Pending)
2281            }
2282            Some(PendingSyncRpcState::TimedOut(current)) if current == id => {
2283                Ok(PendingSyncRpcResolution::TimedOut)
2284            }
2285            _ => Ok(PendingSyncRpcResolution::Missing),
2286        }
2287    }
2288}
2289
2290impl Drop for JavascriptExecution {
2291    fn drop(&mut self) {
2292        // Closing the V8 producer lets the bridge task drain any terminal
2293        // warning/result and then finish when its per-session lane closes.
2294        // Aborting the task first would drop the lane while the session thread
2295        // was still completing teardown.
2296        let _ = self.v8_session.destroy();
2297    }
2298}
2299
2300fn append_captured_output(
2301    target: &mut Vec<u8>,
2302    chunk: Vec<u8>,
2303    stream: &'static str,
2304) -> Result<(), JavascriptExecutionError> {
2305    let next_len = target.len().checked_add(chunk.len()).ok_or(
2306        JavascriptExecutionError::OutputBufferExceeded {
2307            stream,
2308            limit: JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES,
2309        },
2310    )?;
2311    if next_len > JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES {
2312        return Err(JavascriptExecutionError::OutputBufferExceeded {
2313            stream,
2314            limit: JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES,
2315        });
2316    }
2317
2318    target.extend(chunk);
2319    Ok(())
2320}
2321
2322struct V8SessionRegistrationGuard<'a> {
2323    v8_host: &'a V8RuntimeHost,
2324    session_id: String,
2325    active: bool,
2326}
2327
2328impl<'a> V8SessionRegistrationGuard<'a> {
2329    fn new(v8_host: &'a V8RuntimeHost, session_id: String) -> Self {
2330        Self {
2331            v8_host,
2332            session_id,
2333            active: true,
2334        }
2335    }
2336
2337    fn disarm(&mut self) {
2338        self.active = false;
2339    }
2340}
2341
2342impl Drop for V8SessionRegistrationGuard<'_> {
2343    fn drop(&mut self) {
2344        if self.active {
2345            self.v8_host.unregister_session(&self.session_id);
2346        }
2347    }
2348}
2349
2350struct PendingV8SessionRegistration<'a> {
2351    frame_receiver: V8SessionFrameReceiver,
2352    registration_guard: V8SessionRegistrationGuard<'a>,
2353}
2354
2355#[allow(clippy::too_many_arguments)] // one session's identity, limits, hint, and creation hook
2356fn register_v8_session<'a, F>(
2357    v8_host: &'a V8RuntimeHost,
2358    runtime: &RuntimeContext,
2359    session_id: String,
2360    heap_limit_mb: u32,
2361    cpu_time_limit_ms: u32,
2362    wall_clock_limit_ms: u32,
2363    warm_hint: Option<WarmSessionHint>,
2364    create_session: F,
2365) -> Result<PendingV8SessionRegistration<'a>, JavascriptExecutionError>
2366where
2367    F: FnOnce(RuntimeCommand) -> std::io::Result<()>,
2368{
2369    let frame_receiver = v8_host
2370        .register_session(&session_id, runtime)
2371        .map_err(JavascriptExecutionError::Spawn)?;
2372    let registration_guard = V8SessionRegistrationGuard::new(v8_host, session_id.clone());
2373
2374    create_session(RuntimeCommand::CreateSession {
2375        session_id,
2376        heap_limit_mb: (heap_limit_mb > 0).then_some(heap_limit_mb),
2377        cpu_time_limit_ms: (cpu_time_limit_ms > 0).then_some(cpu_time_limit_ms),
2378        wall_clock_limit_ms: (wall_clock_limit_ms > 0).then_some(wall_clock_limit_ms),
2379        warm_hint,
2380    })
2381    .map_err(JavascriptExecutionError::Spawn)?;
2382
2383    Ok(PendingV8SessionRegistration {
2384        frame_receiver,
2385        registration_guard,
2386    })
2387}
2388
2389pub struct JavascriptExecutionEngine {
2390    runtime: Option<RuntimeContext>,
2391    next_context_id: usize,
2392    next_execution_id: usize,
2393    contexts: BTreeMap<String, JavascriptContext>,
2394    import_caches: BTreeMap<String, NodeImportCache>,
2395    v8_host: Option<V8RuntimeHost>,
2396    event_notify: Option<Arc<Notify>>,
2397}
2398
2399impl Default for JavascriptExecutionEngine {
2400    fn default() -> Self {
2401        Self {
2402            runtime: default_test_runtime_context(),
2403            next_context_id: 0,
2404            next_execution_id: 0,
2405            contexts: BTreeMap::new(),
2406            import_caches: BTreeMap::new(),
2407            v8_host: None,
2408            event_notify: None,
2409        }
2410    }
2411}
2412
2413impl std::fmt::Debug for JavascriptExecutionEngine {
2414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2415        f.debug_struct("JavascriptExecutionEngine")
2416            .field("next_context_id", &self.next_context_id)
2417            .field("next_execution_id", &self.next_execution_id)
2418            .field("contexts", &self.contexts)
2419            .field("v8_host", &self.v8_host.is_some())
2420            .finish()
2421    }
2422}
2423
2424impl JavascriptExecutionEngine {
2425    pub fn new(runtime: RuntimeContext) -> Self {
2426        Self {
2427            runtime: Some(runtime),
2428            ..Self::default()
2429        }
2430    }
2431
2432    /// Bind this engine to the process-owned runtime before starting work.
2433    /// This setter exists for embedders that previously constructed via
2434    /// `Default`; new code should prefer [`Self::new`].
2435    pub fn set_runtime_context(&mut self, runtime: RuntimeContext) {
2436        self.runtime = Some(runtime);
2437    }
2438
2439    pub(crate) fn runtime_context(&self) -> Result<&RuntimeContext, JavascriptExecutionError> {
2440        self.runtime.as_ref().ok_or_else(|| {
2441            JavascriptExecutionError::Spawn(std::io::Error::other(
2442                "ERR_AGENTOS_RUNTIME_NOT_INJECTED: JavascriptExecutionEngine requires a process RuntimeContext; construct it with JavascriptExecutionEngine::new(runtime)",
2443            ))
2444        })
2445    }
2446
2447    #[doc(hidden)]
2448    pub fn set_event_notify(&mut self, notify: Option<Arc<Notify>>) {
2449        self.event_notify = notify;
2450    }
2451
2452    #[doc(hidden)]
2453    pub fn set_import_cache_base_dir(&mut self, vm_id: impl Into<String>, base_dir: PathBuf) {
2454        self.import_caches
2455            .insert(vm_id.into(), NodeImportCache::new_in(base_dir));
2456    }
2457
2458    pub fn create_context(&mut self, request: CreateJavascriptContextRequest) -> JavascriptContext {
2459        self.next_context_id += 1;
2460        self.import_caches.entry(request.vm_id.clone()).or_default();
2461
2462        let context = JavascriptContext {
2463            context_id: format!("js-ctx-{}", self.next_context_id),
2464            vm_id: request.vm_id,
2465            bootstrap_module: request.bootstrap_module,
2466            compile_cache_dir: request
2467                .compile_cache_root
2468                .map(resolve_node_import_compile_cache_dir),
2469        };
2470        self.contexts
2471            .insert(context.context_id.clone(), context.clone());
2472        context
2473    }
2474
2475    /// Dispose an execution context once its final start/prepare operation has
2476    /// consumed the metadata. Live executions own their resolved runtime state
2477    /// independently and do not consult this registry after creation.
2478    pub fn dispose_context(&mut self, context_id: &str) -> bool {
2479        self.contexts.remove(context_id).is_some()
2480    }
2481
2482    #[doc(hidden)]
2483    pub fn context_count_for_test(&self) -> usize {
2484        self.contexts.len()
2485    }
2486
2487    pub fn start_execution(
2488        &mut self,
2489        request: StartJavascriptExecutionRequest,
2490    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2491        let runtime = self.runtime_context()?.clone();
2492        self.start_execution_with_runtime(request, runtime)
2493    }
2494
2495    pub fn start_execution_with_runtime(
2496        &mut self,
2497        request: StartJavascriptExecutionRequest,
2498        runtime: RuntimeContext,
2499    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2500        self.create_execution_with_module_reader_and_runtime(request, None, None, runtime, false)
2501    }
2502
2503    pub fn prepare_execution(
2504        &mut self,
2505        request: StartJavascriptExecutionRequest,
2506    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2507        let runtime = self.runtime_context()?.clone();
2508        self.prepare_execution_with_runtime(request, runtime)
2509    }
2510
2511    /// Prepare an execution with an explicitly scoped runtime without enqueueing
2512    /// guest code. Cross-runtime exec uses this to bind the replacement isolate
2513    /// to the target VM's accounting and reactor state before committing execve.
2514    pub fn prepare_execution_with_runtime(
2515        &mut self,
2516        request: StartJavascriptExecutionRequest,
2517        runtime: RuntimeContext,
2518    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2519        self.create_execution_with_module_reader_and_runtime(request, None, None, runtime, true)
2520    }
2521
2522    fn ensure_v8_host(&mut self) -> Result<(), JavascriptExecutionError> {
2523        let should_spawn_v8_host = match self.v8_host.as_mut() {
2524            Some(v8_host) => !v8_host
2525                .is_alive()
2526                .map_err(JavascriptExecutionError::Spawn)?,
2527            None => true,
2528        };
2529        if should_spawn_v8_host {
2530            let runtime = self.runtime_context()?.clone();
2531            self.v8_host =
2532                Some(V8RuntimeHost::spawn(&runtime).map_err(JavascriptExecutionError::Spawn)?);
2533        }
2534        Ok(())
2535    }
2536
2537    pub(crate) fn snapshot_userland_ready(
2538        &mut self,
2539        userland_code: &str,
2540    ) -> Result<bool, JavascriptExecutionError> {
2541        self.ensure_v8_host()?;
2542        Ok(self
2543            .v8_host
2544            .as_ref()
2545            .expect("V8 host initialized")
2546            .snapshot_ready(userland_code))
2547    }
2548
2549    pub(crate) fn pre_warm_snapshot(
2550        &mut self,
2551        userland_code: &str,
2552    ) -> Result<(), JavascriptExecutionError> {
2553        self.ensure_v8_host()?;
2554        self.v8_host
2555            .as_ref()
2556            .expect("V8 host initialized")
2557            .pre_warm_snapshot(userland_code)
2558            .map_err(JavascriptExecutionError::Spawn)
2559    }
2560
2561    pub(crate) fn pre_warm_workers(
2562        &mut self,
2563        userland_code: &str,
2564        heap_limit_mb: u32,
2565        count: usize,
2566    ) -> Result<(), JavascriptExecutionError> {
2567        self.ensure_v8_host()?;
2568        self.v8_host
2569            .as_ref()
2570            .expect("V8 host initialized")
2571            .pre_warm_workers(userland_code, heap_limit_mb, count);
2572        Ok(())
2573    }
2574
2575    /// Like [`start_execution`](Self::start_execution) but with an optional
2576    /// read-only VFS reader over the mounted `node_modules` tree. When supplied,
2577    /// the bridge thread resolves module-resolution RPCs inline against this
2578    /// reader (off the service loop, concurrently with it) instead of routing
2579    /// them through the service loop. The reader must be `Send` because it is
2580    /// moved onto the bridge thread; it must read the same mount the guest sees.
2581    pub fn start_execution_with_module_reader(
2582        &mut self,
2583        request: StartJavascriptExecutionRequest,
2584        module_reader: Option<Box<dyn ModuleFsReader + Send>>,
2585        guest_reader: Option<Box<dyn agentos_v8_runtime::execution::GuestModuleReader>>,
2586    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2587        let runtime = self.runtime_context()?.clone();
2588        self.create_execution_with_module_reader_and_runtime(
2589            request,
2590            module_reader,
2591            guest_reader,
2592            runtime,
2593            false,
2594        )
2595    }
2596
2597    /// Prepare an execution through every fallible image-loading step without
2598    /// enqueueing guest code in V8. Used by execve when the replacement runtime
2599    /// differs from the current runtime.
2600    pub fn prepare_execution_with_module_reader(
2601        &mut self,
2602        request: StartJavascriptExecutionRequest,
2603        module_reader: Option<Box<dyn ModuleFsReader + Send>>,
2604        guest_reader: Option<Box<dyn agentos_v8_runtime::execution::GuestModuleReader>>,
2605    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2606        let runtime = self.runtime_context()?.clone();
2607        self.create_execution_with_module_reader_and_runtime(
2608            request,
2609            module_reader,
2610            guest_reader,
2611            runtime,
2612            true,
2613        )
2614    }
2615
2616    pub fn start_execution_with_module_reader_and_runtime(
2617        &mut self,
2618        request: StartJavascriptExecutionRequest,
2619        module_reader: Option<Box<dyn ModuleFsReader + Send>>,
2620        guest_reader: Option<Box<dyn agentos_v8_runtime::execution::GuestModuleReader>>,
2621        runtime: RuntimeContext,
2622    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2623        self.create_execution_with_module_reader_and_runtime(
2624            request,
2625            module_reader,
2626            guest_reader,
2627            runtime,
2628            false,
2629        )
2630    }
2631
2632    pub fn prepare_execution_with_module_reader_and_runtime(
2633        &mut self,
2634        request: StartJavascriptExecutionRequest,
2635        module_reader: Option<Box<dyn ModuleFsReader + Send>>,
2636        guest_reader: Option<Box<dyn agentos_v8_runtime::execution::GuestModuleReader>>,
2637        runtime: RuntimeContext,
2638    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2639        self.create_execution_with_module_reader_and_runtime(
2640            request,
2641            module_reader,
2642            guest_reader,
2643            runtime,
2644            true,
2645        )
2646    }
2647
2648    fn create_execution_with_module_reader_and_runtime(
2649        &mut self,
2650        request: StartJavascriptExecutionRequest,
2651        module_reader: Option<Box<dyn ModuleFsReader + Send>>,
2652        guest_reader: Option<Box<dyn agentos_v8_runtime::execution::GuestModuleReader>>,
2653        runtime: RuntimeContext,
2654        defer_execute: bool,
2655    ) -> Result<JavascriptExecution, JavascriptExecutionError> {
2656        let process_runtime = self.runtime_context()?.clone();
2657        let context = self
2658            .contexts
2659            .get(&request.context_id)
2660            .cloned()
2661            .ok_or_else(|| JavascriptExecutionError::MissingContext(request.context_id.clone()))?;
2662
2663        if context.vm_id != request.vm_id {
2664            return Err(JavascriptExecutionError::VmMismatch {
2665                expected: context.vm_id,
2666                found: request.vm_id,
2667            });
2668        }
2669
2670        if request.argv.is_empty() {
2671            return Err(JavascriptExecutionError::EmptyArgv);
2672        }
2673        let reactor_work_quantum = javascript_reactor_work_quantum(&request, &runtime)?;
2674        let bridge_call_timeout = javascript_bridge_call_timeout(&request, &runtime)?;
2675
2676        let phase_start = Instant::now();
2677        // Ensure import cache is materialized (still needed for module resolution)
2678        let import_cache = self.import_caches.entry(context.vm_id.clone()).or_default();
2679        import_cache
2680            .ensure_materialized_with_timeout_and_runtime(
2681                &process_runtime,
2682                javascript_import_cache_materialize_timeout(&request),
2683            )
2684            .map_err(JavascriptExecutionError::PrepareImportCache)?;
2685        let import_cache_guard = import_cache.cleanup_guard();
2686        record_js_start_phase("js_start_import_cache", phase_start.elapsed());
2687
2688        self.next_execution_id += 1;
2689        let execution_id = format!("exec-{}", self.next_execution_id);
2690
2691        let phase_start = Instant::now();
2692        self.ensure_v8_host()?;
2693        let v8_host = self.v8_host.as_ref().unwrap();
2694        record_js_start_phase("js_start_v8_host_ready", phase_start.elapsed());
2695
2696        let phase_start = Instant::now();
2697        // Create a V8 session
2698        let session_id = format!(
2699            "v8-{execution_id}-{}",
2700            NEXT_V8_SESSION_ID.fetch_add(1, Ordering::Relaxed)
2701        );
2702        let heap_limit_mb = javascript_heap_limit_mb(&request);
2703        let cpu_time_limit_ms = javascript_cpu_time_limit_ms(&request);
2704        let wall_clock_limit_ms = javascript_wall_clock_limit_ms(&request);
2705        let snapshot_userland_code = request
2706            .guest_runtime
2707            .snapshot_userland_code
2708            .clone()
2709            .unwrap_or_default();
2710        let warm_hint = Some(WarmSessionHint {
2711            bridge_code: V8RuntimeHost::bridge_code().to_owned(),
2712            userland_code: snapshot_userland_code.clone(),
2713            heap_limit_mb: (heap_limit_mb > 0).then_some(heap_limit_mb),
2714        });
2715        if snapshot_userland_code.is_empty() && heap_limit_mb == 0 {
2716            v8_host.seed_default_warm_workers_async();
2717        }
2718        let PendingV8SessionRegistration {
2719            frame_receiver,
2720            mut registration_guard,
2721        } = register_v8_session(
2722            v8_host,
2723            &runtime,
2724            session_id.clone(),
2725            heap_limit_mb,
2726            cpu_time_limit_ms,
2727            wall_clock_limit_ms,
2728            warm_hint,
2729            |command| {
2730                v8_host.create_session_from_command_with_runtime(
2731                    command,
2732                    &runtime,
2733                    reactor_work_quantum,
2734                    bridge_call_timeout,
2735                )
2736            },
2737        )?;
2738        record_js_start_phase("js_start_v8_session_register", phase_start.elapsed());
2739
2740        let phase_start = Instant::now();
2741        // Build user code: prefer inline code, fall back to entrypoint-based
2742        let translator = GuestPathTranslator::from_request(&request);
2743        let host_entrypoint = translator.resolve_host_entrypoint(&request.cwd, &request.argv[0]);
2744        let guest_entrypoint = if request.argv[0] == "-e" || request.argv[0] == "--eval" {
2745            request.argv[0].clone()
2746        } else if let Some(explicit_guest_entrypoint) = request
2747            .env
2748            .get(NODE_GUEST_ENTRYPOINT_ENV)
2749            .filter(|value| value.starts_with('/'))
2750        {
2751            // Part B (guest-VFS adapter launch): the sidecar already resolved the
2752            // GUEST entrypoint path (AGENTOS_GUEST_ENTRYPOINT). Use it directly as
2753            // the sourceURL / module-resolution base instead of translating the
2754            // host entrypoint — `host_to_guest_string` misses for guest-native
2755            // mounts (`agentos_packages`, whose host staging dir is not in the
2756            // translation map) and falls back to `/unknown/<cmd>`, which then
2757            // poisons the adapter's own relative/bare imports. For host-backed
2758            // mounts the two values are equal, so this is a no-op there. Applies
2759            // to child launches too (they set AGENTOS_GUEST_ENTRYPOINT as well),
2760            // which is the child-process `/unknown` case.
2761            explicit_guest_entrypoint.clone()
2762        } else {
2763            translator.host_to_guest_string(&host_entrypoint)
2764        };
2765        let process_argv = if matches!(guest_entrypoint.as_str(), "-e" | "--eval") {
2766            std::iter::once(String::from("node"))
2767                .chain(request.argv.iter().skip(1).cloned())
2768                .collect::<Vec<_>>()
2769        } else {
2770            std::iter::once(String::from("node"))
2771                .chain(std::iter::once(guest_entrypoint.clone()))
2772                .chain(request.argv.iter().skip(1).cloned())
2773                .collect::<Vec<_>>()
2774        };
2775        // Node resolves relative imports from `node -e` against process.cwd().
2776        // Keep the guest-visible argv entry as `-e`, but give V8 an absolute
2777        // synthetic resource name so its dynamic-import callback has the same
2778        // resolution base instead of trying to resolve from the literal `-e`.
2779        let execution_file_path = if matches!(guest_entrypoint.as_str(), "-e" | "--eval") {
2780            let cwd = translator.guest_cwd().trim_end_matches('/');
2781            if cwd.is_empty() {
2782                String::from("/[eval]")
2783            } else {
2784                format!("{cwd}/[eval]")
2785            }
2786        } else {
2787            guest_entrypoint.clone()
2788        };
2789        let inline_code = request
2790            .inline_code
2791            .clone()
2792            .map(|inline_code| strip_javascript_hashbang(&inline_code));
2793        let use_module_mode = request
2794            .env
2795            .get(NODE_GUEST_ENTRYPOINT_MODULE_MODE_ENV)
2796            .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
2797            || host_entrypoint_uses_module_mode(&host_entrypoint)
2798            || inline_code
2799                .as_deref()
2800                .is_some_and(inline_code_uses_module_mode);
2801        if !matches!(guest_entrypoint.as_str(), "-e" | "--eval") && !use_module_mode {
2802            if let Some(inline_code) = inline_code.as_ref() {
2803                if let Some(parent) = host_entrypoint.parent() {
2804                    fs::create_dir_all(parent)
2805                        .map_err(JavascriptExecutionError::PrepareImportCache)?;
2806                }
2807                fs::write(&host_entrypoint, inline_code)
2808                    .map_err(JavascriptExecutionError::PrepareImportCache)?;
2809            }
2810        }
2811        let user_code = if matches!(guest_entrypoint.as_str(), "-e" | "--eval") {
2812            inline_code.unwrap_or_else(|| build_v8_user_code(&guest_entrypoint, &request.env))
2813        } else if use_module_mode {
2814            if let Some(inline_code) = inline_code {
2815                format!("{inline_code}\n//# sourceURL={guest_entrypoint}")
2816            } else {
2817                strip_javascript_hashbang(&fs::read_to_string(&host_entrypoint).map_err(
2818                    |error| {
2819                        JavascriptExecutionError::PrepareImportCache(std::io::Error::new(
2820                            error.kind(),
2821                            format!(
2822                                "failed to read JavaScript entrypoint {}: {error}",
2823                                host_entrypoint.display()
2824                            ),
2825                        ))
2826                    },
2827                )?)
2828            }
2829        } else {
2830            build_v8_user_code(&guest_entrypoint, &request.env)
2831        };
2832        let user_code = prepend_v8_runtime_shim(
2833            user_code,
2834            &guest_entrypoint,
2835            &process_argv,
2836            request.argv0.as_deref(),
2837            translator.guest_cwd(),
2838            &request.env,
2839            heap_limit_mb,
2840            &request.guest_runtime,
2841        );
2842        record_js_start_phase("js_start_build_user_code", phase_start.elapsed());
2843
2844        let phase_start = Instant::now();
2845        // Create session handle for sending bridge responses
2846        let v8_session = v8_host.session_handle(session_id.clone());
2847
2848        // Start the event bridge before execution so early sync bridge calls
2849        // made during module instantiation/evaluation cannot deadlock waiting
2850        // for a response while no host thread is draining session frames yet.
2851        let pending_sync_rpc = Arc::new(Mutex::new(None));
2852        let exited = Arc::new(AtomicBool::new(false));
2853        let kernel_stdin = Arc::new(LocalKernelStdinBridge::default());
2854        let standalone_translator = translator.clone();
2855        // default + in-place assign: LocalBridgeState is Drop, so `..Default::default()`
2856        // (E0509) is not allowed.
2857        let mut local_bridge = LocalBridgeState::default();
2858        local_bridge.runtime = Some(process_runtime);
2859        local_bridge.timer_resources = Some(Arc::clone(runtime.resources()));
2860        local_bridge.max_timers = javascript_max_timers(&request);
2861        local_bridge.translator = translator;
2862        local_bridge.kernel_stdin = kernel_stdin.clone();
2863        local_bridge.v8_session = Some(v8_session.clone());
2864        local_bridge.module_reader = module_reader;
2865        local_bridge.module_resolution = GuestModuleResolution::from_env(&request.env);
2866        local_bridge.forward_kernel_stdin_rpc = request
2867            .env
2868            .get(FORWARD_KERNEL_STDIN_RPC_ENV)
2869            .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
2870        let (events, event_bridge_task) = spawn_v8_event_bridge(
2871            &runtime,
2872            frame_receiver,
2873            pending_sync_rpc.clone(),
2874            exited.clone(),
2875            v8_session.clone(),
2876            local_bridge,
2877            self.event_notify.clone(),
2878        )?;
2879        record_js_start_phase("js_start_event_bridge", phase_start.elapsed());
2880
2881        let phase_start = Instant::now();
2882        // Install the direct module reader on the session thread BEFORE the Execute
2883        // frame so the SetModuleReader command (routed through the same dispatch
2884        // queue) arrives first; module loads then read source directly on the V8
2885        // thread instead of round-tripping the bridge.
2886        if let Some(guest_reader) = guest_reader {
2887            v8_session
2888                .set_module_reader(guest_reader)
2889                .map_err(JavascriptExecutionError::Spawn)?;
2890        }
2891        record_js_start_phase("js_start_install_module_reader", phase_start.elapsed());
2892
2893        let phase_start = Instant::now();
2894        let prepared_execute = PreparedJavascriptExecute {
2895            mode: if use_module_mode { 1 } else { 0 },
2896            file_path: execution_file_path,
2897            bridge_code: V8RuntimeHost::bridge_code().to_owned(),
2898            post_restore_script: String::new(),
2899            userland_code: snapshot_userland_code,
2900            high_resolution_time: request.guest_runtime.high_resolution_time,
2901            user_code,
2902            wasm_module_bytes: request.wasm_module_bytes.clone(),
2903        };
2904        let prepared_execute = if defer_execute {
2905            Some(prepared_execute)
2906        } else {
2907            v8_session
2908                .execute(
2909                    prepared_execute.mode,
2910                    prepared_execute.file_path,
2911                    prepared_execute.bridge_code,
2912                    prepared_execute.post_restore_script,
2913                    prepared_execute.userland_code,
2914                    prepared_execute.high_resolution_time,
2915                    prepared_execute.user_code,
2916                    prepared_execute.wasm_module_bytes,
2917                )
2918                .map_err(JavascriptExecutionError::Spawn)?;
2919            None
2920        };
2921        registration_guard.disarm();
2922        record_js_start_phase("js_start_send_execute", phase_start.elapsed());
2923
2924        Ok(JavascriptExecution {
2925            execution_id,
2926            child_pid: v8_host.child_pid(),
2927            events,
2928            pending_sync_rpc,
2929            exited,
2930            kernel_stdin,
2931            _import_cache_guard: import_cache_guard,
2932            v8_session,
2933            prepared_execute,
2934            _event_bridge_task: event_bridge_task,
2935            module_resolution: Mutex::new((
2936                standalone_translator,
2937                LocalModuleResolutionCache::default(),
2938            )),
2939        })
2940    }
2941
2942    pub fn dispose_vm(&mut self, vm_id: &str) {
2943        self.contexts.retain(|_, context| context.vm_id != vm_id);
2944        self.import_caches.remove(vm_id);
2945    }
2946
2947    #[doc(hidden)]
2948    #[allow(dead_code)]
2949    pub fn materialize_import_cache_for_vm(
2950        &mut self,
2951        vm_id: &str,
2952    ) -> Result<&std::path::Path, std::io::Error> {
2953        let runtime = self
2954            .runtime
2955            .as_ref()
2956            .ok_or_else(|| std::io::Error::other(
2957                "ERR_AGENTOS_RUNTIME_NOT_INJECTED: JavascriptExecutionEngine requires a process RuntimeContext",
2958            ))?;
2959        let import_cache = self.import_caches.entry(vm_id.to_owned()).or_default();
2960        import_cache.ensure_materialized_with_runtime(runtime)?;
2961        Ok(import_cache.cache_path())
2962    }
2963
2964    #[doc(hidden)]
2965    #[allow(dead_code)]
2966    pub fn import_cache_path_for_vm(&self, vm_id: &str) -> Option<&std::path::Path> {
2967        self.import_caches
2968            .get(vm_id)
2969            .map(NodeImportCache::cache_path)
2970    }
2971}
2972
2973fn set_pending_sync_rpc_state(
2974    pending_sync_rpc: &Arc<Mutex<Option<PendingSyncRpcState>>>,
2975    id: u64,
2976) -> Result<(), JavascriptExecutionError> {
2977    let mut pending = pending_sync_rpc.lock().map_err(|_| {
2978        JavascriptExecutionError::RpcResponse(String::from(
2979            "sync RPC pending-request state lock poisoned",
2980        ))
2981    })?;
2982    *pending = Some(PendingSyncRpcState::Pending(id));
2983    Ok(())
2984}
2985
2986fn resolve_node_import_compile_cache_dir(root_dir: PathBuf) -> PathBuf {
2987    root_dir.join(format!(
2988        "node-imports-v{NODE_IMPORT_COMPILE_CACHE_NAMESPACE_VERSION}-{:016x}",
2989        stable_compile_cache_namespace_hash()
2990    ))
2991}
2992
2993fn stable_compile_cache_namespace_hash() -> u64 {
2994    stable_hash64(
2995        [
2996            env!("CARGO_PKG_NAME"),
2997            env!("CARGO_PKG_VERSION"),
2998            NODE_ENTRYPOINT_ENV,
2999            NODE_BOOTSTRAP_ENV,
3000            NODE_GUEST_ARGV_ENV,
3001            NODE_PREWARM_IMPORTS_ENV,
3002            NODE_WARMUP_MARKER_VERSION,
3003        ]
3004        .into_iter()
3005        .chain(NODE_WARMUP_SPECIFIERS.iter().copied())
3006        .collect::<Vec<_>>()
3007        .join("\n")
3008        .as_bytes(),
3009    )
3010}
3011
3012fn javascript_sync_rpc_timeout(request: &StartJavascriptExecutionRequest) -> Duration {
3013    let timeout_ms = request
3014        .limits
3015        .sync_rpc_wait_timeout_ms
3016        .filter(|value| *value > 0)
3017        .unwrap_or(NODE_SYNC_RPC_DEFAULT_WAIT_TIMEOUT_MS);
3018    Duration::from_millis(timeout_ms)
3019}
3020
3021fn javascript_heap_limit_mb(request: &StartJavascriptExecutionRequest) -> u32 {
3022    request
3023        .limits
3024        .v8_heap_limit_mb
3025        .filter(|value| *value > 0)
3026        .unwrap_or(0)
3027}
3028
3029fn javascript_import_cache_materialize_timeout(
3030    request: &StartJavascriptExecutionRequest,
3031) -> Duration {
3032    let timeout_ms = request
3033        .limits
3034        .import_cache_materialize_timeout_ms
3035        .filter(|value| *value > 0)
3036        .unwrap_or(DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS);
3037    Duration::from_millis(timeout_ms)
3038}
3039
3040fn javascript_max_timers(request: &StartJavascriptExecutionRequest) -> usize {
3041    request
3042        .limits
3043        .max_timers
3044        .filter(|value| *value > 0)
3045        .unwrap_or(MAX_TIMERS_PER_EXECUTION)
3046}
3047
3048fn javascript_reactor_work_quantum(
3049    request: &StartJavascriptExecutionRequest,
3050    runtime: &RuntimeContext,
3051) -> Result<usize, JavascriptExecutionError> {
3052    match request.limits.reactor_work_quantum {
3053        Some(0) => Err(JavascriptExecutionError::InvalidLimit(String::from(
3054            "limits.reactor.workQuantum must be greater than zero",
3055        ))),
3056        Some(limit) => Ok(limit),
3057        None if runtime.vm_generation().is_some() => Err(JavascriptExecutionError::InvalidLimit(
3058            String::from("limits.reactor.workQuantum is required for VM-scoped execution"),
3059        )),
3060        None => runtime
3061            .resources()
3062            .usage(agentos_runtime::accounting::ResourceClass::ReadyHandles)
3063            .limit
3064            .ok_or_else(|| {
3065                JavascriptExecutionError::InvalidLimit(String::from(
3066                    "standalone runtime.resources.maxReadyHandles must be bounded",
3067                ))
3068            }),
3069    }
3070}
3071
3072fn javascript_bridge_call_timeout(
3073    request: &StartJavascriptExecutionRequest,
3074    runtime: &RuntimeContext,
3075) -> Result<Duration, JavascriptExecutionError> {
3076    match request.limits.bridge_call_timeout_ms {
3077        Some(0) => Err(JavascriptExecutionError::InvalidLimit(String::from(
3078            "limits.reactor.operationDeadlineMs must be greater than zero",
3079        ))),
3080        Some(timeout_ms) => Ok(Duration::from_millis(timeout_ms)),
3081        None if runtime.vm_generation().is_some() => Err(JavascriptExecutionError::InvalidLimit(
3082            String::from("limits.reactor.operationDeadlineMs is required for VM-scoped execution"),
3083        )),
3084        None => Ok(Duration::from_secs(30)),
3085    }
3086}
3087
3088/// Resolve the TRUE CPU-time budget (ms) for a JavaScript execution.
3089///
3090/// Read from typed `limits.jsRuntime.cpuTimeLimitMs`, falling back to a bounded
3091/// default when unset. `0` remains an explicit trusted opt-out and is normalized
3092/// to `None` by the V8 session.
3093fn javascript_cpu_time_limit_ms(request: &StartJavascriptExecutionRequest) -> u32 {
3094    request
3095        .limits
3096        .cpu_time_limit_ms
3097        // Generous active-CPU budget: long-lived adapters are still not capped
3098        // on wall-clock, but CPU-bound runaways no longer pin a core forever by
3099        // default.
3100        .unwrap_or(DEFAULT_V8_CPU_TIME_LIMIT_MS)
3101}
3102
3103/// Resolve the opt-in WALL-CLOCK backstop (ms) for a JavaScript execution.
3104///
3105/// Read from typed `limits.jsRuntime.wallClockLimitMs`, falling back to `0` (no
3106/// limit). `0` is normalized to `None` by the V8 session, so the wall-clock
3107/// `TimeoutGuard` is NOT armed and the guest runs without a wall-clock limit.
3108/// This is INDEPENDENT of the CPU-time budget: setting only one arms only that
3109/// guard.
3110fn javascript_wall_clock_limit_ms(request: &StartJavascriptExecutionRequest) -> u32 {
3111    request
3112        .limits
3113        .wall_clock_limit_ms
3114        .unwrap_or(DEFAULT_V8_WALL_CLOCK_LIMIT_MS)
3115}
3116
3117#[cfg(test)]
3118fn spawn_javascript_sync_rpc_timeout(
3119    id: u64,
3120    timeout: Duration,
3121    pending_state: Arc<Mutex<Option<PendingSyncRpcState>>>,
3122    responses: Option<JavascriptSyncRpcResponseWriter>,
3123) {
3124    let Some(responses) = responses else {
3125        return;
3126    };
3127
3128    let runtime = match agentos_runtime::SidecarRuntime::process(
3129        &agentos_runtime::RuntimeConfig::default(),
3130    ) {
3131        Ok(runtime) => runtime.context(),
3132        Err(error) => {
3133            eprintln!("ERR_AGENTOS_RUNTIME_UNAVAILABLE: could not arm JavaScript sync RPC timeout: {error}");
3134            return;
3135        }
3136    };
3137    if let Err(error) = runtime.spawn(agentos_runtime::TaskClass::Timer, async move {
3138        tokio::time::sleep(timeout).await;
3139
3140        let should_timeout = match pending_state.lock() {
3141            Ok(mut guard) if *guard == Some(PendingSyncRpcState::Pending(id)) => {
3142                *guard = Some(PendingSyncRpcState::TimedOut(id));
3143                true
3144            }
3145            Ok(_) => false,
3146            Err(_) => false,
3147        };
3148
3149        if !should_timeout {
3150            return;
3151        }
3152
3153        let _ = write_javascript_sync_rpc_response(
3154            &responses,
3155            json!({
3156                "id": id,
3157                "ok": false,
3158                "error": {
3159                    "code": "ERR_AGENTOS_NODE_SYNC_RPC_TIMEOUT",
3160                    "message": format!(
3161                        "guest JavaScript sync RPC request {id} timed out after {}ms",
3162                        timeout.as_millis()
3163                    ),
3164                },
3165            }),
3166        );
3167    }) {
3168        eprintln!("ERR_AGENTOS_TASK_LIMIT: could not arm JavaScript sync RPC timeout: {error}");
3169    }
3170}
3171
3172#[cfg(test)]
3173fn parse_javascript_sync_rpc_request(line: &str) -> Result<JavascriptSyncRpcRequest, String> {
3174    let wire: JavascriptSyncRpcRequestWire =
3175        serde_json::from_str(line).map_err(|error| error.to_string())?;
3176    Ok(JavascriptSyncRpcRequest {
3177        id: wire.id,
3178        method: wire.method,
3179        args: wire.args,
3180        raw_bytes_args: HashMap::new(),
3181    })
3182}
3183
3184#[cfg(test)]
3185fn write_javascript_sync_rpc_response(
3186    writer: &JavascriptSyncRpcResponseWriter,
3187    response: Value,
3188) -> Result<(), JavascriptExecutionError> {
3189    let mut payload = serde_json::to_vec(&response)
3190        .map_err(|error| JavascriptExecutionError::RpcResponse(error.to_string()))?;
3191    payload.push(b'\n');
3192    writer.send(payload)
3193}
3194
3195#[cfg(test)]
3196fn spawn_javascript_sync_rpc_response_writer(
3197    writer: File,
3198    receiver: Receiver<Vec<u8>>,
3199) -> thread::JoinHandle<()> {
3200    thread::spawn(move || {
3201        let mut writer = BufWriter::new(writer);
3202        while let Ok(payload) = receiver.recv() {
3203            if writer
3204                .write_all(&payload)
3205                .and_then(|()| writer.flush())
3206                .is_err()
3207            {
3208                return;
3209            }
3210        }
3211    })
3212}
3213
3214/// Build the user code wrapper for V8 execution.
3215/// This wraps the entrypoint in a way that the V8 bridge can execute it.
3216fn build_v8_user_code(entrypoint: &str, env: &BTreeMap<String, String>) -> String {
3217    // The bridge code (polyfills) sets up the module system and globals.
3218    // User code is executed after the bridge completes.
3219    // For file-based entrypoints, we load and execute them through the module system.
3220    // For inline code (-e flag), we execute directly.
3221    if entrypoint == "-e" || entrypoint == "--eval" {
3222        // Inline code from NODE_EVAL or similar
3223        env.get("AGENTOS_NODE_EVAL").cloned().unwrap_or_default()
3224    } else {
3225        // Module entrypoint - load it as Node's main CommonJS module so
3226        // require.main, module.parent, and process.mainModule are coherent.
3227        format!(
3228            "_moduleModule._load({}, null, true);\n//# sourceURL={}",
3229            serde_json::to_string(entrypoint).unwrap_or_else(|_| format!("\"{}\"", entrypoint)),
3230            entrypoint
3231        )
3232    }
3233}
3234
3235fn host_entrypoint_uses_module_mode(entrypoint: &Path) -> bool {
3236    // Agent adapters are launched via an extensionless `/opt/agentos/bin/<cmd>`
3237    // symlink into the packed package's `node_modules/<name>/<entry>`. Resolve it
3238    // to the real file so the extension check and the nearest-`package.json` walk
3239    // reflect the package (which carries `"type": "module"`), not the symlink farm
3240    // (which has neither an extension nor a package.json).
3241    let resolved = fs::canonicalize(entrypoint).unwrap_or_else(|_| entrypoint.to_path_buf());
3242    match resolved.extension().and_then(|ext| ext.to_str()) {
3243        Some("mjs" | "mts") => true,
3244        Some("js") => nearest_package_json_type(&resolved).as_deref() == Some("module"),
3245        _ => false,
3246    }
3247}
3248
3249fn inline_code_uses_module_mode(source: &str) -> bool {
3250    let sanitized = strip_non_code_segments(source);
3251    let tokens = tokenize_inline_module_source(&sanitized);
3252    let has_commonjs_signal = tokens.windows(3).any(|window| {
3253        matches!(
3254            window,
3255            [
3256                InlineModuleToken::Identifier("module"),
3257                InlineModuleToken::Punct('.'),
3258                InlineModuleToken::Identifier("exports")
3259            ]
3260        )
3261    }) || tokens.windows(2).any(|window| {
3262        matches!(
3263            window,
3264            [
3265                InlineModuleToken::Identifier("exports"),
3266                InlineModuleToken::Punct('.' | '[')
3267            ] | [
3268                InlineModuleToken::Identifier("require"),
3269                InlineModuleToken::Punct('(')
3270            ]
3271        )
3272    });
3273
3274    if has_commonjs_signal {
3275        return false;
3276    }
3277
3278    tokens.windows(2).any(|window| match window {
3279        [InlineModuleToken::Identifier("import"), InlineModuleToken::Punct('.')] => true,
3280        [InlineModuleToken::Identifier("import"), InlineModuleToken::Punct('(' | ':')] => false,
3281        [InlineModuleToken::Identifier("import"), InlineModuleToken::Identifier(_)
3282        | InlineModuleToken::Punct('{')
3283        | InlineModuleToken::Punct('*')
3284        | InlineModuleToken::StringLiteral] => true,
3285        [InlineModuleToken::Identifier("export"), InlineModuleToken::Identifier(
3286            "default" | "const" | "let" | "var" | "function" | "class" | "async" | "enum" | "type"
3287            | "interface",
3288        )
3289        | InlineModuleToken::Punct('{')
3290        | InlineModuleToken::Punct('*')] => true,
3291        _ => false,
3292    })
3293}
3294
3295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3296enum InlineModuleToken<'a> {
3297    Identifier(&'a str),
3298    StringLiteral,
3299    Punct(char),
3300}
3301
3302const INLINE_MODULE_STRING_PLACEHOLDER: char = '\u{1F}';
3303
3304fn strip_non_code_segments(source: &str) -> String {
3305    let mut sanitized = String::with_capacity(source.len());
3306    let bytes = source.as_bytes();
3307    let mut index = 0;
3308    sanitize_javascript_code(bytes, &mut index, &mut sanitized, None);
3309    sanitized
3310}
3311
3312fn sanitize_javascript_code(
3313    bytes: &[u8],
3314    index: &mut usize,
3315    output: &mut String,
3316    until_brace_depth: Option<usize>,
3317) {
3318    let mut brace_depth = 0usize;
3319
3320    while *index < bytes.len() {
3321        let current = bytes[*index];
3322
3323        if let Some(target_depth) = until_brace_depth {
3324            match current {
3325                b'{' => brace_depth += 1,
3326                b'}' => {
3327                    if brace_depth == target_depth {
3328                        output.push(' ');
3329                        *index += 1;
3330                        return;
3331                    }
3332                    brace_depth = brace_depth.saturating_sub(1);
3333                }
3334                _ => {}
3335            }
3336        }
3337
3338        match current {
3339            b'/' if bytes.get(*index + 1) == Some(&b'/') => {
3340                output.push(' ');
3341                output.push(' ');
3342                *index += 2;
3343                while *index < bytes.len() {
3344                    let comment_byte = bytes[*index];
3345                    *index += 1;
3346                    if comment_byte == b'\n' {
3347                        output.push('\n');
3348                        break;
3349                    }
3350                    output.push(' ');
3351                }
3352            }
3353            b'/' if bytes.get(*index + 1) == Some(&b'*') => {
3354                output.push(' ');
3355                output.push(' ');
3356                *index += 2;
3357                while *index < bytes.len() {
3358                    let comment_byte = bytes[*index];
3359                    if comment_byte == b'*' && bytes.get(*index + 1) == Some(&b'/') {
3360                        output.push(' ');
3361                        output.push(' ');
3362                        *index += 2;
3363                        break;
3364                    }
3365                    output.push(if comment_byte == b'\n' { '\n' } else { ' ' });
3366                    *index += 1;
3367                }
3368            }
3369            b'\'' | b'"' => sanitize_string_literal(bytes, index, output, current),
3370            b'`' => sanitize_template_literal(bytes, index, output),
3371            _ => {
3372                output.push(char::from(current));
3373                *index += 1;
3374            }
3375        }
3376    }
3377}
3378
3379fn sanitize_string_literal(bytes: &[u8], index: &mut usize, output: &mut String, quote: u8) {
3380    output.push(INLINE_MODULE_STRING_PLACEHOLDER);
3381    *index += 1;
3382
3383    while *index < bytes.len() {
3384        let current = bytes[*index];
3385        *index += 1;
3386        match current {
3387            b'\\' => {
3388                if *index < bytes.len() {
3389                    *index += 1;
3390                }
3391            }
3392            c if c == quote => break,
3393            _ => {}
3394        }
3395    }
3396}
3397
3398fn sanitize_template_literal(bytes: &[u8], index: &mut usize, output: &mut String) {
3399    output.push(INLINE_MODULE_STRING_PLACEHOLDER);
3400    *index += 1;
3401
3402    while *index < bytes.len() {
3403        let current = bytes[*index];
3404        match current {
3405            b'\\' => {
3406                *index += 1;
3407                if *index < bytes.len() {
3408                    *index += 1;
3409                }
3410            }
3411            b'`' => {
3412                *index += 1;
3413                break;
3414            }
3415            b'$' if bytes.get(*index + 1) == Some(&b'{') => {
3416                output.push(' ');
3417                output.push(' ');
3418                *index += 2;
3419                sanitize_javascript_code(bytes, index, output, Some(0));
3420                output.push(INLINE_MODULE_STRING_PLACEHOLDER);
3421            }
3422            b'\n' => {
3423                output.push('\n');
3424                *index += 1;
3425            }
3426            _ => {
3427                *index += 1;
3428            }
3429        }
3430    }
3431}
3432
3433fn tokenize_inline_module_source(source: &str) -> Vec<InlineModuleToken<'_>> {
3434    let mut tokens = Vec::new();
3435    let bytes = source.as_bytes();
3436    let mut index = 0;
3437
3438    while index < bytes.len() {
3439        let current = bytes[index];
3440        match current {
3441            b if b.is_ascii_whitespace() => index += 1,
3442            b if char::from(b) == INLINE_MODULE_STRING_PLACEHOLDER => {
3443                tokens.push(InlineModuleToken::StringLiteral);
3444                index += 1;
3445            }
3446            b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'$' => {
3447                let start = index;
3448                index += 1;
3449                while index < bytes.len()
3450                    && matches!(bytes[index], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'$')
3451                {
3452                    index += 1;
3453                }
3454                tokens.push(InlineModuleToken::Identifier(&source[start..index]));
3455            }
3456            _ => {
3457                tokens.push(InlineModuleToken::Punct(char::from(current)));
3458                index += 1;
3459            }
3460        }
3461    }
3462
3463    tokens
3464}
3465
3466fn nearest_package_json_type(entrypoint: &Path) -> Option<String> {
3467    let mut current = entrypoint.parent();
3468    while let Some(dir) = current {
3469        let package_json = dir.join("package.json");
3470        if let Ok(contents) = fs::read_to_string(&package_json) {
3471            if let Ok(pkg) = serde_json::from_str::<LocalPackageJson>(&contents) {
3472                return pkg.package_type;
3473            }
3474        }
3475        current = dir.parent();
3476    }
3477    None
3478}
3479
3480fn resolve_v8_entrypoint(cwd: &Path, entrypoint: &str) -> String {
3481    if entrypoint == "-e" || entrypoint == "--eval" {
3482        return entrypoint.to_owned();
3483    }
3484
3485    let path = Path::new(entrypoint);
3486    let resolved = if path.is_absolute() {
3487        path.to_path_buf()
3488    } else {
3489        cwd.join(path)
3490    };
3491    resolved.to_string_lossy().into_owned()
3492}
3493
3494// Keep each injected process/runtime value explicit at this one serialization
3495// boundary; grouping them would duplicate the already-typed guest config.
3496#[allow(clippy::too_many_arguments)]
3497fn prepend_v8_runtime_shim(
3498    user_code: String,
3499    entrypoint: &str,
3500    argv: &[String],
3501    argv0: Option<&str>,
3502    cwd: &str,
3503    env: &BTreeMap<String, String>,
3504    // V8 heap cap in MB (`0` = engine default). Threaded from the typed wire
3505    // limit and interpolated into the shim so guest heap-stats reporting no
3506    // longer depends on an `AGENTOS_V8_HEAP_LIMIT_MB` env var.
3507    heap_limit_mb: u32,
3508    // Typed guest-runtime identity, interpolated into the shim so virtual
3509    // `process.*` identity no longer rides `AGENTOS_VIRTUAL_PROCESS_*` env vars.
3510    guest_runtime: &GuestRuntimeConfig,
3511) -> String {
3512    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| String::from("[\"node\"]"));
3513    let argv0_json = serde_json::to_string(&argv0.unwrap_or("node"))
3514        .unwrap_or_else(|_| String::from("\"node\""));
3515    let entry_json =
3516        serde_json::to_string(entrypoint).unwrap_or_else(|_| String::from("\"/<entry>\""));
3517    let cwd_json = serde_json::to_string(cwd).unwrap_or_else(|_| String::from("\"/\""));
3518    let env_json = serde_json::to_string(env).unwrap_or_else(|_| String::from("{}"));
3519    // Virtual process identity object. `Option` fields serialize to `null`, which
3520    // the shim treats as "unset" (leaving the V8-baked default) — matching the
3521    // prior behavior when the env var was absent.
3522    let identity_json = serde_json::json!({
3523        "pid": guest_runtime.virtual_pid,
3524        "ppid": guest_runtime.virtual_ppid,
3525        "uid": guest_runtime.virtual_uid,
3526        "gid": guest_runtime.virtual_gid,
3527        "execPath": guest_runtime.virtual_exec_path,
3528    })
3529    .to_string();
3530    // Virtual OS identity (os.cpus/totalmem/freemem/homedir/userInfo/...). Read
3531    // by the bridge + node-import-cache os polyfill from the `__agentOSVirtualOs`
3532    // global instead of `AGENTOS_VIRTUAL_OS_*` env vars. Absent fields stay
3533    // `null`, so the consumers fall back to their built-in defaults.
3534    let virtual_os_json = serde_json::json!({
3535        "cpuCount": guest_runtime.os_cpu_count,
3536        "totalmem": guest_runtime.os_totalmem,
3537        "freemem": guest_runtime.os_freemem,
3538        "homedir": guest_runtime.os_homedir,
3539        "hostname": guest_runtime.os_hostname,
3540        "tmpdir": guest_runtime.os_tmpdir,
3541        "type": guest_runtime.os_type,
3542        "release": guest_runtime.os_release,
3543        "version": guest_runtime.os_version,
3544        "machine": guest_runtime.os_machine,
3545        "shell": guest_runtime.os_shell,
3546        "user": guest_runtime.os_user,
3547    })
3548    .to_string();
3549    let high_resolution_time = guest_runtime.high_resolution_time;
3550
3551    format!(
3552        r#"(function () {{
3553  const __guestIdentity = {identity_json};
3554  Object.defineProperty(globalThis, "__agentOSVirtualOs", {{
3555    configurable: true,
3556    enumerable: false,
3557    value: {virtual_os_json},
3558    writable: true,
3559  }});
3560  const nextArgv = {argv_json};
3561  const nextArgv0 = {argv0_json};
3562  const entryFile = {entry_json};
3563  const nextCwd = {cwd_json};
3564  const nextEnv = {env_json};
3565  const nextHighResolutionTime = {high_resolution_time};
3566  try {{
3567    const previousProcessConfig =
3568      typeof globalThis._processConfig === "object" && globalThis._processConfig !== null
3569        ? globalThis._processConfig
3570        : {{}};
3571    Object.defineProperty(globalThis, "_processConfig", {{
3572      configurable: true,
3573      enumerable: false,
3574      value: Object.freeze({{
3575        ...previousProcessConfig,
3576        cwd: nextCwd,
3577        env: nextEnv,
3578        argv: nextArgv,
3579        argv0: nextArgv0,
3580        high_resolution_time: nextHighResolutionTime,
3581      }}),
3582      writable: false,
3583    }});
3584  }} catch (_e) {{}}
3585  if (typeof globalThis.__runtimeRefreshProcessConfig === "function") {{
3586    globalThis.__runtimeRefreshProcessConfig();
3587  }}
3588  Object.defineProperty(globalThis, "__agentOSProcessConfigEnv", {{
3589    configurable: true,
3590    enumerable: false,
3591    value: nextEnv,
3592    writable: true,
3593  }});
3594  const visibleEnv = Object.fromEntries(
3595    Object.entries(nextEnv).filter(([key]) => !key.startsWith("AGENTOS_"))
3596  );
3597
3598  // Refresh the process module's closure-backed state before user modules run.
3599  // Updating only globalThis.process leaves named ESM imports such as
3600  // `import {{ cwd }} from "process"` reading the warm snapshot's stale cwd.
3601  if (typeof globalThis.__runtimeRefreshProcessConfig === "function") {{
3602    globalThis.__runtimeRefreshProcessConfig();
3603  }}
3604
3605  if (typeof process !== "undefined") {{
3606    process.argv = nextArgv;
3607    process.argv0 = nextArgv0;
3608    process.env = {{
3609      ...(process.env || {{}}),
3610      ...visibleEnv,
3611    }};
3612    const configuredHeapLimitMb = {heap_limit_mb};
3613    if (Number.isFinite(configuredHeapLimitMb) && configuredHeapLimitMb > 0) {{
3614      Object.defineProperty(globalThis, "__agentOSV8HeapLimitBytes", {{
3615        configurable: true,
3616        enumerable: false,
3617        value: configuredHeapLimitMb * 1024 * 1024,
3618        writable: true,
3619      }});
3620    }}
3621    if (nextEnv.AGENTOS_ALLOW_PROCESS_BINDINGS === "1" && typeof process.binding === "function") {{
3622      const originalProcessBinding = process.binding.bind(process);
3623      process.binding = (name) => {{
3624        const bindingName = String(name);
3625        if (
3626          bindingName === "constants" &&
3627          typeof __agentOSConstantsBinding !== "undefined"
3628        ) {{
3629          const constantsBinding =
3630            __agentOSConstantsBinding.default ?? __agentOSConstantsBinding;
3631          return {{
3632            fs: constantsBinding,
3633            crypto: constantsBinding,
3634            zlib: constantsBinding,
3635            trace: constantsBinding,
3636            internal: constantsBinding,
3637            os: {{
3638              UV_UDP_REUSEADDR: constantsBinding.UV_UDP_REUSEADDR,
3639              dlopen: constantsBinding.dlopen,
3640              errno: constantsBinding.errno,
3641              signals: constantsBinding.signals,
3642              priority: constantsBinding.priority,
3643            }},
3644          }};
3645        }}
3646        try {{
3647          return originalProcessBinding(name);
3648        }} catch (error) {{
3649          const originalMessage =
3650            error && typeof error === "object" && typeof error.message === "string"
3651              ? error.message
3652              : String(error);
3653          throw new Error(
3654            `process.binding(${{bindingName}}) failed: ${{originalMessage}}`
3655          );
3656        }}
3657      }};
3658    }}
3659    const nextPid = Number(__guestIdentity.pid);
3660    if (Number.isFinite(nextPid) && nextPid > 0) {{
3661      process.pid = nextPid;
3662    }}
3663    const nextPpid = Number(__guestIdentity.ppid);
3664    if (Number.isFinite(nextPpid) && nextPpid >= 0) {{
3665      process.ppid = nextPpid;
3666    }}
3667    const nextUid = Number(__guestIdentity.uid);
3668    if (Number.isFinite(nextUid) && nextUid >= 0) {{
3669      process.uid = nextUid;
3670      process.euid = nextUid;
3671    }}
3672    const nextGid = Number(__guestIdentity.gid);
3673    if (Number.isFinite(nextGid) && nextGid >= 0) {{
3674      process.gid = nextGid;
3675      process.egid = nextGid;
3676      process.groups = [nextGid];
3677    }}
3678    if (typeof __guestIdentity.execPath === "string" && __guestIdentity.execPath.length > 0) {{
3679      process.execPath = __guestIdentity.execPath;
3680    }}
3681    if (nextEnv.AGENTOS_NODE_IPC === "1" && typeof __runtimeInstallProcessIpcBridge === "function") {{
3682      process.connected = true;
3683      __runtimeInstallProcessIpcBridge();
3684    }}
3685    if (typeof process.getBuiltinModule !== "function") {{
3686      process.getBuiltinModule = function(specifier) {{
3687        return globalThis.require ? globalThis.require(specifier) : undefined;
3688      }};
3689    }}
3690  }}
3691
3692  const streamStdin = nextEnv.AGENTOS_KEEP_STDIN_OPEN === "1";
3693  if (typeof globalThis.__runtimeConfigureStreamStdin === "function") {{
3694    globalThis.__runtimeConfigureStreamStdin(
3695      streamStdin,
3696      nextEnv.AGENTOS_EAGER_STDIN_HANDLE === "1",
3697    );
3698  }} else {{
3699    globalThis.__runtimeStreamStdin = streamStdin;
3700  }}
3701  globalThis.__runtimeKernelStdin =
3702    nextEnv.AGENTOS_FORWARD_KERNEL_STDIN_RPC === "1";
3703
3704  if (
3705    typeof globalThis.WebAssembly === "object" &&
3706    globalThis.WebAssembly !== null &&
3707    typeof globalThis.WebAssembly.instantiateStreaming !== "function"
3708  ) {{
3709    globalThis.WebAssembly.instantiateStreaming = async function instantiateStreaming(source, imports) {{
3710      const response = await source;
3711      if (response == null || typeof response.arrayBuffer !== "function") {{
3712        throw new TypeError(
3713          "WebAssembly.instantiateStreaming requires a Response or promise for one",
3714        );
3715      }}
3716      const bytes = new Uint8Array(await response.arrayBuffer());
3717      return globalThis.WebAssembly.instantiate(bytes, imports);
3718    }};
3719  }}
3720
3721  if (
3722    typeof globalThis.require === "undefined" &&
3723    typeof globalThis._moduleModule?.createRequire === "function"
3724  ) {{
3725    const requireEntryFile =
3726      entryFile === "-e" || entryFile === "--eval"
3727        ? nextCwd === "/"
3728          ? "/__agentos_eval__.js"
3729          : `${{nextCwd.replace(/\/+$/, "")}}/__agentos_eval__.js`
3730        : entryFile;
3731    globalThis.require =
3732      globalThis._moduleModule.createRequire(requireEntryFile);
3733  }}
3734
3735  if (typeof globalThis.require === "function") {{
3736    let preloadModules = [];
3737    try {{
3738      const parsed = JSON.parse(nextEnv.AGENTOS_NODE_PRELOAD_MODULES || "[]");
3739      if (Array.isArray(parsed)) preloadModules = parsed.map(String);
3740    }} catch (_e) {{}}
3741    const preloadBase =
3742      nextCwd === "/"
3743        ? "/__agentos_preload__.js"
3744        : `${{nextCwd.replace(/\/+$/, "")}}/__agentos_preload__.js`;
3745    const preloadRequire =
3746      typeof globalThis._moduleModule?.createRequire === "function"
3747        ? globalThis._moduleModule.createRequire(preloadBase)
3748        : globalThis.require;
3749    for (const preloadModule of preloadModules) {{
3750      preloadRequire(preloadModule);
3751    }}
3752  }}
3753
3754  // jsRuntime platform tiering: the guest JS host surface is baked into the
3755  // shared V8 snapshot, so non-node platforms are produced by subtractively
3756  // scrubbing baked globals here, per execution.
3757  const __jsPlatform = nextEnv.AGENTOS_JS_PLATFORM || "node";
3758  // Install the builtin allow-list gate consulted by the bridge's
3759  // rejectRestrictedBuiltinRequest (covers require + ESM builtin loads). Present
3760  // for non-node platforms (empty => deny all) and node + explicit allow-list;
3761  // absent => unrestricted (node default).
3762  {{
3763    const __builtinAllowRaw = nextEnv.AGENTOS_JS_BUILTIN_ALLOWLIST;
3764    if (typeof __builtinAllowRaw === "string") {{
3765      let __allowList = [];
3766      try {{ __allowList = JSON.parse(__builtinAllowRaw); }} catch (_e) {{ __allowList = []; }}
3767      if (typeof globalThis.__agentOSInitJsRuntime === "function") {{
3768        globalThis.__agentOSInitJsRuntime(Array.isArray(__allowList) ? __allowList : []);
3769      }}
3770      try {{ delete globalThis.__agentOSInitJsRuntime; }} catch (_e) {{}}
3771    }}
3772  }}
3773  if (__jsPlatform !== "node") {{
3774    const __dropGlobal = (name) => {{
3775      try {{ delete globalThis[name]; }} catch (_e) {{}}
3776      if (
3777        Object.prototype.hasOwnProperty.call(globalThis, name) ||
3778        typeof globalThis[name] !== "undefined"
3779      ) {{
3780        try {{ globalThis[name] = undefined; }} catch (_e) {{}}
3781        try {{
3782          Object.defineProperty(globalThis, name, {{
3783            value: undefined,
3784            configurable: true,
3785            writable: true,
3786          }});
3787        }} catch (_e) {{}}
3788        try {{ delete globalThis[name]; }} catch (_e) {{}}
3789      }}
3790    }};
3791    // Node host surface + identity channels — removed on every non-node platform.
3792    [
3793      "process", "Buffer", "require", "module", "exports",
3794      "__dirname", "__filename", "global",
3795      "_processConfig", "__agentOSProcessConfigEnv", "__agentOSVirtualOs",
3796    ].forEach(__dropGlobal);
3797    if (__jsPlatform === "browser") {{
3798      // Narrow `crypto` from the full node:crypto module to the WebCrypto object
3799      // (drops randomBytes/createHash/... while keeping subtle/getRandomValues).
3800      try {{
3801        const __wc = globalThis.crypto && globalThis.crypto.webcrypto;
3802        if (__wc) {{ globalThis.crypto = __wc; }}
3803      }} catch (_e) {{}}
3804    }}
3805    if (__jsPlatform === "neutral" || __jsPlatform === "bare") {{
3806      // Web-platform globals removed at neutral and below.
3807      [
3808        "fetch", "Headers", "Request", "Response", "FormData",
3809        "URL", "URLSearchParams", "Blob", "File", "crypto",
3810        "atob", "btoa", "structuredClone", "performance",
3811        "AbortController", "AbortSignal", "Event", "EventTarget",
3812        "MessageChannel", "MessagePort", "MessageEvent",
3813        "ReadableStream", "WritableStream", "TransformStream",
3814      ].forEach(__dropGlobal);
3815    }}
3816    if (__jsPlatform === "bare") {{
3817      // Universal host primitives removed only at the language-only tier.
3818      [
3819        "console", "queueMicrotask",
3820        "setTimeout", "clearTimeout", "setInterval", "clearInterval",
3821        "setImmediate", "clearImmediate",
3822      ].forEach(__dropGlobal);
3823    }}
3824  }}
3825}})();
3826{user_code}"#
3827    )
3828}
3829
3830/// Spawn a supervised task on the process runtime that converts V8 BinaryFrame
3831/// messages into JavascriptExecutionEvent values for the sidecar event loop.
3832///
3833/// Internal bridge calls (module loading, logging, timers) are handled locally
3834/// by the event bridge. Kernel operations (fs, net, child_process, dns) are
3835/// forwarded to the sidecar via SyncRpcRequest events.
3836fn spawn_v8_event_bridge(
3837    runtime: &RuntimeContext,
3838    frame_receiver: V8SessionFrameReceiver,
3839    pending_sync_rpc: Arc<Mutex<Option<PendingSyncRpcState>>>,
3840    exited: Arc<AtomicBool>,
3841    v8_session: V8SessionHandle,
3842    mut local_bridge: LocalBridgeState,
3843    event_notify: Option<Arc<Notify>>,
3844) -> Result<
3845    (
3846        EventReceiver<JavascriptExecutionEvent>,
3847        tokio::task::JoinHandle<()>,
3848    ),
3849    JavascriptExecutionError,
3850> {
3851    let (sender, receiver) = flume::bounded(JAVASCRIPT_EVENT_CHANNEL_CAPACITY);
3852    let event_gauge = register_queue(
3853        TrackedLimit::JavascriptEventChannel,
3854        JAVASCRIPT_EVENT_CHANNEL_CAPACITY,
3855    );
3856
3857    let task = runtime
3858        .spawn(agentos_runtime::TaskClass::Vm, async move {
3859            let mut emitted_exit = false;
3860            loop {
3861                let frame_recv_start = Instant::now();
3862                let Ok(frame) = frame_receiver.recv_async().await else {
3863                    break;
3864                };
3865                let frame_recv_wait = frame_recv_start.elapsed();
3866                let mut exit_frame_start = None;
3867                let event = match frame {
3868                    BinaryFrame::BridgeCall {
3869                        call_id,
3870                        method,
3871                        payload,
3872                        ..
3873                    } => {
3874                        // Convert CBOR payload to JSON args
3875                        let phase_start = Instant::now();
3876                        let args =
3877                            v8_runtime::cbor_payload_to_json_args(&payload).unwrap_or_default();
3878                        record_sync_bridge_phase(
3879                            &method,
3880                            "event_decode_args",
3881                            phase_start.elapsed(),
3882                        );
3883
3884                        // Module resolution / loading must read the mounted
3885                        // `node_modules` VFS, not host files directly. When the
3886                        // sidecar supplied a read-only VFS module reader, resolve
3887                        // these inline on this bridge thread (off the service loop) so
3888                        // a large cold-start module graph runs concurrently with — and
3889                        // never serializes behind / starves — the ACP bootstrap that
3890                        // is itself awaiting the adapter's `session/new` response on
3891                        // the single service-loop thread. Without a reader (no mount),
3892                        // they flow to the service loop as SyncRpcRequests (mapped to
3893                        // `__resolve_module` / `__load_file` / `__module_format` /
3894                        // `__batch_resolve_modules`) and resolve against `vm.kernel`.
3895                        let is_module_method = matches!(
3896                            method.as_str(),
3897                            "_resolveModule"
3898                                | "_resolveModuleSync"
3899                                | "_loadFile"
3900                                | "_loadFileSync"
3901                                | "_moduleFormat"
3902                                | "_batchResolveModules"
3903                        );
3904                        let resolve_on_service_loop =
3905                            is_module_method && !local_bridge.has_module_reader();
3906
3907                        // Check if this is an internal bridge call we handle locally
3908                        if !resolve_on_service_loop {
3909                            if let Some(response) =
3910                                local_bridge.handle_internal_bridge_call(call_id, &method, &args)
3911                            {
3912                                if let LocalBridgeCallResult::Immediate(response) = response {
3913                                    let cbor_payload = v8_runtime::json_to_cbor_payload(&response)
3914                                        .unwrap_or_default();
3915                                    if let Err(error) =
3916                                        v8_session.send_bridge_response(call_id, 0, cbor_payload)
3917                                    {
3918                                        eprintln!(
3919                                            "INFO_AGENTOS_STALE_BRIDGE_COMPLETION: call_id={call_id} error={error}"
3920                                        );
3921                                    }
3922                                }
3923                                continue;
3924                            }
3925                        }
3926
3927                        // Handle logging locally (produce stdout/stderr events)
3928                        if method == "_log" || method == "_error" {
3929                            let output = decode_bridge_output_args(&args);
3930                            // Respond to the bridge call
3931                            if let Err(error) = v8_session.send_bridge_response(
3932                                call_id,
3933                                0,
3934                                v8_runtime::json_to_cbor_payload(&Value::Null).unwrap_or_default(),
3935                            ) {
3936                                eprintln!(
3937                                    "INFO_AGENTOS_STALE_BRIDGE_COMPLETION: call_id={call_id} error={error}"
3938                                );
3939                            }
3940                            if method == "_log" {
3941                                if !send_javascript_event_async(
3942                                    &sender,
3943                                    &event_gauge,
3944                                    event_notify.as_deref(),
3945                                    JavascriptExecutionEvent::Stdout(output),
3946                                )
3947                                .await
3948                                {
3949                                    break;
3950                                }
3951                            } else {
3952                                if !send_javascript_event_async(
3953                                    &sender,
3954                                    &event_gauge,
3955                                    event_notify.as_deref(),
3956                                    JavascriptExecutionEvent::Stderr(output),
3957                                )
3958                                .await
3959                                {
3960                                    break;
3961                                }
3962                            }
3963                            continue;
3964                        }
3965
3966                        // Map the bridge method name to the sidecar sync RPC method name
3967                        let phase_start = Instant::now();
3968                        let (sidecar_method, _needs_translation) =
3969                            v8_runtime::map_bridge_method(&method);
3970                        record_sync_bridge_phase(
3971                            &method,
3972                            "event_map_method",
3973                            phase_start.elapsed(),
3974                        );
3975
3976                        // Track pending sync RPC
3977                        let phase_start = Instant::now();
3978                        if let Ok(mut pending) = pending_sync_rpc.lock() {
3979                            *pending = Some(PendingSyncRpcState::Pending(call_id));
3980                        }
3981                        record_sync_bridge_phase(
3982                            &method,
3983                            "event_mark_pending",
3984                            phase_start.elapsed(),
3985                        );
3986
3987                        let phase_start = Instant::now();
3988                        let request_args = translate_request_args_for_legacy(sidecar_method, &args);
3989                        let mut raw_bytes_args = HashMap::new();
3990                        if sidecar_method == "net.write"
3991                            || sidecar_method == "fs.writeSync"
3992                            || sidecar_method == "fs.writevSync"
3993                            || sidecar_method == "fs.writeFileSync"
3994                            || sidecar_method == "crypto.hashUpdate"
3995                        {
3996                            if let Ok(Some(bytes)) =
3997                                v8_runtime::cbor_payload_raw_byte_arg(&payload, 1)
3998                            {
3999                                raw_bytes_args.insert(1, bytes);
4000                            }
4001                        }
4002                        if method == "_fsReadRaw" || method == "_fsReadFileRangeRaw" {
4003                            raw_bytes_args.insert(usize::MAX, Vec::new());
4004                        }
4005                        record_sync_bridge_phase(
4006                            &method,
4007                            "event_translate_args",
4008                            phase_start.elapsed(),
4009                        );
4010                        Some(JavascriptExecutionEvent::SyncRpcRequest(
4011                            JavascriptSyncRpcRequest {
4012                                id: call_id,
4013                                method: sidecar_method.to_owned(),
4014                                args: request_args,
4015                                raw_bytes_args,
4016                            },
4017                        ))
4018                    }
4019                    BinaryFrame::Log {
4020                        channel, message, ..
4021                    } => {
4022                        if channel == 0 {
4023                            Some(JavascriptExecutionEvent::Stdout(message.into_bytes()))
4024                        } else {
4025                            Some(JavascriptExecutionEvent::Stderr(message.into_bytes()))
4026                        }
4027                    }
4028                    BinaryFrame::ExecutionResult {
4029                        exit_code, error, ..
4030                    } => {
4031                        exited.store(true, Ordering::Release);
4032                        let phase_start = Instant::now();
4033                        exit_frame_start = Some(phase_start);
4034                        record_js_event_phase("js_exit_frame_recv_wait", frame_recv_wait);
4035                        let is_process_exit_error = error.as_ref().is_some_and(|err| {
4036                            err.error_type == "ProcessExitError"
4037                                || err.message.starts_with("process.exit(")
4038                        });
4039                        let resolved_exit_code = error
4040                            .as_ref()
4041                            .and_then(|err| {
4042                                if is_process_exit_error {
4043                                    parse_process_exit_code_message(&err.message)
4044                                } else {
4045                                    None
4046                                }
4047                            })
4048                            .unwrap_or(exit_code);
4049                        let should_emit_error = error.is_some() && !is_process_exit_error;
4050                        if should_emit_error {
4051                            let err = error.as_ref().expect("checked above");
4052                            let error_msg = if err.stack.is_empty() {
4053                                format!("{}: {}\n", err.error_type, err.message)
4054                            } else {
4055                                format!("{}\n", err.stack)
4056                            };
4057                            if !send_javascript_event_async(
4058                                &sender,
4059                                &event_gauge,
4060                                event_notify.as_deref(),
4061                                JavascriptExecutionEvent::Stderr(error_msg.into_bytes()),
4062                            )
4063                            .await
4064                            {
4065                                break;
4066                            }
4067                        }
4068                        emitted_exit = true;
4069                        record_js_event_phase(
4070                            "js_exit_frame_to_event_construct",
4071                            phase_start.elapsed(),
4072                        );
4073                        Some(JavascriptExecutionEvent::Exited(resolved_exit_code))
4074                    }
4075                    BinaryFrame::StreamCallback { .. } => None,
4076                    _ => None,
4077                };
4078
4079                if let Some(event) = event {
4080                    let sync_rpc = match &event {
4081                        JavascriptExecutionEvent::SyncRpcRequest(request) => {
4082                            Some((request.id, request.method.clone()))
4083                        }
4084                        _ => None,
4085                    };
4086                    if let Some((call_id, method)) = sync_rpc.as_ref() {
4087                        record_sync_bridge_request_enqueued(*call_id, method);
4088                    }
4089                    let phase_start = sync_rpc.as_ref().map(|_| Instant::now());
4090                    let exit_send_start = if matches!(&event, JavascriptExecutionEvent::Exited(_)) {
4091                        Some(Instant::now())
4092                    } else {
4093                        None
4094                    };
4095                    if !send_javascript_event_async(
4096                        &sender,
4097                        &event_gauge,
4098                        event_notify.as_deref(),
4099                        event,
4100                    )
4101                    .await
4102                    {
4103                        break;
4104                    }
4105                    if let (Some((_, method)), Some(start)) = (sync_rpc, phase_start) {
4106                        record_sync_bridge_phase(&method, "event_enqueue", start.elapsed());
4107                    }
4108                    if let Some(start) = exit_send_start {
4109                        record_js_event_phase("js_exit_event_send", start.elapsed());
4110                    }
4111                    if let Some(start) = exit_frame_start {
4112                        record_js_event_phase("js_exit_frame_to_event_sent", start.elapsed());
4113                    }
4114                }
4115            }
4116
4117            if !emitted_exit {
4118                exited.store(true, Ordering::Release);
4119                let phase_start = Instant::now();
4120                let sent = send_javascript_event_async(
4121                    &sender,
4122                    &event_gauge,
4123                    event_notify.as_deref(),
4124                    JavascriptExecutionEvent::Exited(1),
4125                )
4126                .await;
4127                if sent {
4128                    record_js_event_phase("js_exit_fallback_event_send", phase_start.elapsed());
4129                }
4130            }
4131        })
4132        .map_err(|error| {
4133            JavascriptExecutionError::Spawn(std::io::Error::other(error.to_string()))
4134        })?;
4135
4136    Ok((receiver, task))
4137}
4138
4139async fn send_javascript_event_async(
4140    sender: &EventSender<JavascriptExecutionEvent>,
4141    gauge: &agentos_bridge::queue_tracker::QueueGauge,
4142    notify: Option<&Notify>,
4143    event: JavascriptExecutionEvent,
4144) -> bool {
4145    match event {
4146        JavascriptExecutionEvent::Stdout(chunk)
4147            if chunk.len() > JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES =>
4148        {
4149            for chunk in chunk.chunks(JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES) {
4150                if !send_single_javascript_event_async(
4151                    sender,
4152                    gauge,
4153                    notify,
4154                    JavascriptExecutionEvent::Stdout(chunk.to_vec()),
4155                )
4156                .await
4157                {
4158                    return false;
4159                }
4160            }
4161            true
4162        }
4163        JavascriptExecutionEvent::Stderr(chunk)
4164            if chunk.len() > JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES =>
4165        {
4166            for chunk in chunk.chunks(JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES) {
4167                if !send_single_javascript_event_async(
4168                    sender,
4169                    gauge,
4170                    notify,
4171                    JavascriptExecutionEvent::Stderr(chunk.to_vec()),
4172                )
4173                .await
4174                {
4175                    return false;
4176                }
4177            }
4178            true
4179        }
4180        event => send_single_javascript_event_async(sender, gauge, notify, event).await,
4181    }
4182}
4183
4184async fn send_single_javascript_event_async(
4185    sender: &EventSender<JavascriptExecutionEvent>,
4186    gauge: &agentos_bridge::queue_tracker::QueueGauge,
4187    notify: Option<&Notify>,
4188    event: JavascriptExecutionEvent,
4189) -> bool {
4190    match sender.send_async(event).await {
4191        Ok(()) => {
4192            gauge.observe_depth(sender.len());
4193            if let Some(notify) = notify {
4194                notify.notify_one();
4195            }
4196            true
4197        }
4198        Err(_closed) => false,
4199    }
4200}
4201
4202#[cfg(test)]
4203fn send_javascript_event(
4204    sender: &EventSender<JavascriptExecutionEvent>,
4205    gauge: &agentos_bridge::queue_tracker::QueueGauge,
4206    notify: Option<&Notify>,
4207    event: JavascriptExecutionEvent,
4208) -> bool {
4209    match event {
4210        JavascriptExecutionEvent::Stdout(chunk)
4211            if chunk.len() > JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES =>
4212        {
4213            for chunk in chunk.chunks(JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES) {
4214                if !send_single_javascript_event(
4215                    sender,
4216                    gauge,
4217                    notify,
4218                    JavascriptExecutionEvent::Stdout(chunk.to_vec()),
4219                ) {
4220                    return false;
4221                }
4222            }
4223            true
4224        }
4225        JavascriptExecutionEvent::Stderr(chunk)
4226            if chunk.len() > JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES =>
4227        {
4228            for chunk in chunk.chunks(JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES) {
4229                if !send_single_javascript_event(
4230                    sender,
4231                    gauge,
4232                    notify,
4233                    JavascriptExecutionEvent::Stderr(chunk.to_vec()),
4234                ) {
4235                    return false;
4236                }
4237            }
4238            true
4239        }
4240        event => send_single_javascript_event(sender, gauge, notify, event),
4241    }
4242}
4243
4244#[cfg(test)]
4245fn send_single_javascript_event(
4246    sender: &EventSender<JavascriptExecutionEvent>,
4247    gauge: &agentos_bridge::queue_tracker::QueueGauge,
4248    notify: Option<&Notify>,
4249    event: JavascriptExecutionEvent,
4250) -> bool {
4251    // Apply backpressure instead of self-destructing when the host consumer is
4252    // slow. A burst of guest events that briefly outpaces the host draining this
4253    // channel is normal; previously a single `try_send` returning `Full` tore the
4254    // whole session down (`destroy()` -> Shutdown -> `Exited(1)`), turning a
4255    // transient backlog into a fatal crash. This test-only synchronous helper
4256    // parks its calling thread until the host drains capacity. Production uses
4257    // `send_javascript_event_async`, which yields the shared runtime task.
4258    match sender.send(event) {
4259        Ok(()) => {
4260            // Sample the live channel depth so the centralized queue tracker can
4261            // warn before the host consumer falls far enough behind to stall the
4262            // session (and surface the high-water mark for debugging).
4263            gauge.observe_depth(sender.len());
4264            if let Some(notify) = notify {
4265                notify.notify_one();
4266            }
4267            true
4268        }
4269        Err(_closed) => false,
4270    }
4271}
4272
4273/// Handle internal bridge calls that don't need to go to the sidecar.
4274/// Returns Some(response) if handled locally, None if it should be forwarded.
4275impl LocalBridgeState {
4276    fn handle_internal_bridge_call(
4277        &mut self,
4278        call_id: u64,
4279        method: &str,
4280        args: &[Value],
4281    ) -> Option<LocalBridgeCallResult> {
4282        match method {
4283            "_resolveModule" | "_resolveModuleSync" => {
4284                let specifier = args.first().and_then(Value::as_str).unwrap_or("");
4285                let parent = args.get(1).and_then(Value::as_str).unwrap_or("/");
4286                let mode = match args.get(2).and_then(Value::as_str) {
4287                    Some("import") => ModuleResolveMode::Import,
4288                    Some("require") => ModuleResolveMode::Require,
4289                    _ if method == "_resolveModule" => ModuleResolveMode::Import,
4290                    _ => ModuleResolveMode::Require,
4291                };
4292                if self.js_runtime_denies_specifier(specifier) {
4293                    return Some(LocalBridgeCallResult::Immediate(Value::Null));
4294                }
4295                let resolved = self.with_module_resolver(|resolver| {
4296                    resolver.resolve_module(specifier, parent, mode)
4297                });
4298                if resolved.is_none() && self.has_module_reader() {
4299                    return None;
4300                }
4301                Some(LocalBridgeCallResult::Immediate(
4302                    resolved.map(Value::String).unwrap_or(Value::Null),
4303                ))
4304            }
4305            "_moduleFormat" => {
4306                let format = self.module_format(args.first().and_then(Value::as_str).unwrap_or(""));
4307                if format.is_none() && self.has_module_reader() {
4308                    return None;
4309                }
4310                Some(LocalBridgeCallResult::Immediate(
4311                    format
4312                        .map(|format| Value::String(String::from(format.as_str())))
4313                        .unwrap_or(Value::Null),
4314                ))
4315            }
4316            "_loadFile" | "_loadFileSync" => {
4317                let source = self.load_file(args.first().and_then(Value::as_str).unwrap_or(""));
4318                if source.is_none() && self.has_module_reader() {
4319                    return None;
4320                }
4321                Some(LocalBridgeCallResult::Immediate(
4322                    source.map(Value::String).unwrap_or(Value::Null),
4323                ))
4324            }
4325            "_batchResolveModules" => {
4326                let resolved = self.batch_resolve_modules(args);
4327                if self.has_module_reader()
4328                    && resolved
4329                        .as_array()
4330                        .is_some_and(|items| items.iter().any(Value::is_null))
4331                {
4332                    return None;
4333                }
4334                Some(LocalBridgeCallResult::Immediate(resolved))
4335            }
4336            "_loadPolyfill" => Some(LocalBridgeCallResult::Immediate(
4337                self.handle_polyfill_dispatch(args),
4338            )),
4339            "_cryptoRandomFill" => {
4340                let size = args.first().and_then(Value::as_u64).unwrap_or(16) as usize;
4341                let mut bytes = vec![0u8; size];
4342                if getrandom(&mut bytes).is_err() {
4343                    return Some(LocalBridgeCallResult::Immediate(Value::Null));
4344                }
4345                Some(LocalBridgeCallResult::Immediate(Value::String(
4346                    v8_runtime::base64_encode_pub(&bytes),
4347                )))
4348            }
4349            "_cryptoRandomUUID" => {
4350                let mut bytes = [0u8; 16];
4351                if getrandom(&mut bytes).is_err() {
4352                    return Some(LocalBridgeCallResult::Immediate(Value::Null));
4353                }
4354                bytes[6] = (bytes[6] & 0x0f) | 0x40;
4355                bytes[8] = (bytes[8] & 0x3f) | 0x80;
4356
4357                Some(LocalBridgeCallResult::Immediate(Value::String(format!(
4358                    "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
4359                    bytes[0],
4360                    bytes[1],
4361                    bytes[2],
4362                    bytes[3],
4363                    bytes[4],
4364                    bytes[5],
4365                    bytes[6],
4366                    bytes[7],
4367                    bytes[8],
4368                    bytes[9],
4369                    bytes[10],
4370                    bytes[11],
4371                    bytes[12],
4372                    bytes[13],
4373                    bytes[14],
4374                    bytes[15],
4375                ))))
4376            }
4377            "_kernelStdinRead" | "_kernelStdinReadRaw" if self.forward_kernel_stdin_rpc => None,
4378            "_kernelStdinRead" | "_kernelStdinReadRaw" => Some(LocalBridgeCallResult::Immediate(
4379                self.kernel_stdin.read(args),
4380            )),
4381            "_pythonStdinRead" => Some(LocalBridgeCallResult::Immediate(
4382                self.kernel_stdin.read_python_raw(args),
4383            )),
4384            "_scheduleTimer" => {
4385                self.schedule_bridge_timer_response(call_id, timer_delay_ms(args.first()));
4386                Some(LocalBridgeCallResult::Deferred)
4387            }
4388            _ => None,
4389        }
4390    }
4391
4392    fn handle_polyfill_dispatch(&mut self, args: &[Value]) -> Value {
4393        let Some(dispatch) = args.first().and_then(Value::as_str) else {
4394            return Value::Null;
4395        };
4396        if !dispatch.starts_with("__bd:") {
4397            return polyfill_expression(dispatch)
4398                .map(Value::String)
4399                .unwrap_or(Value::Null);
4400        }
4401        let (dispatch_method, payload_json) = dispatch
4402            .strip_prefix("__bd:")
4403            .and_then(|value| value.split_once(':'))
4404            .unwrap_or(("", "[]"));
4405        let payload = serde_json::from_str::<Value>(payload_json).unwrap_or_else(|_| json!([]));
4406        let args = payload.as_array().cloned().unwrap_or_default();
4407        let result = match dispatch_method {
4408            "kernelHandleRegister" => {
4409                if let (Some(id), Some(description)) = (
4410                    args.first().and_then(Value::as_str),
4411                    args.get(1).and_then(Value::as_str),
4412                ) {
4413                    self.handle_descriptions
4414                        .insert(id.to_owned(), description.to_owned());
4415                }
4416                Value::Null
4417            }
4418            "kernelHandleUnregister" => {
4419                if let Some(id) = args.first().and_then(Value::as_str) {
4420                    self.handle_descriptions.remove(id);
4421                }
4422                Value::Null
4423            }
4424            "kernelHandleList" => Value::Array(
4425                self.handle_descriptions
4426                    .iter()
4427                    .map(|(id, description)| {
4428                        json!({
4429                            "id": id,
4430                            "description": description,
4431                        })
4432                    })
4433                    .collect(),
4434            ),
4435            "kernelTimerCreate" => {
4436                let delay_ms = timer_delay_ms(args.first());
4437                let repeat = args.get(1).and_then(Value::as_bool).unwrap_or(false);
4438                match self.create_kernel_timer(delay_ms, repeat) {
4439                    Ok(timer_id) => json!(timer_id),
4440                    Err(error) => timer_dispatch_error(error),
4441                }
4442            }
4443            "kernelTimerArm" => {
4444                if let Some(timer_id) = args.first().and_then(Value::as_u64) {
4445                    if let Err(error) = self.arm_kernel_timer(timer_id) {
4446                        return timer_dispatch_error(error);
4447                    }
4448                }
4449                Value::Null
4450            }
4451            "kernelTimerClear" => {
4452                if let Some(timer_id) = args.first().and_then(Value::as_u64) {
4453                    self.clear_kernel_timer(timer_id);
4454                }
4455                Value::Null
4456            }
4457            _ => json!({
4458                "__bd_error": {
4459                    "name": "Error",
4460                    "message": format!("No handler: {dispatch_method}"),
4461                }
4462            }),
4463        };
4464
4465        if result.get("__bd_error").is_some() {
4466            Value::String(serde_json::to_string(&result).unwrap_or_else(|_| {
4467                String::from(
4468                    "{\"__bd_error\":{\"name\":\"Error\",\"message\":\"dispatch failed\"}}",
4469                )
4470            }))
4471        } else if dispatch_method.starts_with("kernel") {
4472            Value::String(
4473                serde_json::to_string(&json!({ "__bd_result": result }))
4474                    .unwrap_or_else(|_| String::from("{\"__bd_result\":null}")),
4475            )
4476        } else {
4477            Value::String(
4478                serde_json::to_string(&json!({
4479                    "__bd_error": {
4480                        "name": "Error",
4481                        "message": format!("No handler: {dispatch_method}"),
4482                    }
4483                }))
4484                .unwrap_or_else(|_| {
4485                    String::from(
4486                        "{\"__bd_error\":{\"name\":\"Error\",\"message\":\"dispatch failed\"}}",
4487                    )
4488                }),
4489            )
4490        }
4491    }
4492
4493    fn create_kernel_timer(&mut self, delay_ms: u64, repeat: bool) -> Result<u64, String> {
4494        self.register_timer(delay_ms, repeat)
4495    }
4496
4497    /// Allocate a fresh timer id and register a one-shot (`repeat == false`)
4498    /// tracking entry at generation 0. Used by the bridge-timer path so the
4499    /// queued wheel action can be cancelled (its entry removed) on `clear`/teardown.
4500    fn register_oneshot_timer(&mut self, delay_ms: u64) -> Result<u64, String> {
4501        self.register_timer(delay_ms, false)
4502    }
4503
4504    fn register_timer(&mut self, delay_ms: u64, repeat: bool) -> Result<u64, String> {
4505        let mut timers = self.timers.lock().map_err(|_| {
4506            String::from(
4507                "ERR_AGENTOS_JAVASCRIPT_TIMER_STATE: JavaScript timer registry lock poisoned",
4508            )
4509        })?;
4510        if timers.len() >= self.max_timers {
4511            return Err(format!(
4512                "ERR_AGENTOS_JAVASCRIPT_TIMER_LIMIT: execution exceeded {} active timers; raise limits.jsRuntime.maxTimers",
4513                self.max_timers
4514            ));
4515        }
4516        let reservation = self
4517            .timer_resources
4518            .as_ref()
4519            .ok_or_else(|| {
4520                String::from(
4521                    "ERR_AGENTOS_RUNTIME_NOT_INJECTED: JavaScript timers require a resource ledger",
4522                )
4523            })?
4524            .reserve(agentos_runtime::accounting::ResourceClass::Timers, 1)
4525            .map_err(|error| error.to_string())?;
4526        let timer_id = self.next_timer_id.checked_add(1).ok_or_else(|| {
4527            String::from("ERR_AGENTOS_JAVASCRIPT_TIMER_ID_EXHAUSTED: execution exhausted timer IDs")
4528        })?;
4529        self.next_timer_id = timer_id;
4530        timers.insert(
4531            timer_id,
4532            LocalTimerEntry {
4533                delay_ms,
4534                generation: 0,
4535                repeat,
4536                _reservation: Some(reservation),
4537            },
4538        );
4539        Ok(timer_id)
4540    }
4541
4542    fn arm_kernel_timer(&self, timer_id: u64) -> Result<(), String> {
4543        let Some(session) = self.v8_session.clone() else {
4544            return Err(String::from(
4545                "ERR_AGENTOS_JAVASCRIPT_TIMER_SESSION: timer has no live V8 session",
4546            ));
4547        };
4548
4549        let (delay_ms, generation, timers) = {
4550            let mut timers = self.timers.lock().map_err(|_| {
4551                String::from(
4552                    "ERR_AGENTOS_JAVASCRIPT_TIMER_STATE: JavaScript timer registry lock poisoned",
4553                )
4554            })?;
4555            let entry = timers.get_mut(&timer_id).ok_or_else(|| {
4556                format!("ERR_AGENTOS_JAVASCRIPT_TIMER_UNKNOWN: unknown timer {timer_id}")
4557            })?;
4558            entry.generation = entry.generation.checked_add(1).ok_or_else(|| {
4559                format!(
4560                    "ERR_AGENTOS_JAVASCRIPT_TIMER_GENERATION_EXHAUSTED: timer {timer_id} exhausted generations"
4561                )
4562            })?;
4563            (entry.delay_ms, entry.generation, self.timers.clone())
4564        };
4565
4566        let runtime = self.runtime.as_ref().ok_or_else(|| {
4567            String::from(
4568                "ERR_AGENTOS_RUNTIME_NOT_INJECTED: JavaScript timers require a process RuntimeContext",
4569            )
4570        })?;
4571        TimerWheel::get(runtime)?.schedule(
4572            delay_ms,
4573            TimerAction::StreamEvent {
4574                session,
4575                timer_id,
4576                generation,
4577                timers,
4578            },
4579        )
4580    }
4581
4582    fn clear_kernel_timer(&self, timer_id: u64) {
4583        if let Ok(mut timers) = self.timers.lock() {
4584            timers.remove(&timer_id);
4585        }
4586        if let Some(wheel) = JAVASCRIPT_TIMER_WHEEL.get() {
4587            wheel.cancel(&self.timers, timer_id);
4588        }
4589    }
4590
4591    fn schedule_bridge_timer_response(&mut self, call_id: u64, delay_ms: u64) {
4592        let Some(session) = self.v8_session.clone() else {
4593            return;
4594        };
4595
4596        // Register the bridge timer in the shared `timers` map with a generation,
4597        // mirroring the kernel-timer cancellation path. Tracking it means that
4598        // when `LocalBridgeState` is dropped on session teardown (which clears the
4599        // map) or the entry is otherwise removed, the timer wheel observes the
4600        // missing/mismatched generation via `timer_should_fire` and suppresses the
4601        // response instead of touching the torn-down session.
4602        let timer_id = match self.register_oneshot_timer(delay_ms) {
4603            Ok(timer_id) => timer_id,
4604            Err(error) => {
4605                settle_timer_bridge_response(&session, call_id, 1, error.into_bytes());
4606                return;
4607            }
4608        };
4609        let generation = 0;
4610        let timers = self.timers.clone();
4611
4612        let Some(runtime) = self.runtime.as_ref() else {
4613            self.clear_kernel_timer(timer_id);
4614            let error = "ERR_AGENTOS_RUNTIME_NOT_INJECTED: JavaScript timers require a process RuntimeContext";
4615            settle_timer_bridge_response(&session, call_id, 1, error.as_bytes().to_vec());
4616            return;
4617        };
4618        let wheel = match TimerWheel::get(runtime) {
4619            Ok(wheel) => wheel,
4620            Err(error) => {
4621                self.clear_kernel_timer(timer_id);
4622                settle_timer_bridge_response(&session, call_id, 1, error.into_bytes());
4623                return;
4624            }
4625        };
4626        if let Err(error) = wheel.schedule(
4627            delay_ms,
4628            TimerAction::BridgeResponse {
4629                session: session.clone(),
4630                call_id,
4631                timer_id,
4632                generation,
4633                timers,
4634            },
4635        ) {
4636            self.clear_kernel_timer(timer_id);
4637            settle_timer_bridge_response(&session, call_id, 1, error.into_bytes());
4638        }
4639    }
4640
4641    fn has_module_reader(&self) -> bool {
4642        self.module_reader.is_some()
4643    }
4644
4645    fn batch_resolve_modules(&mut self, args: &[Value]) -> Value {
4646        self.with_module_resolver(|resolver| resolver.batch_resolve_modules(args))
4647    }
4648
4649    fn resolve_module(
4650        &mut self,
4651        specifier: &str,
4652        from_dir: &str,
4653        mode: ModuleResolveMode,
4654    ) -> Option<String> {
4655        if self.js_runtime_denies_specifier(specifier) {
4656            if std::env::var("AGENTOS_MODULE_READER_TRACE").is_ok() {
4657                eprintln!("resolve DENIED: {specifier} from {from_dir}");
4658            }
4659            return None;
4660        }
4661        let resolved = self
4662            .with_module_resolver(|resolver| resolver.resolve_module(specifier, from_dir, mode));
4663        if resolved.is_none() && std::env::var("AGENTOS_MODULE_READER_TRACE").is_ok() {
4664            eprintln!("resolve MISS: {specifier} from {from_dir} mode={mode:?}");
4665        }
4666        resolved
4667    }
4668
4669    /// jsRuntime resolution gate. Denies builtin and bare/relative imports per the
4670    /// configured `moduleResolution` and builtin allow-list, before the resolver
4671    /// touches the VFS. This is the authoritative chokepoint for the live
4672    /// shared-V8 path (both `import`/`import()` and `require`/`createRequire`
4673    /// route through `_resolveModule` -> here).
4674    fn js_runtime_denies_specifier(&self, specifier: &str) -> bool {
4675        let is_local = specifier.starts_with("./")
4676            || specifier.starts_with("../")
4677            || specifier == "."
4678            || specifier == ".."
4679            || specifier.starts_with('/')
4680            || specifier.starts_with("file:");
4681        match self.module_resolution {
4682            GuestModuleResolution::Node => false,
4683            // Relative permits local files only; bare specifiers and package
4684            // imports (`#...`) do not resolve.
4685            GuestModuleResolution::Relative => !is_local,
4686            // None denies every specifier, local included.
4687            GuestModuleResolution::None => true,
4688        }
4689    }
4690
4691    fn module_format(&mut self, path: &str) -> Option<LocalResolvedModuleFormat> {
4692        self.with_module_resolver(|resolver| resolver.module_format(path))
4693    }
4694
4695    fn load_file(&mut self, path: &str) -> Option<String> {
4696        self.with_module_resolver(|resolver| resolver.load_file(path))
4697    }
4698
4699    /// Run `f` against a resolver bound to this bridge's resolution cache, reading
4700    /// through the supplied VFS `module_reader` when present (the live VM path:
4701    /// resolution executes here on the bridge thread, reading the mounted
4702    /// `node_modules` filesystem in parallel with the service loop), or through
4703    /// the host-backed path translator otherwise (the legacy host-direct path
4704    /// used by `handle_internal_bridge_call_from_host_context`).
4705    fn with_module_resolver<T>(
4706        &mut self,
4707        f: impl FnOnce(&mut ModuleResolver<'_, &mut dyn ModuleFsReader>) -> T,
4708    ) -> T {
4709        let cache = &mut self.resolution_cache;
4710        if let Some(reader) = self.module_reader.as_deref_mut() {
4711            let reader: &mut dyn ModuleFsReader = reader;
4712            let mut resolver = ModuleResolver { reader, cache };
4713            f(&mut resolver)
4714        } else {
4715            let mut translator = &mut self.translator;
4716            let reader: &mut dyn ModuleFsReader = &mut translator;
4717            let mut resolver = ModuleResolver { reader, cache };
4718            f(&mut resolver)
4719        }
4720    }
4721}
4722
4723impl ModuleFsReader for &mut dyn ModuleFsReader {
4724    fn canonical_guest_path(&mut self, guest_path: &str) -> Option<String> {
4725        (**self).canonical_guest_path(guest_path)
4726    }
4727
4728    fn read_to_string(&mut self, guest_path: &str) -> Option<String> {
4729        (**self).read_to_string(guest_path)
4730    }
4731
4732    fn path_is_dir(&mut self, guest_path: &str) -> Option<bool> {
4733        (**self).path_is_dir(guest_path)
4734    }
4735
4736    fn path_exists(&mut self, guest_path: &str) -> bool {
4737        (**self).path_exists(guest_path)
4738    }
4739}
4740
4741impl ModuleFsReader for &mut GuestPathTranslator {
4742    fn canonical_guest_path(&mut self, guest_path: &str) -> Option<String> {
4743        GuestPathTranslator::canonical_guest_path(self, guest_path)
4744    }
4745
4746    fn read_to_string(&mut self, guest_path: &str) -> Option<String> {
4747        let host_path = self.guest_to_host(guest_path)?;
4748        fs::read_to_string(host_path).ok()
4749    }
4750
4751    fn path_is_dir(&mut self, guest_path: &str) -> Option<bool> {
4752        self.guest_to_host(guest_path)
4753            .and_then(|host_path| fs::metadata(host_path).ok())
4754            .map(|metadata| metadata.is_dir())
4755    }
4756
4757    fn path_exists(&mut self, guest_path: &str) -> bool {
4758        self.guest_to_host(guest_path)
4759            .map(|host_path| host_path.exists())
4760            .unwrap_or(false)
4761    }
4762}
4763
4764/// Standard Node module resolution executed as pure path algebra over a
4765/// [`ModuleFsReader`]. The same algorithm backs both the legacy host-direct
4766/// path (reader = host path translator) and the live VM path (reader = kernel
4767/// VFS), guaranteeing they resolve identically.
4768pub struct ModuleResolver<'a, R: ModuleFsReader> {
4769    reader: R,
4770    cache: &'a mut LocalModuleResolutionCache,
4771}
4772
4773impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> {
4774    /// Construct a resolver over `reader`, reusing `cache` across calls. The
4775    /// cache must be persisted per-VM so cold-start resolution does not rebuild
4776    /// it on every dispatch.
4777    pub fn new(reader: R, cache: &'a mut LocalModuleResolutionCache) -> Self {
4778        Self { reader, cache }
4779    }
4780
4781    pub fn batch_resolve_modules(&mut self, args: &[Value]) -> Value {
4782        let requests = args
4783            .first()
4784            .and_then(Value::as_array)
4785            .cloned()
4786            .unwrap_or_default();
4787        Value::Array(
4788            requests
4789                .into_iter()
4790                .map(|request| {
4791                    let pair = request.as_array().cloned().unwrap_or_default();
4792                    let specifier = pair.first().and_then(Value::as_str).unwrap_or("");
4793                    let referrer = pair.get(1).and_then(Value::as_str).unwrap_or("/");
4794                    self.resolve_module(specifier, referrer, ModuleResolveMode::Import)
4795                        .and_then(|resolved| {
4796                            self.load_file(&resolved).map(|source| {
4797                                json!({
4798                                    "resolved": resolved,
4799                                    "source": source,
4800                                })
4801                            })
4802                        })
4803                        .unwrap_or(Value::Null)
4804                })
4805                .collect(),
4806        )
4807    }
4808
4809    pub fn resolve_module(
4810        &mut self,
4811        specifier: &str,
4812        from_dir: &str,
4813        mode: ModuleResolveMode,
4814    ) -> Option<String> {
4815        let normalized_from_path = self
4816            .reader
4817            .canonical_guest_path(from_dir)
4818            .unwrap_or_else(|| normalize_guest_path(from_dir));
4819        let normalized_from = if self.cached_stat(&normalized_from_path) == Some(false) {
4820            dirname_guest_path(&normalized_from_path)
4821        } else {
4822            normalize_module_resolve_context(&normalized_from_path)
4823        };
4824        let cache_key = (specifier.to_owned(), normalized_from.clone(), mode);
4825        if let Some(cached) = self.cache.resolve_results.get(&cache_key) {
4826            return cached.clone();
4827        }
4828
4829        let resolved = if let Some(builtin) = normalize_builtin_specifier(specifier) {
4830            Some(builtin)
4831        } else if specifier.starts_with("file:") {
4832            guest_path_from_file_url(specifier)
4833                .and_then(|file_path| self.resolve_path(&file_path, mode))
4834        } else if specifier.starts_with('/') {
4835            self.resolve_path(specifier, mode)
4836        } else if specifier.starts_with("./")
4837            || specifier.starts_with("../")
4838            || specifier == "."
4839            || specifier == ".."
4840        {
4841            self.resolve_path(&join_guest_path(&normalized_from, specifier), mode)
4842        } else if specifier.starts_with('#') {
4843            self.resolve_package_imports(specifier, &normalized_from, mode)
4844        } else {
4845            self.resolve_package_self_reference(specifier, &normalized_from, mode)
4846                .or_else(|| self.resolve_node_modules(specifier, &normalized_from, mode))
4847        };
4848
4849        if resolved.is_some() || module_resolution_miss_is_stable(&normalized_from) {
4850            self.cache
4851                .resolve_results
4852                .insert(cache_key, resolved.clone());
4853        }
4854        resolved
4855    }
4856
4857    pub fn load_file(&mut self, path: &str) -> Option<String> {
4858        let bare = path.trim_start_matches("node:");
4859        if is_builtin_specifier(path) {
4860            return Some(build_builtin_module_wrapper(bare));
4861        }
4862
4863        let source = self.reader.read_to_string(path)?;
4864        Some(
4865            if matches!(
4866                Path::new(path).extension().and_then(|ext| ext.to_str()),
4867                Some("js" | "mjs" | "cjs")
4868            ) {
4869                strip_javascript_hashbang(&source)
4870            } else {
4871                source
4872            },
4873        )
4874    }
4875
4876    pub fn module_format(&mut self, path: &str) -> Option<LocalResolvedModuleFormat> {
4877        if let Some(cached) = self.cache.module_format_results.get(path) {
4878            return *cached;
4879        }
4880
4881        let format = self.detect_module_format(path);
4882        self.cache
4883            .module_format_results
4884            .insert(path.to_owned(), format);
4885        format
4886    }
4887
4888    fn detect_module_format(&mut self, path: &str) -> Option<LocalResolvedModuleFormat> {
4889        if is_builtin_specifier(path) {
4890            return Some(LocalResolvedModuleFormat::Module);
4891        }
4892
4893        let normalized = normalize_guest_path(path);
4894        match Path::new(&normalized)
4895            .extension()
4896            .and_then(|ext| ext.to_str())
4897        {
4898            Some("mjs" | "mts") => Some(LocalResolvedModuleFormat::Module),
4899            Some("cjs" | "cts") => Some(LocalResolvedModuleFormat::Commonjs),
4900            Some("json") => Some(LocalResolvedModuleFormat::Json),
4901            Some("js") => Some(
4902                if self
4903                    .nearest_package_json_type_for_guest_path(&normalized)
4904                    .as_deref()
4905                    == Some("module")
4906                {
4907                    LocalResolvedModuleFormat::Module
4908                } else {
4909                    LocalResolvedModuleFormat::Commonjs
4910                },
4911            ),
4912            _ => None,
4913        }
4914    }
4915
4916    fn nearest_package_json_type_for_guest_path(&mut self, guest_path: &str) -> Option<String> {
4917        let mut dir = dirname_guest_path(guest_path);
4918        loop {
4919            let package_json_path = join_guest_path(&dir, "package.json");
4920            if let Some(package_json) = self.read_package_json(&package_json_path) {
4921                return package_json.package_type;
4922            }
4923            // Node package scopes do not inherit `type` across a node_modules
4924            // boundary. This also matters for pnpm's nested symlink layout: if
4925            // a package.json read is unavailable at the symlinked package root,
4926            // climbing into the fixture's `type: module` package would
4927            // incorrectly classify a dependency's CommonJS `.js` files as ESM.
4928            if dir == "/" || dir.rsplit('/').next() == Some("node_modules") {
4929                break;
4930            }
4931            dir = dirname_guest_path(&dir);
4932        }
4933        None
4934    }
4935
4936    fn resolve_package_imports(
4937        &mut self,
4938        request: &str,
4939        from_dir: &str,
4940        mode: ModuleResolveMode,
4941    ) -> Option<String> {
4942        let mut dir = normalize_guest_path(from_dir);
4943        loop {
4944            let pkg_json_path = join_guest_path(&dir, "package.json");
4945            if let Some(pkg_json) = self.read_package_json(&pkg_json_path) {
4946                if let Some(imports) = &pkg_json.imports {
4947                    if let Some(target) = resolve_imports_target(imports, request, mode) {
4948                        let target_path = if target.starts_with('/') {
4949                            target
4950                        } else {
4951                            join_guest_path(&dir, &target)
4952                        };
4953                        return self.resolve_path(&target_path, mode);
4954                    }
4955                    return None;
4956                }
4957            }
4958            if dir == "/" {
4959                break;
4960            }
4961            dir = dirname_guest_path(&dir);
4962        }
4963        None
4964    }
4965
4966    fn resolve_package_self_reference(
4967        &mut self,
4968        request: &str,
4969        from_dir: &str,
4970        mode: ModuleResolveMode,
4971    ) -> Option<String> {
4972        let (package_name, subpath) = split_package_request(request)?;
4973        let mut dir = normalize_guest_path(from_dir);
4974        loop {
4975            let pkg_json_path = join_guest_path(&dir, "package.json");
4976            if let Some(pkg_json) = self.read_package_json(&pkg_json_path) {
4977                if pkg_json.name.as_deref() == Some(package_name) {
4978                    return self.resolve_package_entry_from_dir(&dir, subpath, mode);
4979                }
4980            }
4981            if dir == "/" {
4982                break;
4983            }
4984            dir = dirname_guest_path(&dir);
4985        }
4986        None
4987    }
4988
4989    fn resolve_node_modules(
4990        &mut self,
4991        request: &str,
4992        from_dir: &str,
4993        mode: ModuleResolveMode,
4994    ) -> Option<String> {
4995        let (package_name, subpath) = split_package_request(request)?;
4996
4997        // Standard Node resolution over the faithful VFS: walk ancestor
4998        // `node_modules` directories (following symlinks via the importer's
4999        // realpath). pnpm/yarn layouts resolve because the VFS exposes their
5000        // symlinks, not because the resolver understands package-manager
5001        // internals (see CLAUDE.md npm Compatibility).
5002        let mut dir = normalize_guest_path(from_dir);
5003        loop {
5004            for package_dir in node_modules_direct_candidate_dirs(&dir, package_name) {
5005                if let Some(entry) =
5006                    self.resolve_package_entry_from_dir(&package_dir, subpath, mode)
5007                {
5008                    return Some(entry);
5009                }
5010            }
5011            if dir == "/" {
5012                break;
5013            }
5014            dir = dirname_guest_path(&dir);
5015        }
5016
5017        ["/root/node_modules", "/node_modules"]
5018            .into_iter()
5019            .find_map(|root| {
5020                self.resolve_package_entry_from_dir(
5021                    &join_guest_path(root, package_name),
5022                    subpath,
5023                    mode,
5024                )
5025            })
5026    }
5027
5028    fn resolve_package_entry_from_dir(
5029        &mut self,
5030        package_dir: &str,
5031        subpath: &str,
5032        mode: ModuleResolveMode,
5033    ) -> Option<String> {
5034        let package_json_path = join_guest_path(package_dir, "package.json");
5035        let pkg_json = self.read_package_json(&package_json_path);
5036        if pkg_json.is_none() && !self.cached_exists(package_dir) {
5037            return None;
5038        }
5039
5040        if let Some(pkg_json) = pkg_json.as_ref() {
5041            if let Some(exports) = &pkg_json.exports {
5042                let exports_subpath = if subpath.is_empty() {
5043                    String::from(".")
5044                } else {
5045                    format!("./{subpath}")
5046                };
5047                let exports_target = resolve_exports_target(exports, &exports_subpath, mode)?;
5048                let target_path = join_guest_path(package_dir, &exports_target);
5049                return self.resolve_path(&target_path, mode).or(Some(target_path));
5050            }
5051        }
5052
5053        if !subpath.is_empty() {
5054            return self.resolve_path(&join_guest_path(package_dir, subpath), mode);
5055        }
5056
5057        let entry_field = pkg_json
5058            .as_ref()
5059            .and_then(|pkg_json| pkg_json.main.as_deref())
5060            .unwrap_or("index.js");
5061        let entry_path = join_guest_path(package_dir, entry_field);
5062        self.resolve_path(&entry_path, mode)
5063            .or_else(|| self.resolve_path(&join_guest_path(package_dir, "index"), mode))
5064    }
5065
5066    fn resolve_path(&mut self, base_path: &str, mode: ModuleResolveMode) -> Option<String> {
5067        if self.cached_stat(base_path) == Some(false) {
5068            return Some(normalize_guest_path(base_path));
5069        }
5070
5071        for extension in [".js", ".json", ".mjs", ".cjs"] {
5072            let candidate = format!("{}{}", normalize_guest_path(base_path), extension);
5073            if self.cached_exists(&candidate) {
5074                return Some(candidate);
5075            }
5076        }
5077
5078        if self.cached_stat(base_path) == Some(true) {
5079            let pkg_json_path = join_guest_path(base_path, "package.json");
5080            if let Some(pkg_json) = self.read_package_json(&pkg_json_path) {
5081                if let Some(main) = pkg_json.main.as_deref() {
5082                    let entry_path = join_guest_path(base_path, main);
5083                    if entry_path != normalize_guest_path(base_path) {
5084                        if let Some(entry) = self.resolve_path(&entry_path, mode) {
5085                            return Some(entry);
5086                        }
5087                    }
5088                }
5089                if mode == ModuleResolveMode::Import
5090                    && pkg_json.package_type.as_deref() == Some("module")
5091                    && self.cached_exists(&join_guest_path(base_path, "index.js"))
5092                {
5093                    return Some(join_guest_path(base_path, "index.js"));
5094                }
5095            }
5096
5097            for extension in [".js", ".json", ".mjs", ".cjs"] {
5098                let index_path = join_guest_path(base_path, &format!("index{extension}"));
5099                if self.cached_exists(&index_path) {
5100                    return Some(index_path);
5101                }
5102            }
5103        }
5104
5105        None
5106    }
5107
5108    fn read_package_json(&mut self, guest_path: &str) -> Option<LocalPackageJson> {
5109        if let Some(cached) = self.cache.package_json_results.get(guest_path).cloned() {
5110            return cached;
5111        }
5112
5113        let parsed = self
5114            .reader
5115            .read_to_string(guest_path)
5116            .and_then(|contents| serde_json::from_str::<LocalPackageJson>(&contents).ok());
5117        if parsed.is_some() || module_path_miss_is_stable(guest_path) {
5118            self.cache
5119                .package_json_results
5120                .insert(guest_path.to_owned(), parsed.clone());
5121        }
5122        parsed
5123    }
5124
5125    fn cached_exists(&mut self, guest_path: &str) -> bool {
5126        if let Some(cached) = self.cache.exists_results.get(guest_path) {
5127            return *cached;
5128        }
5129        let exists = self.reader.path_exists(guest_path);
5130        if exists || module_path_miss_is_stable(guest_path) {
5131            self.cache
5132                .exists_results
5133                .insert(guest_path.to_owned(), exists);
5134        }
5135        exists
5136    }
5137
5138    fn cached_stat(&mut self, guest_path: &str) -> Option<bool> {
5139        if let Some(cached) = self.cache.stat_results.get(guest_path) {
5140            return *cached;
5141        }
5142        let result = self.reader.path_is_dir(guest_path);
5143        if result.is_some() || module_path_miss_is_stable(guest_path) {
5144            self.cache
5145                .stat_results
5146                .insert(guest_path.to_owned(), result);
5147        }
5148        result
5149    }
5150}
5151
5152fn module_resolution_miss_is_stable(from_dir: &str) -> bool {
5153    module_path_miss_is_stable(from_dir)
5154}
5155
5156fn module_path_miss_is_stable(guest_path: &str) -> bool {
5157    guest_path == "/node_modules"
5158        || guest_path.ends_with("/node_modules")
5159        || guest_path.contains("/node_modules/")
5160}
5161
5162fn guest_path_from_file_url(specifier: &str) -> Option<String> {
5163    if !specifier.starts_with("file:") {
5164        return None;
5165    }
5166
5167    let mut pathname = if let Some(stripped) = specifier.strip_prefix("file://") {
5168        stripped
5169    } else {
5170        specifier.strip_prefix("file:")?
5171    };
5172
5173    if let Some(terminator_index) = pathname.find(['?', '#']) {
5174        pathname = &pathname[..terminator_index];
5175    }
5176
5177    if !pathname.starts_with('/') {
5178        let slash_index = pathname.find('/')?;
5179        let host = &pathname[..slash_index];
5180        if !host.is_empty() && host != "localhost" {
5181            return None;
5182        }
5183        pathname = &pathname[slash_index..];
5184    }
5185
5186    Some(normalize_guest_path(&percent_decode(pathname)?))
5187}
5188
5189fn percent_decode(raw: &str) -> Option<String> {
5190    let bytes = raw.as_bytes();
5191    let mut index = 0;
5192    let mut decoded = Vec::with_capacity(bytes.len());
5193    while index < bytes.len() {
5194        match bytes[index] {
5195            b'%' if index + 2 < bytes.len() => {
5196                if let (Some(high), Some(low)) =
5197                    (hex_digit(bytes[index + 1]), hex_digit(bytes[index + 2]))
5198                {
5199                    let value = (high << 4) | low;
5200                    decoded.push(value);
5201                    index += 3;
5202                } else {
5203                    decoded.push(bytes[index]);
5204                    index += 1;
5205                }
5206            }
5207            byte => {
5208                decoded.push(byte);
5209                index += 1;
5210            }
5211        }
5212    }
5213    String::from_utf8(decoded).ok()
5214}
5215
5216fn hex_digit(byte: u8) -> Option<u8> {
5217    match byte {
5218        b'0'..=b'9' => Some(byte - b'0'),
5219        b'a'..=b'f' => Some(byte - b'a' + 10),
5220        b'A'..=b'F' => Some(byte - b'A' + 10),
5221        _ => None,
5222    }
5223}
5224
5225impl LocalKernelStdinBridge {
5226    fn write(&self, chunk: &[u8]) -> Result<(), JavascriptExecutionError> {
5227        let mut state = self.state.lock().expect("kernel stdin state poisoned");
5228        if state.closed {
5229            return Err(JavascriptExecutionError::StdinClosed);
5230        }
5231        let next_len = state.bytes.len().checked_add(chunk.len()).ok_or_else(|| {
5232            JavascriptExecutionError::Stdin(std::io::Error::new(
5233                std::io::ErrorKind::InvalidData,
5234                format!("guest stdin buffer exceeded {KERNEL_STDIN_BUFFER_LIMIT_BYTES} bytes"),
5235            ))
5236        })?;
5237        if next_len > KERNEL_STDIN_BUFFER_LIMIT_BYTES {
5238            return Err(JavascriptExecutionError::Stdin(std::io::Error::new(
5239                std::io::ErrorKind::InvalidData,
5240                format!("guest stdin buffer exceeded {KERNEL_STDIN_BUFFER_LIMIT_BYTES} bytes"),
5241            )));
5242        }
5243
5244        state.bytes.extend(chunk.iter().copied());
5245        self.ready.notify_all();
5246        Ok(())
5247    }
5248
5249    fn close(&self) {
5250        let mut state = self.state.lock().expect("kernel stdin state poisoned");
5251        state.closed = true;
5252        self.ready.notify_all();
5253    }
5254
5255    fn read(&self, args: &[Value]) -> Value {
5256        let max_bytes = args
5257            .first()
5258            .and_then(Value::as_u64)
5259            .map(|value| value.clamp(1, 64 * 1024) as usize)
5260            .unwrap_or(64 * 1024);
5261        let deadline = if args.get(1).is_some_and(Value::is_null) {
5262            None
5263        } else {
5264            let timeout = Duration::from_millis(args.get(1).and_then(Value::as_u64).unwrap_or(100));
5265            Some(Instant::now() + timeout)
5266        };
5267        let mut state = self.state.lock().expect("kernel stdin state poisoned");
5268
5269        while state.bytes.is_empty() && !state.closed {
5270            if let Some(deadline) = deadline {
5271                let remaining = deadline.saturating_duration_since(Instant::now());
5272                if remaining.is_zero() {
5273                    return Value::Null;
5274                }
5275                let (next_state, wait_result) = self
5276                    .ready
5277                    .wait_timeout(state, remaining)
5278                    .expect("kernel stdin wait poisoned");
5279                state = next_state;
5280                if wait_result.timed_out() && state.bytes.is_empty() && !state.closed {
5281                    return Value::Null;
5282                }
5283            } else {
5284                state = self.ready.wait(state).expect("kernel stdin wait poisoned");
5285            }
5286        }
5287
5288        if !state.bytes.is_empty() {
5289            let read_len = state.bytes.len().min(max_bytes);
5290            let bytes = state.bytes.drain(..read_len).collect::<Vec<_>>();
5291            return json!({
5292                "dataBase64": v8_runtime::base64_encode_pub(&bytes),
5293            });
5294        }
5295
5296        json!({
5297            "done": true,
5298        })
5299    }
5300
5301    fn read_python_raw(&self, args: &[Value]) -> Value {
5302        const PYTHON_STDIN_DONE_SENTINEL: &str = "__AGENTOS_PYTHON_STDIN_DONE__";
5303
5304        let max_bytes = args
5305            .first()
5306            .and_then(Value::as_u64)
5307            .map(|value| value.clamp(1, 64 * 1024) as usize)
5308            .unwrap_or(64 * 1024);
5309        let timeout = Duration::from_millis(args.get(1).and_then(Value::as_u64).unwrap_or(100));
5310        let deadline = Instant::now() + timeout;
5311        let mut state = self.state.lock().expect("kernel stdin state poisoned");
5312
5313        while state.bytes.is_empty() && !state.closed {
5314            let remaining = deadline.saturating_duration_since(Instant::now());
5315            if remaining.is_zero() {
5316                return Value::Null;
5317            }
5318            let (next_state, wait_result) = self
5319                .ready
5320                .wait_timeout(state, remaining)
5321                .expect("kernel stdin wait poisoned");
5322            state = next_state;
5323            if wait_result.timed_out() && state.bytes.is_empty() && !state.closed {
5324                return Value::Null;
5325            }
5326        }
5327
5328        if !state.bytes.is_empty() {
5329            let read_len = state.bytes.len().min(max_bytes);
5330            let bytes = state.bytes.drain(..read_len).collect::<Vec<_>>();
5331            return Value::String(v8_runtime::base64_encode_pub(&bytes));
5332        }
5333
5334        Value::String(String::from(PYTHON_STDIN_DONE_SENTINEL))
5335    }
5336}
5337
5338fn normalize_module_resolve_context(path: &str) -> String {
5339    let normalized = normalize_guest_path(path);
5340    if normalized == "/[eval]"
5341        || normalized.ends_with("/[eval]")
5342        || normalized.ends_with(".js")
5343        || normalized.ends_with(".mjs")
5344        || normalized.ends_with(".cjs")
5345        || normalized.ends_with(".json")
5346        || normalized.ends_with(".ts")
5347        || normalized.ends_with(".mts")
5348        || normalized.ends_with(".cts")
5349    {
5350        dirname_guest_path(&normalized)
5351    } else {
5352        normalized
5353    }
5354}
5355
5356fn strip_javascript_hashbang(source: &str) -> String {
5357    if let Some(stripped) = source.strip_prefix("#!") {
5358        match stripped.find('\n') {
5359            Some(index) => format!("\n{}", &stripped[index + 1..]),
5360            None => String::new(),
5361        }
5362    } else {
5363        source.to_owned()
5364    }
5365}
5366
5367fn parse_process_exit_code_message(message: &str) -> Option<i32> {
5368    let code = message.strip_prefix("process.exit(")?.strip_suffix(')')?;
5369    code.parse::<i32>().ok()
5370}
5371
5372fn dirname_guest_path(path: &str) -> String {
5373    let normalized = normalize_guest_path(path);
5374    if normalized == "/" {
5375        return normalized;
5376    }
5377    normalized
5378        .rsplit_once('/')
5379        .map(|(parent, _)| {
5380            if parent.is_empty() {
5381                String::from("/")
5382            } else {
5383                parent.to_owned()
5384            }
5385        })
5386        .unwrap_or_else(|| String::from("/"))
5387}
5388
5389fn normalize_builtin_specifier(specifier: &str) -> Option<String> {
5390    let bare = specifier.trim_start_matches("node:");
5391    match bare {
5392        "assert"
5393        | "assert/strict"
5394        | "async_hooks"
5395        | "buffer"
5396        | "child_process"
5397        | "cluster"
5398        | "console"
5399        | "constants"
5400        | "crypto"
5401        | "dgram"
5402        | "diagnostics_channel"
5403        | "dns"
5404        | "dns/promises"
5405        | "events"
5406        | "fs"
5407        | "fs/promises"
5408        | "http"
5409        | "http2"
5410        | "https"
5411        | "inspector"
5412        | "module"
5413        | "net"
5414        | "os"
5415        | "path"
5416        | "path/posix"
5417        | "path/win32"
5418        | "perf_hooks"
5419        | "process"
5420        | "punycode"
5421        | "querystring"
5422        | "readline"
5423        | "repl"
5424        | "sqlite"
5425        | "stream"
5426        | "stream/consumers"
5427        | "stream/promises"
5428        | "stream/web"
5429        | "string_decoder"
5430        | "sys"
5431        | "timers"
5432        | "tls"
5433        | "timers/promises"
5434        | "test"
5435        | "test/reporters"
5436        | "trace_events"
5437        | "tty"
5438        | "url"
5439        | "util"
5440        | "util/types"
5441        | "domain"
5442        | "vm"
5443        | "v8"
5444        | "wasi"
5445        | "worker_threads"
5446        | "zlib" => Some(format!("node:{bare}")),
5447        _ => None,
5448    }
5449}
5450
5451fn is_builtin_specifier(specifier: &str) -> bool {
5452    normalize_builtin_specifier(specifier).is_some()
5453}
5454
5455fn polyfill_expression(request: &str) -> Option<String> {
5456    let normalized = request.trim_start_matches("node:");
5457    let entry = polyfill_registry()
5458        .groups
5459        .iter()
5460        .find(|group| group.names.iter().any(|name| name == normalized))?;
5461
5462    Some(match entry.source {
5463        PolyfillSourceKind::NodeStdlibBrowser | PolyfillSourceKind::CustomBridge => format!(
5464            "globalThis._requireFrom({}, \"/\")",
5465            serde_json::to_string(&format!("node:{normalized}"))
5466                .unwrap_or_else(|_| format!("\"node:{normalized}\""))
5467        ),
5468        PolyfillSourceKind::Denied => {
5469            let error_code = entry.error_code.as_deref().unwrap_or("ERR_ACCESS_DENIED");
5470            format!(
5471                "(() => {{ const error = new Error({message}); error.code = {code}; throw error; }})()",
5472                message = serde_json::to_string(&format!(
5473                    "node:{normalized} is not available in the secure-exec guest runtime"
5474                ))
5475                .unwrap_or_else(|_| format!(
5476                    "\"node:{normalized} is not available in the secure-exec guest runtime\""
5477                )),
5478                code = serde_json::to_string(error_code)
5479                    .unwrap_or_else(|_| "\"ERR_ACCESS_DENIED\"".to_owned())
5480            )
5481        }
5482    })
5483}
5484
5485fn build_builtin_module_wrapper(module_name: &str) -> String {
5486    if matches!(
5487        module_name,
5488        "assert"
5489            | "assert/strict"
5490            | "path"
5491            | "path/posix"
5492            | "path/win32"
5493            | "string_decoder"
5494            | "url"
5495    ) {
5496        return build_delegating_builtin_module_wrapper(module_name);
5497    }
5498
5499    if module_name == "test" {
5500        return String::from(
5501            r#"const state = globalThis.__agentOSNodeTestState ??= {
5502  tests: [],
5503  suite: [],
5504  before: [],
5505  after: [],
5506  beforeEach: [],
5507  afterEach: [],
5508  ran: false,
5509};
5510
5511function normalizeTest(name, optionsOrFn, maybeFn) {
5512  const options = typeof optionsOrFn === "object" && optionsOrFn !== null ? optionsOrFn : {};
5513  const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
5514  return { name: String(name), options, fn };
5515}
5516
5517function test(name, optionsOrFn, maybeFn) {
5518  const record = normalizeTest(name, optionsOrFn, maybeFn);
5519  state.tests.push({
5520    ...record,
5521    name: [...state.suite, record.name].join(" > "),
5522  });
5523}
5524test.skip = (name, optionsOrFn, maybeFn) => {
5525  const record = normalizeTest(name, optionsOrFn, maybeFn);
5526  test(record.name, { ...record.options, skip: true }, record.fn);
5527};
5528test.todo = (name, optionsOrFn, maybeFn) => {
5529  const record = normalizeTest(name, optionsOrFn, maybeFn);
5530  test(record.name, { ...record.options, todo: true }, record.fn);
5531};
5532test.only = test;
5533
5534function describe(name, optionsOrFn, maybeFn) {
5535  const record = normalizeTest(name, optionsOrFn, maybeFn);
5536  state.suite.push(record.name);
5537  try {
5538    record.fn?.();
5539  } finally {
5540    state.suite.pop();
5541  }
5542}
5543describe.skip = (_name, _optionsOrFn, _maybeFn) => {};
5544describe.only = describe;
5545
5546function before(fn) { state.before.push(fn); }
5547function after(fn) { state.after.push(fn); }
5548function beforeEach(fn) { state.beforeEach.push(fn); }
5549function afterEach(fn) { state.afterEach.push(fn); }
5550
5551async function __agentOSRunTests(namePattern) {
5552  if (state.ran) throw new Error("node:test runner was already consumed");
5553  state.ran = true;
5554  const pattern = namePattern ? new RegExp(namePattern) : null;
5555  const records = pattern ? state.tests.filter((record) => pattern.test(record.name)) : state.tests;
5556  let passed = 0;
5557  let failed = 0;
5558  let skipped = 0;
5559  console.log("TAP version 13");
5560  for (const hook of state.before) await hook();
5561  for (let index = 0; index < records.length; index += 1) {
5562    const record = records[index];
5563    if (record.options.skip || record.options.todo || typeof record.fn !== "function") {
5564      skipped += 1;
5565      console.log(`ok ${index + 1} - ${record.name} # SKIP`);
5566      continue;
5567    }
5568    try {
5569      for (const hook of state.beforeEach) await hook();
5570      const context = { test, skip() { throw Object.assign(new Error("skip"), { __agentOSTestSkip: true }); } };
5571      await record.fn(context);
5572      passed += 1;
5573      console.log(`ok ${index + 1} - ${record.name}`);
5574    } catch (error) {
5575      if (error?.__agentOSTestSkip) {
5576        skipped += 1;
5577        console.log(`ok ${index + 1} - ${record.name} # SKIP`);
5578      } else {
5579        failed += 1;
5580        console.log(`not ok ${index + 1} - ${record.name}`);
5581        console.log(`  error: ${JSON.stringify(String(error?.stack ?? error))}`);
5582      }
5583    } finally {
5584      for (const hook of state.afterEach) await hook();
5585    }
5586  }
5587  for (const hook of state.after) await hook();
5588  console.log(`1..${records.length}`);
5589  console.log(`# tests ${records.length}`);
5590  console.log(`# pass ${passed}`);
5591  console.log(`# fail ${failed}`);
5592  console.log(`# skipped ${skipped}`);
5593  return { total: records.length, passed, failed, skipped };
5594}
5595
5596const it = test;
5597const suite = describe;
5598const mock = {};
5599export {
5600  __agentOSRunTests,
5601  after,
5602  afterEach,
5603  before,
5604  beforeEach,
5605  describe,
5606  it,
5607  mock,
5608  suite,
5609  test as default,
5610  test,
5611};
5612"#,
5613        );
5614    }
5615
5616    if module_name == "test/reporters" {
5617        return String::from(
5618            r#"const empty = async function* (source) { for await (const event of source) yield event; };
5619export { empty as dot, empty as junit, empty as spec, empty as tap };
5620"#,
5621        );
5622    }
5623
5624    if module_name == "readline" {
5625        return String::from(
5626            r#"class MiniEmitter {
5627  constructor() {
5628    this.listeners = new Map();
5629  }
5630
5631  on(event, listener) {
5632    const listeners = this.listeners.get(event) ?? [];
5633    listeners.push(listener);
5634    this.listeners.set(event, listeners);
5635    return this;
5636  }
5637
5638  addListener(event, listener) {
5639    return this.on(event, listener);
5640  }
5641
5642  once(event, listener) {
5643    const wrapped = (...args) => {
5644      this.off(event, wrapped);
5645      listener(...args);
5646    };
5647    return this.on(event, wrapped);
5648  }
5649
5650  off(event, listener) {
5651    const listeners = this.listeners.get(event) ?? [];
5652    this.listeners.set(
5653      event,
5654      listeners.filter((candidate) => candidate !== listener),
5655    );
5656    return this;
5657  }
5658
5659  removeListener(event, listener) {
5660    return this.off(event, listener);
5661  }
5662
5663  emit(event, ...args) {
5664    const listeners = this.listeners.get(event) ?? [];
5665    for (const listener of listeners) {
5666      listener(...args);
5667    }
5668    return listeners.length > 0;
5669  }
5670}
5671
5672export function createInterface(options = {}) {
5673  const input = options.input ?? null;
5674  const output = options.output ?? null;
5675  const emitter = new MiniEmitter();
5676  let buffer = "";
5677  let closed = false;
5678  let ended = false;
5679  const queuedLines = [];
5680  let pendingResolve = null;
5681  const pendingQuestionResolves = [];
5682
5683  const enqueueLine = (line) => {
5684    if (pendingQuestionResolves.length > 0) {
5685      const resolve = pendingQuestionResolves.shift();
5686      resolve(line);
5687      return;
5688    }
5689    if (pendingResolve) {
5690      const resolve = pendingResolve;
5691      pendingResolve = null;
5692      resolve({ done: false, value: line });
5693      return;
5694    }
5695    queuedLines.push(line);
5696  };
5697
5698  const flush = () => {
5699    if (buffer.length > 0) {
5700      emitter.emit("line", buffer);
5701      enqueueLine(buffer);
5702      buffer = "";
5703    }
5704  };
5705
5706  const onData = (chunk) => {
5707    buffer += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
5708    while (true) {
5709      const index = buffer.indexOf("\n");
5710      if (index < 0) break;
5711      const line = buffer.slice(0, index).replace(/\r$/, "");
5712      buffer = buffer.slice(index + 1);
5713      emitter.emit("line", line);
5714      enqueueLine(line);
5715    }
5716  };
5717
5718  const onEnd = () => {
5719    if (ended) return;
5720    ended = true;
5721    flush();
5722    emitter.emit("close");
5723    while (pendingQuestionResolves.length > 0) {
5724      const resolve = pendingQuestionResolves.shift();
5725      resolve("");
5726    }
5727    if (pendingResolve) {
5728      const resolve = pendingResolve;
5729      pendingResolve = null;
5730      resolve({ done: true, value: void 0 });
5731    }
5732  };
5733
5734  if (input && typeof input.on === "function") {
5735    input.on("data", onData);
5736    input.on("end", onEnd);
5737    if (typeof input.resume === "function") {
5738      input.resume();
5739    }
5740  }
5741
5742  emitter.close = () => {
5743    if (closed) return;
5744    closed = true;
5745    if (input && typeof input.off === "function") {
5746      input.off("data", onData);
5747      input.off("end", onEnd);
5748    }
5749    flush();
5750    emitter.emit("close");
5751    while (pendingQuestionResolves.length > 0) {
5752      const resolve = pendingQuestionResolves.shift();
5753      resolve("");
5754    }
5755    if (pendingResolve) {
5756      const resolve = pendingResolve;
5757      pendingResolve = null;
5758      resolve({ done: true, value: void 0 });
5759    }
5760  };
5761
5762  emitter.question = (prompt, callback) => {
5763    if (output && typeof output.write === "function" && prompt) {
5764      output.write(String(prompt));
5765    }
5766    const readLine = () => {
5767      if (queuedLines.length > 0) {
5768        return Promise.resolve(queuedLines.shift());
5769      }
5770      if (closed || ended) {
5771        return Promise.resolve("");
5772      }
5773      return new Promise((resolve) => {
5774        pendingQuestionResolves.push(resolve);
5775      });
5776    };
5777    if (typeof callback === "function") {
5778      void readLine().then((line) => {
5779        callback(line);
5780      });
5781      return;
5782    }
5783    return readLine();
5784  };
5785
5786  emitter[Symbol.asyncIterator] = () => ({
5787    next() {
5788      if (queuedLines.length > 0) {
5789        return Promise.resolve({ done: false, value: queuedLines.shift() });
5790      }
5791      if (closed || ended) {
5792        return Promise.resolve({ done: true, value: void 0 });
5793      }
5794      return new Promise((resolve) => {
5795        pendingResolve = resolve;
5796      });
5797    },
5798    return() {
5799      emitter.close();
5800      return Promise.resolve({ done: true, value: void 0 });
5801    },
5802    [Symbol.asyncIterator]() {
5803      return this;
5804    },
5805  });
5806
5807  return emitter;
5808}
5809
5810export default { createInterface };
5811"#,
5812        );
5813    }
5814
5815    // Historical embedded-only stream classes live below only as source text
5816    // for compatibility archaeology. The active `node:stream` wrapper must
5817    // fall through to the runtime builtin so ESM and CommonJS share constructor
5818    // identity (including the Duplex used by node:net.Socket).
5819    if module_name == "stream/promises" {
5820        return String::from(
5821            r#"const _m = globalThis._requireFrom("node:stream/promises", "/");
5822
5823export default _m;
5824export const finished = _m.finished;
5825export const pipeline = _m.pipeline;
5826"#,
5827        );
5828    }
5829
5830    if module_name == "zlib" {
5831        return String::from(
5832            r#"const _m = globalThis._requireFrom("node:zlib", "/");
5833const zlibConstants =
5834  typeof _m.constants === "object" && _m.constants !== null
5835    ? _m.constants
5836    : Object.fromEntries(
5837        Object.entries(_m).filter(
5838          ([key, value]) => /^[A-Z0-9_]+$/.test(key) && typeof value === "number",
5839        ),
5840      );
5841
5842if (typeof _m.constants === "undefined") {
5843  Object.defineProperty(_m, "constants", {
5844    configurable: true,
5845    enumerable: true,
5846    value: zlibConstants,
5847    writable: true,
5848  });
5849}
5850
5851export default _m;
5852export const constants = _m.constants;
5853export const BrotliCompress = _m.BrotliCompress;
5854export const BrotliDecompress = _m.BrotliDecompress;
5855export const Deflate = _m.Deflate;
5856export const DeflateRaw = _m.DeflateRaw;
5857export const Gunzip = _m.Gunzip;
5858export const Gzip = _m.Gzip;
5859export const Inflate = _m.Inflate;
5860export const InflateRaw = _m.InflateRaw;
5861export const Unzip = _m.Unzip;
5862export const brotliCompress = _m.brotliCompress;
5863export const brotliCompressSync = _m.brotliCompressSync;
5864export const brotliDecompress = _m.brotliDecompress;
5865export const brotliDecompressSync = _m.brotliDecompressSync;
5866export const createBrotliCompress = _m.createBrotliCompress;
5867export const createBrotliDecompress = _m.createBrotliDecompress;
5868export const createDeflate = _m.createDeflate;
5869export const createDeflateRaw = _m.createDeflateRaw;
5870export const createGunzip = _m.createGunzip;
5871export const createGzip = _m.createGzip;
5872export const createInflate = _m.createInflate;
5873export const createInflateRaw = _m.createInflateRaw;
5874export const createUnzip = _m.createUnzip;
5875export const deflate = _m.deflate;
5876export const deflateRaw = _m.deflateRaw;
5877export const deflateRawSync = _m.deflateRawSync;
5878export const deflateSync = _m.deflateSync;
5879export const gunzip = _m.gunzip;
5880export const gunzipSync = _m.gunzipSync;
5881export const gzip = _m.gzip;
5882export const gzipSync = _m.gzipSync;
5883export const inflate = _m.inflate;
5884export const inflateRaw = _m.inflateRaw;
5885export const inflateRawSync = _m.inflateRawSync;
5886export const inflateSync = _m.inflateSync;
5887export const unzip = _m.unzip;
5888export const unzipSync = _m.unzipSync;
5889"#,
5890        );
5891    }
5892
5893    if module_name == "stream/web" {
5894        return String::from(
5895            r#"export const ReadableStream = globalThis.ReadableStream;
5896export const WritableStream = globalThis.WritableStream;
5897export const TransformStream = globalThis.TransformStream;
5898export const TextEncoderStream = globalThis.TextEncoderStream;
5899export const TextDecoderStream = globalThis.TextDecoderStream;
5900export const CompressionStream = globalThis.CompressionStream;
5901export const DecompressionStream = globalThis.DecompressionStream;
5902export default {
5903  ReadableStream,
5904  WritableStream,
5905  TransformStream,
5906  TextEncoderStream,
5907  TextDecoderStream,
5908  CompressionStream,
5909  DecompressionStream,
5910};
5911"#,
5912        );
5913    }
5914
5915    if module_name == "fs/promises" {
5916        return String::from(
5917            r#"const fsModule = globalThis._requireFrom("node:fs", "/");
5918const _m = fsModule.promises;
5919
5920export default _m;
5921export const constants = fsModule.constants;
5922export const FileHandle = _m.FileHandle;
5923export const access = _m.access;
5924export const appendFile = _m.appendFile;
5925export const chmod = _m.chmod;
5926export const chown = _m.chown;
5927export const copyFile = _m.copyFile;
5928export const cp = _m.cp;
5929export const lchmod = _m.lchmod;
5930export const lchown = _m.lchown;
5931export const link = _m.link;
5932export const lstat = _m.lstat;
5933export const lutimes = _m.lutimes;
5934export const mkdir = _m.mkdir;
5935export const mkdtemp = _m.mkdtemp;
5936export const open = _m.open;
5937export const opendir = _m.opendir;
5938export const readFile = _m.readFile;
5939export const readdir = _m.readdir;
5940export const readlink = _m.readlink;
5941export const realpath = _m.realpath;
5942export const rename = _m.rename;
5943export const rm = _m.rm;
5944export const rmdir = _m.rmdir;
5945export const stat = _m.stat;
5946export const statfs = _m.statfs;
5947export const symlink = _m.symlink;
5948export const truncate = _m.truncate;
5949export const unlink = _m.unlink;
5950export const utimes = _m.utimes;
5951export const watch = _m.watch;
5952export const writeFile = _m.writeFile;
5953"#,
5954        );
5955    }
5956
5957    if module_name == "readline" {
5958        return String::from(
5959            r#"const _m = globalThis._requireFrom("node:readline", "/");
5960
5961function createInterface(...args) {
5962  const interfaceValue = _m.createInterface(...args);
5963  if (interfaceValue && typeof interfaceValue === "object") {
5964    if (interfaceValue.__agentOSReadlineWrapped === true) {
5965      return interfaceValue;
5966    }
5967    Object.defineProperty(interfaceValue, "__agentOSReadlineWrapped", {
5968      value: true,
5969      configurable: true,
5970      enumerable: false,
5971      writable: false,
5972    });
5973    const options = args[0] && typeof args[0] === "object" ? args[0] : {};
5974    const output = options.output ?? null;
5975    const originalOn = typeof interfaceValue.on === "function"
5976      ? interfaceValue.on.bind(interfaceValue)
5977      : null;
5978    const originalOff = typeof interfaceValue.off === "function"
5979      ? interfaceValue.off.bind(interfaceValue)
5980      : typeof interfaceValue.removeListener === "function"
5981        ? interfaceValue.removeListener.bind(interfaceValue)
5982        : null;
5983    const originalClose = typeof interfaceValue.close === "function"
5984      ? interfaceValue.close.bind(interfaceValue)
5985      : null;
5986    const queued = [];
5987    const pendingQuestionResolves = [];
5988    let pendingResolve = null;
5989    let done = false;
5990    const enqueue = (line) => {
5991      if (pendingQuestionResolves.length > 0) {
5992        const resolve = pendingQuestionResolves.shift();
5993        resolve(line);
5994        return;
5995      }
5996      if (pendingResolve) {
5997        const resolve = pendingResolve;
5998        pendingResolve = null;
5999        resolve({ done: false, value: line });
6000        return;
6001      }
6002      queued.push(line);
6003    };
6004    const finish = () => {
6005      if (done) {
6006        return;
6007      }
6008      done = true;
6009      while (pendingQuestionResolves.length > 0) {
6010        const resolve = pendingQuestionResolves.shift();
6011        resolve("");
6012      }
6013      if (pendingResolve) {
6014        const resolve = pendingResolve;
6015        pendingResolve = null;
6016        resolve({ done: true, value: void 0 });
6017      }
6018    };
6019    const readLine = () => {
6020      if (queued.length > 0) {
6021        return Promise.resolve(queued.shift());
6022      }
6023      if (done) {
6024        return Promise.resolve("");
6025      }
6026      return new Promise((resolve) => {
6027        pendingQuestionResolves.push(resolve);
6028      });
6029    };
6030    originalOn?.("line", enqueue);
6031    originalOn?.("close", finish);
6032    interfaceValue.question = (prompt, callback) => {
6033      if (output && typeof output.write === "function" && prompt) {
6034        output.write(String(prompt));
6035      }
6036      if (typeof callback === "function") {
6037        void readLine().then((line) => {
6038          callback(line);
6039        });
6040        return;
6041      }
6042      return readLine();
6043    };
6044    interfaceValue[Symbol.asyncIterator] = () => ({
6045      next() {
6046        if (queued.length > 0) {
6047          return Promise.resolve({ done: false, value: queued.shift() });
6048        }
6049        if (done) {
6050          return Promise.resolve({ done: true, value: void 0 });
6051        }
6052        return new Promise((resolve) => {
6053          pendingResolve = resolve;
6054        });
6055      },
6056      return() {
6057        originalOff?.("line", enqueue);
6058        originalOff?.("close", finish);
6059        originalClose?.();
6060        finish();
6061        return Promise.resolve({ done: true, value: void 0 });
6062      },
6063      [Symbol.asyncIterator]() {
6064        return this;
6065      },
6066    });
6067  }
6068  return interfaceValue;
6069}
6070
6071export default _m;
6072export { createInterface };
6073"#,
6074        );
6075    }
6076
6077    if module_name == "v8" {
6078        return String::from(
6079            r#"function serialize(value) {
6080  return Buffer.from(JSON.stringify(value ?? null), "utf8");
6081}
6082
6083function deserialize(value) {
6084  const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value ?? []);
6085  return JSON.parse(buffer.toString("utf8"));
6086}
6087
6088class Serializer {
6089  constructor() {
6090    this._value = null;
6091  }
6092
6093  writeHeader() {}
6094
6095  writeValue(value) {
6096    this._value = value;
6097  }
6098
6099  releaseBuffer() {
6100    return serialize(this._value);
6101  }
6102
6103  transferArrayBuffer() {}
6104}
6105
6106class Deserializer {
6107  constructor(buffer) {
6108    this._buffer = buffer;
6109  }
6110
6111  readHeader() {}
6112
6113  readValue() {
6114    return deserialize(this._buffer);
6115  }
6116
6117  transferArrayBuffer() {}
6118}
6119
6120function cachedDataVersionTag() {
6121  return 0;
6122}
6123
6124function getCppHeapStatistics() {
6125  return {
6126    committed_size_bytes: 0,
6127    resident_size_bytes: 0,
6128    used_size_bytes: 0,
6129    space_statistics: [],
6130  };
6131}
6132
6133function getHeapCodeStatistics() {
6134  return {
6135    code_and_metadata_size: 0,
6136    bytecode_and_metadata_size: 0,
6137    external_script_source_size: 0,
6138    cpu_profiler_metadata_size: 0,
6139  };
6140}
6141
6142function configuredHeapLimitBytes() {
6143  const configured = Number(globalThis.__agentOSV8HeapLimitBytes);
6144  if (!Number.isFinite(configured) || configured <= 0) {
6145    return 0;
6146  }
6147  return configured;
6148}
6149
6150function getHeapStatistics() {
6151  const heapLimit = configuredHeapLimitBytes();
6152  return {
6153    total_heap_size: 0,
6154    total_heap_size_executable: 0,
6155    total_physical_size: 0,
6156    total_available_size: 0,
6157    used_heap_size: 0,
6158    heap_size_limit: heapLimit,
6159    malloced_memory: 0,
6160    peak_malloced_memory: 0,
6161    does_zap_garbage: 0,
6162    number_of_native_contexts: 0,
6163    number_of_detached_contexts: 0,
6164    total_global_handles_size: 0,
6165    used_global_handles_size: 0,
6166    external_memory: 0,
6167  };
6168}
6169
6170function getHeapSpaceStatistics() {
6171  return [];
6172}
6173
6174function getHeapSnapshot() {
6175  return Readable.fromWeb(
6176    new ReadableStream({
6177      start(controller) {
6178        controller.enqueue(Buffer.from("{}"));
6179        controller.close();
6180      },
6181    }),
6182  );
6183}
6184
6185function isStringOneByteRepresentation(value) {
6186  return typeof value === "string" && !/[^\x00-\xff]/.test(value);
6187}
6188
6189function queryObjects() {
6190  return [];
6191}
6192
6193function setFlagsFromString() {}
6194
6195function setHeapSnapshotNearHeapLimit() {
6196  return [];
6197}
6198
6199function startCpuProfile() {
6200  return {
6201    stop() {
6202      return {};
6203    },
6204  };
6205}
6206
6207function stopCoverage() {
6208  return [];
6209}
6210
6211function takeCoverage() {
6212  return [];
6213}
6214
6215function writeHeapSnapshot() {
6216  return "";
6217}
6218
6219class GCProfiler {
6220  start() {}
6221
6222  stop() {
6223    return [];
6224  }
6225}
6226
6227const promiseHooks = {};
6228const startupSnapshot = {};
6229
6230export {
6231  GCProfiler,
6232  cachedDataVersionTag,
6233  Deserializer,
6234  deserialize,
6235  getCppHeapStatistics,
6236  getHeapCodeStatistics,
6237  getHeapSnapshot,
6238  getHeapSpaceStatistics,
6239  getHeapStatistics,
6240  isStringOneByteRepresentation,
6241  promiseHooks,
6242  queryObjects,
6243  serialize,
6244  Serializer,
6245  setFlagsFromString,
6246  setHeapSnapshotNearHeapLimit,
6247  startCpuProfile,
6248  startupSnapshot,
6249  stopCoverage,
6250  takeCoverage,
6251  writeHeapSnapshot,
6252};
6253export {
6254  Deserializer as DefaultDeserializer,
6255  Serializer as DefaultSerializer,
6256};
6257export default {
6258  GCProfiler,
6259  cachedDataVersionTag,
6260  DefaultDeserializer: Deserializer,
6261  DefaultSerializer: Serializer,
6262  Deserializer,
6263  deserialize,
6264  getCppHeapStatistics,
6265  getHeapCodeStatistics,
6266  getHeapSnapshot,
6267  getHeapSpaceStatistics,
6268  getHeapStatistics,
6269  isStringOneByteRepresentation,
6270  promiseHooks,
6271  queryObjects,
6272  serialize,
6273  Serializer,
6274  setFlagsFromString,
6275  setHeapSnapshotNearHeapLimit,
6276  startCpuProfile,
6277  startupSnapshot,
6278  stopCoverage,
6279  takeCoverage,
6280  writeHeapSnapshot,
6281};
6282"#,
6283        );
6284    }
6285
6286    if module_name == "vm" {
6287        return String::from(
6288            r#"const VM_CONTEXT_TAG = typeof Symbol === "function" ? Symbol.for("secure-exec.vm.context") : "__secure_exec_vm_context__";
6289const VM_CONTEXT_ID = typeof Symbol === "function" ? Symbol.for("secure-exec.vm.context.id") : "__secure_exec_vm_context_id__";
6290
6291function createVmNotImplementedError(feature) {
6292  const error = new Error(`node:vm ${feature} is not implemented in the secure-exec guest runtime`);
6293  error.code = "ERR_NOT_IMPLEMENTED";
6294  return error;
6295}
6296
6297function isVmContextCandidate(value) {
6298  return value !== null && (typeof value === "object" || typeof value === "function");
6299}
6300
6301function normalizeVmOptions(options = undefined) {
6302  if (typeof options === "string") {
6303    return { filename: options };
6304  }
6305  if (!options || typeof options !== "object") {
6306    return {};
6307  }
6308  const normalized = {};
6309  if (typeof options.filename === "string") {
6310    normalized.filename = options.filename;
6311  }
6312  if (Number.isInteger(options.lineOffset)) {
6313    normalized.lineOffset = options.lineOffset;
6314  }
6315  if (Number.isInteger(options.columnOffset)) {
6316    normalized.columnOffset = options.columnOffset;
6317  }
6318  if (Number.isInteger(options.timeout) && options.timeout > 0) {
6319    normalized.timeout = options.timeout;
6320  }
6321  if (options.cachedData !== undefined) {
6322    normalized.cachedData = options.cachedData;
6323  }
6324  if (options.produceCachedData === true) {
6325    normalized.produceCachedData = true;
6326  }
6327  return normalized;
6328}
6329
6330function mergeVmOptions(baseOptions, overrideOptions) {
6331  return { ...normalizeVmOptions(baseOptions), ...normalizeVmOptions(overrideOptions) };
6332}
6333
6334function createContext(context = {}) {
6335  if (!isVmContextCandidate(context)) {
6336    throw new TypeError('The "object" argument must be of type object.');
6337  }
6338  if (context[VM_CONTEXT_TAG] === true && Number.isInteger(context[VM_CONTEXT_ID])) {
6339    return context;
6340  }
6341  const contextId = globalThis._vmCreateContext(context);
6342  Object.defineProperty(context, VM_CONTEXT_TAG, {
6343    value: true,
6344    configurable: true,
6345    enumerable: false,
6346    writable: false,
6347  });
6348  Object.defineProperty(context, VM_CONTEXT_ID, {
6349    value: contextId,
6350    configurable: false,
6351    enumerable: false,
6352    writable: false,
6353  });
6354  return context;
6355}
6356
6357function isContext(context) {
6358  return isVmContextCandidate(context) && context[VM_CONTEXT_TAG] === true && Number.isInteger(context[VM_CONTEXT_ID]);
6359}
6360
6361function assertContext(context) {
6362  if (!isContext(context)) {
6363    throw new TypeError('The "contextifiedObject" argument must be a vm context.');
6364  }
6365  return context;
6366}
6367
6368function runInThisContext(code, options = undefined) {
6369  return globalThis._vmRunInThisContext(String(code), normalizeVmOptions(options));
6370}
6371
6372function runInContext(code, contextifiedObject, options = undefined) {
6373  const context = assertContext(contextifiedObject);
6374  return globalThis._vmRunInContext(context[VM_CONTEXT_ID], String(code), normalizeVmOptions(options), context);
6375}
6376
6377function runInNewContext(code, contextOrOptions = {}, maybeOptions = undefined) {
6378  const hasExplicitContext = isVmContextCandidate(contextOrOptions);
6379  const context = hasExplicitContext ? contextOrOptions : {};
6380  const options = hasExplicitContext ? maybeOptions : contextOrOptions;
6381  return runInContext(code, createContext(context), options);
6382}
6383
6384class Script {
6385  constructor(code, options = undefined) {
6386    this.code = String(code);
6387    this.options = normalizeVmOptions(options);
6388    this.filename = this.options.filename ?? "evalmachine.<anonymous>";
6389    this.lineOffset = this.options.lineOffset ?? 0;
6390    this.columnOffset = this.options.columnOffset ?? 0;
6391    this.cachedData = this.options.cachedData;
6392    this.cachedDataProduced = false;
6393    this.cachedDataRejected = false;
6394  }
6395
6396  createCachedData() {
6397    return typeof Buffer === "function" ? Buffer.alloc(0) : new Uint8Array(0);
6398  }
6399
6400  runInThisContext(options = undefined) {
6401    return runInThisContext(this.code, mergeVmOptions(this.options, options));
6402  }
6403
6404  runInContext(contextifiedObject, options = undefined) {
6405    return runInContext(this.code, contextifiedObject, mergeVmOptions(this.options, options));
6406  }
6407
6408  runInNewContext(context = {}, options = undefined) {
6409    return runInNewContext(this.code, context, mergeVmOptions(this.options, options));
6410  }
6411}
6412
6413function compileFunction() {
6414  throw createVmNotImplementedError("compileFunction");
6415}
6416
6417function measureMemory() {
6418  throw createVmNotImplementedError("measureMemory");
6419}
6420
6421export { Script, compileFunction, createContext, isContext, measureMemory, runInContext, runInNewContext, runInThisContext };
6422export default { Script, compileFunction, createContext, isContext, measureMemory, runInContext, runInNewContext, runInThisContext };
6423"#,
6424        );
6425    }
6426
6427    if module_name == "worker_threads" {
6428        return String::from(
6429            r#"function createNotImplementedError(feature) {
6430  const error = new Error(`node:worker_threads ${feature} is not available in the secure-exec guest runtime`);
6431  error.code = "ERR_NOT_IMPLEMENTED";
6432  return error;
6433}
6434
6435class MessagePort {
6436  postMessage() {}
6437  start() {}
6438  close() {}
6439  unref() {
6440    return this;
6441  }
6442  ref() {
6443    return this;
6444  }
6445}
6446
6447class MessageChannel {
6448  constructor() {
6449    this.port1 = new MessagePort();
6450    this.port2 = new MessagePort();
6451  }
6452}
6453
6454class Worker {
6455  constructor() {
6456    throw createNotImplementedError("Worker");
6457  }
6458}
6459
6460function getEnvironmentData() {
6461  return undefined;
6462}
6463
6464function markAsUncloneable() {}
6465
6466function markAsUntransferable() {}
6467
6468function moveMessagePortToContext() {
6469  throw createNotImplementedError("moveMessagePortToContext");
6470}
6471
6472function postMessageToThread() {
6473  throw createNotImplementedError("postMessageToThread");
6474}
6475
6476function receiveMessageOnPort() {
6477  return undefined;
6478}
6479
6480function setEnvironmentData() {}
6481
6482export const BroadcastChannel = globalThis.BroadcastChannel;
6483export { MessageChannel, MessagePort, Worker, getEnvironmentData, markAsUncloneable, markAsUntransferable, moveMessagePortToContext, postMessageToThread, receiveMessageOnPort, setEnvironmentData };
6484export const SHARE_ENV = Symbol.for("secure-exec.worker_threads.SHARE_ENV");
6485export const isMainThread = true;
6486export const parentPort = null;
6487export const resourceLimits = {};
6488export const threadId = 0;
6489export const workerData = null;
6490export default {
6491  BroadcastChannel: globalThis.BroadcastChannel,
6492  MessageChannel,
6493  MessagePort,
6494  SHARE_ENV,
6495  Worker,
6496  getEnvironmentData,
6497  isMainThread,
6498  markAsUncloneable,
6499  markAsUntransferable,
6500  moveMessagePortToContext,
6501  parentPort,
6502  postMessageToThread,
6503  receiveMessageOnPort,
6504  resourceLimits,
6505  setEnvironmentData,
6506  threadId,
6507  workerData,
6508};
6509"#,
6510        );
6511    }
6512
6513    build_delegating_builtin_module_wrapper(module_name)
6514}
6515
6516fn build_delegating_builtin_module_wrapper(module_name: &str) -> String {
6517    let default_target = format!(
6518        "globalThis._requireFrom({}, \"/\")",
6519        serde_json::to_string(&format!("node:{module_name}"))
6520            .unwrap_or_else(|_| format!("\"node:{module_name}\""))
6521    );
6522    let mut exports = builtin_named_exports(module_name)
6523        .iter()
6524        .collect::<HashSet<_>>()
6525        .into_iter()
6526        .collect::<Vec<_>>();
6527    exports.sort_unstable();
6528
6529    let mut source = format!("const _m = {default_target};\nexport default _m;\n");
6530    for name in exports {
6531        source.push_str(&format!("export const {name} = _m[\"{name}\"];\n"));
6532    }
6533    source
6534}
6535
6536fn builtin_named_exports(module_name: &str) -> &'static [&'static str] {
6537    match module_name {
6538        "assert" | "assert/strict" => &[
6539            "AssertionError",
6540            "CallTracker",
6541            "deepEqual",
6542            "deepStrictEqual",
6543            "doesNotMatch",
6544            "doesNotReject",
6545            "doesNotThrow",
6546            "equal",
6547            "fail",
6548            "ifError",
6549            "match",
6550            "notDeepEqual",
6551            "notDeepStrictEqual",
6552            "notEqual",
6553            "notStrictEqual",
6554            "ok",
6555            "partialDeepStrictEqual",
6556            "rejects",
6557            "strict",
6558            "strictEqual",
6559            "throws",
6560        ],
6561        "async_hooks" => &[
6562            "AsyncLocalStorage",
6563            "AsyncResource",
6564            "createHook",
6565            "executionAsyncId",
6566            "triggerAsyncId",
6567        ],
6568        "buffer" => &[
6569            "Blob",
6570            "Buffer",
6571            "File",
6572            "INSPECT_MAX_BYTES",
6573            "SlowBuffer",
6574            "isAscii",
6575            "isUtf8",
6576            "resolveObjectURL",
6577        ],
6578        "child_process" => &[
6579            "ChildProcess",
6580            "exec",
6581            "execFile",
6582            "execFileSync",
6583            "execSync",
6584            "fork",
6585            "spawn",
6586            "spawnSync",
6587        ],
6588        "console" => &[
6589            "Console",
6590            "assert",
6591            "clear",
6592            "context",
6593            "count",
6594            "countReset",
6595            "createTask",
6596            "debug",
6597            "dir",
6598            "dirxml",
6599            "error",
6600            "group",
6601            "groupCollapsed",
6602            "groupEnd",
6603            "info",
6604            "log",
6605            "profile",
6606            "profileEnd",
6607            "table",
6608            "time",
6609            "timeEnd",
6610            "timeLog",
6611            "timeStamp",
6612            "trace",
6613            "warn",
6614        ],
6615        "constants" => &[
6616            "COPYFILE_EXCL",
6617            "COPYFILE_FICLONE",
6618            "COPYFILE_FICLONE_FORCE",
6619            "F_OK",
6620            "R_OK",
6621            "W_OK",
6622            "X_OK",
6623            "O_RDONLY",
6624            "O_WRONLY",
6625            "O_RDWR",
6626            "O_CREAT",
6627            "O_EXCL",
6628            "O_TRUNC",
6629            "O_APPEND",
6630            "O_DIRECTORY",
6631            "O_NOFOLLOW",
6632            "O_SYNC",
6633            "O_DSYNC",
6634            "O_NONBLOCK",
6635            "S_IFMT",
6636            "S_IFREG",
6637            "S_IFDIR",
6638            "S_IFCHR",
6639            "S_IFBLK",
6640            "S_IFIFO",
6641            "S_IFLNK",
6642            "S_IFSOCK",
6643        ],
6644        "crypto" => &[
6645            "DiffieHellman",
6646            "ECDH",
6647            "KeyObject",
6648            "constants",
6649            "createCipheriv",
6650            "createDecipheriv",
6651            "createDiffieHellman",
6652            "createECDH",
6653            "createHash",
6654            "createHmac",
6655            "createPrivateKey",
6656            "createPublicKey",
6657            "createSecretKey",
6658            "createSign",
6659            "createVerify",
6660            "diffieHellman",
6661            "generateKeyPair",
6662            "generateKeyPairSync",
6663            "generateKeySync",
6664            "generatePrime",
6665            "generatePrimeSync",
6666            "getCiphers",
6667            "getCurves",
6668            "getDiffieHellman",
6669            "getFips",
6670            "getHashes",
6671            "getRandomValues",
6672            "pbkdf2",
6673            "pbkdf2Sync",
6674            "privateDecrypt",
6675            "privateEncrypt",
6676            "publicDecrypt",
6677            "publicEncrypt",
6678            "randomBytes",
6679            "randomFill",
6680            "randomFillSync",
6681            "randomUUID",
6682            "scrypt",
6683            "scryptSync",
6684            "sign",
6685            "subtle",
6686            "timingSafeEqual",
6687            "verify",
6688            "webcrypto",
6689        ],
6690        "diagnostics_channel" => &[
6691            "Channel",
6692            "channel",
6693            "hasSubscribers",
6694            "subscribe",
6695            "tracingChannel",
6696            "unsubscribe",
6697        ],
6698        "events" => &[
6699            "EventEmitter",
6700            "addAbortListener",
6701            "defaultMaxListeners",
6702            "errorMonitor",
6703            "getEventListeners",
6704            "getMaxListeners",
6705            "on",
6706            "once",
6707            "setMaxListeners",
6708        ],
6709        "dns" => &[
6710            "ADDRCONFIG",
6711            "ALL",
6712            "Resolver",
6713            "V4MAPPED",
6714            "getServers",
6715            "lookup",
6716            "promises",
6717            "resolve",
6718            "resolve4",
6719            "resolve6",
6720            "setServers",
6721        ],
6722        "dns/promises" => &[
6723            "Resolver",
6724            "lookup",
6725            "resolve",
6726            "resolve4",
6727            "resolve6",
6728            "resolveAny",
6729            "resolveMx",
6730            "resolveTxt",
6731            "resolveSrv",
6732            "resolveCname",
6733            "resolvePtr",
6734            "resolveNs",
6735            "resolveSoa",
6736            "resolveNaptr",
6737            "resolveCaa",
6738        ],
6739        "fs" => &[
6740            "Dir",
6741            "Dirent",
6742            "ReadStream",
6743            "Stats",
6744            "WriteStream",
6745            "access",
6746            "accessSync",
6747            "appendFile",
6748            "appendFileSync",
6749            "chmod",
6750            "chmodSync",
6751            "chown",
6752            "chownSync",
6753            "close",
6754            "closeSync",
6755            "constants",
6756            "copyFile",
6757            "copyFileSync",
6758            "cp",
6759            "cpSync",
6760            "createReadStream",
6761            "createWriteStream",
6762            "exists",
6763            "existsSync",
6764            "lchmod",
6765            "lchmodSync",
6766            "lchown",
6767            "lchownSync",
6768            "link",
6769            "linkSync",
6770            "fstat",
6771            "fstatSync",
6772            "fsyncSync",
6773            "lstat",
6774            "lstatSync",
6775            "lutimes",
6776            "lutimesSync",
6777            "mkdir",
6778            "mkdirSync",
6779            "mkdtemp",
6780            "mkdtempSync",
6781            "open",
6782            "openSync",
6783            "opendir",
6784            "opendirSync",
6785            "read",
6786            "readFile",
6787            "promises",
6788            "readFileSync",
6789            "readdir",
6790            "readSync",
6791            "readdirSync",
6792            "readlink",
6793            "readlinkSync",
6794            "realpath",
6795            "realpathSync",
6796            "rename",
6797            "renameSync",
6798            "rmdir",
6799            "rmdirSync",
6800            "rm",
6801            "rmSync",
6802            "rmdir",
6803            "rmdirSync",
6804            "stat",
6805            "statSync",
6806            "statfs",
6807            "statfsSync",
6808            "symlink",
6809            "symlinkSync",
6810            "truncate",
6811            "truncateSync",
6812            "unlink",
6813            "unlinkSync",
6814            "utimes",
6815            "utimesSync",
6816            "watch",
6817            "watchFile",
6818            "unwatchFile",
6819            "write",
6820            "writeFile",
6821            "writeFileSync",
6822            "writeSync",
6823        ],
6824        "fs/promises" => &[
6825            "access",
6826            "appendFile",
6827            "chmod",
6828            "chown",
6829            "constants",
6830            "copyFile",
6831            "cp",
6832            "glob",
6833            "lchown",
6834            "link",
6835            "lstat",
6836            "mkdir",
6837            "mkdtemp",
6838            "open",
6839            "opendir",
6840            "readFile",
6841            "readdir",
6842            "readlink",
6843            "realpath",
6844            "rename",
6845            "rm",
6846            "rmdir",
6847            "stat",
6848            "statfs",
6849            "symlink",
6850            "truncate",
6851            "unlink",
6852            "utimes",
6853            "writeFile",
6854        ],
6855        "http" => &[
6856            "Agent",
6857            "ClientRequest",
6858            "IncomingMessage",
6859            "METHODS",
6860            "Server",
6861            "ServerResponse",
6862            "STATUS_CODES",
6863            "_checkInvalidHeaderChar",
6864            "_checkIsHttpToken",
6865            "createServer",
6866            "get",
6867            "globalAgent",
6868            "maxHeaderSize",
6869            "request",
6870            "validateHeaderName",
6871            "validateHeaderValue",
6872        ],
6873        "http2" => &["connect", "createServer", "createSecureServer"],
6874        "https" => &[
6875            "Agent",
6876            "ClientRequest",
6877            "IncomingMessage",
6878            "Server",
6879            "ServerResponse",
6880            "_checkInvalidHeaderChar",
6881            "_checkIsHttpToken",
6882            "createServer",
6883            "get",
6884            "globalAgent",
6885            "maxHeaderSize",
6886            "request",
6887            "validateHeaderName",
6888            "validateHeaderValue",
6889        ],
6890        "module" => &[
6891            "Module",
6892            "_cache",
6893            "_extensions",
6894            "_resolveFilename",
6895            "builtinModules",
6896            "createRequire",
6897            "findSourceMap",
6898            "isBuiltin",
6899            "syncBuiltinESMExports",
6900            "wrap",
6901        ],
6902        "net" => &[
6903            "BlockList",
6904            "Socket",
6905            "SocketAddress",
6906            "Server",
6907            "Stream",
6908            "connect",
6909            "createConnection",
6910            "createServer",
6911            "getDefaultAutoSelectFamily",
6912            "getDefaultAutoSelectFamilyAttemptTimeout",
6913            "isIP",
6914            "isIPv4",
6915            "isIPv6",
6916            "setDefaultAutoSelectFamily",
6917            "setDefaultAutoSelectFamilyAttemptTimeout",
6918        ],
6919        "os" => &[
6920            "EOL",
6921            "arch",
6922            "availableParallelism",
6923            "constants",
6924            "cpus",
6925            "endianness",
6926            "freemem",
6927            "homedir",
6928            "hostname",
6929            "networkInterfaces",
6930            "platform",
6931            "release",
6932            "totalmem",
6933            "tmpdir",
6934            "type",
6935            "userInfo",
6936            "version",
6937        ],
6938        "path" | "path/posix" | "path/win32" => &[
6939            "basename",
6940            "delimiter",
6941            "dirname",
6942            "extname",
6943            "format",
6944            "isAbsolute",
6945            "join",
6946            "normalize",
6947            "parse",
6948            "posix",
6949            "relative",
6950            "resolve",
6951            "sep",
6952            "toNamespacedPath",
6953            "win32",
6954        ],
6955        "process" => &[
6956            "abort",
6957            "allowedNodeEnvironmentFlags",
6958            "arch",
6959            "argv",
6960            "argv0",
6961            "availableMemory",
6962            "chdir",
6963            "config",
6964            "constrainedMemory",
6965            "cpuUsage",
6966            "cwd",
6967            "debugPort",
6968            "dlopen",
6969            "emitWarning",
6970            "env",
6971            "execArgv",
6972            "execPath",
6973            "execve",
6974            "exit",
6975            "exitCode",
6976            "features",
6977            "finalization",
6978            "getActiveResourcesInfo",
6979            "getBuiltinModule",
6980            "getegid",
6981            "geteuid",
6982            "getgid",
6983            "getgroups",
6984            "getuid",
6985            "hasUncaughtExceptionCaptureCallback",
6986            "hrtime",
6987            "initgroups",
6988            "kill",
6989            "loadEnvFile",
6990            "memoryUsage",
6991            "moduleLoadList",
6992            "nextTick",
6993            "openStdin",
6994            "pid",
6995            "platform",
6996            "ppid",
6997            "reallyExit",
6998            "ref",
6999            "release",
7000            "report",
7001            "resourceUsage",
7002            "setSourceMapsEnabled",
7003            "setUncaughtExceptionCaptureCallback",
7004            "setegid",
7005            "seteuid",
7006            "setgid",
7007            "setgroups",
7008            "setuid",
7009            "sourceMapsEnabled",
7010            "stderr",
7011            "stdin",
7012            "stdout",
7013            "threadCpuUsage",
7014            "title",
7015            "umask",
7016            "unref",
7017            "uptime",
7018            "version",
7019            "versions",
7020        ],
7021        "perf_hooks" => &[
7022            "PerformanceObserver",
7023            "constants",
7024            "createHistogram",
7025            "performance",
7026        ],
7027        "readline" => &["createInterface"],
7028        "sqlite" => &["DatabaseSync", "StatementSync", "constants"],
7029        "stream" => &[
7030            "Duplex",
7031            "PassThrough",
7032            "Readable",
7033            "Stream",
7034            "Transform",
7035            "Writable",
7036            "addAbortSignal",
7037            "compose",
7038            "finished",
7039            "getDefaultHighWaterMark",
7040            "isDisturbed",
7041            "isErrored",
7042            "isReadable",
7043            "isWritable",
7044            "pipeline",
7045            "setDefaultHighWaterMark",
7046        ],
7047        "stream/consumers" => &["arrayBuffer", "blob", "buffer", "json", "text"],
7048        "sys" => &[
7049            "MIMEType",
7050            "MIMEParams",
7051            "TextDecoder",
7052            "TextEncoder",
7053            "aborted",
7054            "callbackify",
7055            "debug",
7056            "debuglog",
7057            "deprecate",
7058            "format",
7059            "formatWithOptions",
7060            "inherits",
7061            "inspect",
7062            "parseEnv",
7063            "parseArgs",
7064            "promisify",
7065            "styleText",
7066            "stripVTControlCharacters",
7067            "types",
7068        ],
7069        "timers" => &[
7070            "clearImmediate",
7071            "clearInterval",
7072            "clearTimeout",
7073            "setImmediate",
7074            "setInterval",
7075            "setTimeout",
7076        ],
7077        "tty" => &["ReadStream", "WriteStream", "isatty"],
7078        "tls" => &[
7079            "DEFAULT_MAX_VERSION",
7080            "DEFAULT_MIN_VERSION",
7081            "TLSSocket",
7082            "Server",
7083            "checkServerIdentity",
7084            "connect",
7085            "createSecureContext",
7086            "createServer",
7087            "getCiphers",
7088            "rootCertificates",
7089        ],
7090        "stream/promises" => &["finished", "pipeline"],
7091        "string_decoder" => &["StringDecoder"],
7092        "timers/promises" => &["scheduler", "setImmediate", "setInterval", "setTimeout"],
7093        "url" => &[
7094            "URL",
7095            "URLSearchParams",
7096            "Url",
7097            "domainToASCII",
7098            "domainToUnicode",
7099            "fileURLToPath",
7100            "format",
7101            "parse",
7102            "pathToFileURL",
7103            "resolve",
7104            "resolveObject",
7105            "urlToHttpOptions",
7106        ],
7107        "util" => &[
7108            "MIMEType",
7109            "MIMEParams",
7110            "TextDecoder",
7111            "TextEncoder",
7112            "aborted",
7113            "callbackify",
7114            "debug",
7115            "debuglog",
7116            "deprecate",
7117            "format",
7118            "formatWithOptions",
7119            "inherits",
7120            "inspect",
7121            "isDeepStrictEqual",
7122            "parseEnv",
7123            "parseArgs",
7124            "promisify",
7125            "styleText",
7126            "stripVTControlCharacters",
7127            "types",
7128        ],
7129        "util/types" => &[
7130            "isAnyArrayBuffer",
7131            "isArgumentsObject",
7132            "isArrayBuffer",
7133            "isArrayBufferView",
7134            "isAsyncFunction",
7135            "isBigInt64Array",
7136            "isBigIntObject",
7137            "isBigUint64Array",
7138            "isBooleanObject",
7139            "isBoxedPrimitive",
7140            "isCryptoKey",
7141            "isDataView",
7142            "isDate",
7143            "isExternal",
7144            "isFloat16Array",
7145            "isFloat32Array",
7146            "isFloat64Array",
7147            "isGeneratorFunction",
7148            "isGeneratorObject",
7149            "isInt16Array",
7150            "isInt32Array",
7151            "isInt8Array",
7152            "isKeyObject",
7153            "isMap",
7154            "isMapIterator",
7155            "isModuleNamespaceObject",
7156            "isNativeError",
7157            "isNumberObject",
7158            "isPromise",
7159            "isProxy",
7160            "isRegExp",
7161            "isSet",
7162            "isSetIterator",
7163            "isSharedArrayBuffer",
7164            "isStringObject",
7165            "isSymbolObject",
7166            "isTypedArray",
7167            "isUint16Array",
7168            "isUint32Array",
7169            "isUint8Array",
7170            "isUint8ClampedArray",
7171            "isWeakMap",
7172            "isWeakSet",
7173        ],
7174        "vm" => &[
7175            "Script",
7176            "compileFunction",
7177            "createContext",
7178            "isContext",
7179            "measureMemory",
7180            "runInContext",
7181            "runInNewContext",
7182            "runInThisContext",
7183        ],
7184        "v8" => &[
7185            "cachedDataVersionTag",
7186            "DefaultDeserializer",
7187            "DefaultSerializer",
7188            "Deserializer",
7189            "GCProfiler",
7190            "Serializer",
7191            "deserialize",
7192            "getCppHeapStatistics",
7193            "getHeapCodeStatistics",
7194            "getHeapSnapshot",
7195            "getHeapSpaceStatistics",
7196            "getHeapStatistics",
7197            "isStringOneByteRepresentation",
7198            "promiseHooks",
7199            "queryObjects",
7200            "serialize",
7201            "setFlagsFromString",
7202            "setHeapSnapshotNearHeapLimit",
7203            "startCpuProfile",
7204            "startupSnapshot",
7205            "stopCoverage",
7206            "takeCoverage",
7207            "writeHeapSnapshot",
7208        ],
7209        "worker_threads" => &[
7210            "MessageChannel",
7211            "MessagePort",
7212            "Worker",
7213            "isMainThread",
7214            "parentPort",
7215            "workerData",
7216        ],
7217        "zlib" => &[
7218            "BrotliCompress",
7219            "BrotliDecompress",
7220            "Deflate",
7221            "DeflateRaw",
7222            "Gunzip",
7223            "Gzip",
7224            "Inflate",
7225            "InflateRaw",
7226            "Unzip",
7227            "brotliCompress",
7228            "brotliCompressSync",
7229            "brotliDecompress",
7230            "brotliDecompressSync",
7231            "constants",
7232            "createBrotliCompress",
7233            "createBrotliDecompress",
7234            "createDeflate",
7235            "createDeflateRaw",
7236            "createGunzip",
7237            "createGzip",
7238            "createInflate",
7239            "createInflateRaw",
7240            "createUnzip",
7241            "deflate",
7242            "deflateRaw",
7243            "deflateRawSync",
7244            "deflateSync",
7245            "gunzip",
7246            "gunzipSync",
7247            "gzip",
7248            "gzipSync",
7249            "inflate",
7250            "inflateRaw",
7251            "inflateRawSync",
7252            "inflateSync",
7253            "unzip",
7254            "unzipSync",
7255        ],
7256        _ => &[],
7257    }
7258}
7259
7260fn split_package_request(request: &str) -> Option<(&str, &str)> {
7261    if request.starts_with('@') {
7262        let mut parts = request.splitn(3, '/');
7263        let scope = parts.next()?;
7264        let name = parts.next()?;
7265        let package_name = &request[..scope.len() + 1 + name.len()];
7266        let subpath = parts.next().unwrap_or("");
7267        Some((package_name, subpath))
7268    } else {
7269        request.split_once('/').or(Some((request, "")))
7270    }
7271}
7272
7273fn node_modules_direct_candidate_dirs(dir: &str, package_name: &str) -> Vec<String> {
7274    let mut candidates = HashSet::new();
7275    candidates.insert(join_guest_path(
7276        dir,
7277        &format!("node_modules/{package_name}"),
7278    ));
7279    if dir == "/node_modules" || dir.ends_with("/node_modules") {
7280        candidates.insert(join_guest_path(dir, package_name));
7281    }
7282    let mut candidates = candidates.into_iter().collect::<Vec<_>>();
7283    candidates.sort();
7284    candidates
7285}
7286
7287fn resolve_exports_target(
7288    exports_field: &Value,
7289    subpath: &str,
7290    mode: ModuleResolveMode,
7291) -> Option<String> {
7292    match exports_field {
7293        Value::String(value) => (subpath == ".").then(|| value.clone()),
7294        Value::Array(values) => values
7295            .iter()
7296            .find_map(|value| resolve_exports_target(value, subpath, mode)),
7297        Value::Object(record) => {
7298            if subpath == "."
7299                && !record.contains_key(".")
7300                && !record.keys().any(|key| key.starts_with("./"))
7301            {
7302                return resolve_conditional_target(record, mode);
7303            }
7304            if let Some(value) = record.get(subpath) {
7305                return resolve_exports_target(value, ".", mode);
7306            }
7307            let mut best_match = None;
7308            for (key, value) in record {
7309                if let Some((prefix, suffix)) = key.split_once('*') {
7310                    if subpath.starts_with(prefix) && subpath.ends_with(suffix) {
7311                        let wildcard = &subpath[prefix.len()..subpath.len() - suffix.len()];
7312                        let specificity = (prefix.len(), suffix.len());
7313                        if best_match
7314                            .as_ref()
7315                            .is_none_or(|(_, _, current)| specificity > *current)
7316                        {
7317                            best_match = Some((value, wildcard, specificity));
7318                        }
7319                    }
7320                }
7321            }
7322            if let Some((value, wildcard, _)) = best_match {
7323                let resolved = resolve_exports_target(value, ".", mode)?;
7324                return Some(resolved.replace('*', wildcard));
7325            }
7326            if subpath == "." {
7327                record
7328                    .get(".")
7329                    .and_then(|value| resolve_exports_target(value, ".", mode))
7330            } else {
7331                None
7332            }
7333        }
7334        _ => None,
7335    }
7336}
7337
7338fn resolve_conditional_target(
7339    record: &serde_json::Map<String, Value>,
7340    mode: ModuleResolveMode,
7341) -> Option<String> {
7342    let order: &[&str] = match mode {
7343        ModuleResolveMode::Import => &["import", "node", "module", "default", "require"],
7344        ModuleResolveMode::Require => &["require", "node", "default", "import", "module"],
7345    };
7346    for key in order {
7347        if let Some(value) = record.get(*key) {
7348            if let Some(resolved) = resolve_exports_target(value, ".", mode) {
7349                return Some(resolved);
7350            }
7351        }
7352    }
7353    None
7354}
7355
7356fn resolve_imports_target(
7357    imports_field: &Value,
7358    specifier: &str,
7359    mode: ModuleResolveMode,
7360) -> Option<String> {
7361    match imports_field {
7362        Value::String(value) => Some(value.clone()),
7363        Value::Array(values) => values
7364            .iter()
7365            .find_map(|value| resolve_imports_target(value, specifier, mode)),
7366        Value::Object(record) => {
7367            if let Some(value) = record.get(specifier) {
7368                return resolve_exports_target(value, ".", mode);
7369            }
7370            let mut best_match = None;
7371            for (key, value) in record {
7372                if let Some((prefix, suffix)) = key.split_once('*') {
7373                    if specifier.starts_with(prefix) && specifier.ends_with(suffix) {
7374                        let wildcard = &specifier[prefix.len()..specifier.len() - suffix.len()];
7375                        let specificity = (prefix.len(), suffix.len());
7376                        if best_match
7377                            .as_ref()
7378                            .is_none_or(|(_, _, current)| specificity > *current)
7379                        {
7380                            best_match = Some((value, wildcard, specificity));
7381                        }
7382                    }
7383                }
7384            }
7385            best_match.and_then(|(value, wildcard, _)| {
7386                resolve_exports_target(value, ".", mode)
7387                    .map(|resolved| resolved.replace('*', wildcard))
7388            })
7389        }
7390        _ => None,
7391    }
7392}
7393
7394#[cfg(test)]
7395mod tests {
7396    use super::*;
7397    use nix::fcntl::OFlag;
7398    use nix::unistd::pipe2;
7399    use serde_json::Value;
7400    use std::io::BufRead;
7401    use std::time::{SystemTime, UNIX_EPOCH};
7402    use tempfile::tempdir;
7403
7404    #[test]
7405    fn dispose_context_reclaims_one_shot_metadata_without_reusing_ids() {
7406        let mut engine = JavascriptExecutionEngine::default();
7407        let baseline = engine.context_count_for_test();
7408        let first = engine.create_context(CreateJavascriptContextRequest {
7409            vm_id: String::from("vm-context-dispose"),
7410            bootstrap_module: None,
7411            compile_cache_root: None,
7412        });
7413        assert_eq!(engine.context_count_for_test(), baseline + 1);
7414        assert!(engine.dispose_context(&first.context_id));
7415        assert_eq!(engine.context_count_for_test(), baseline);
7416        assert!(!engine.dispose_context(&first.context_id));
7417
7418        let second = engine.create_context(CreateJavascriptContextRequest {
7419            vm_id: String::from("vm-context-dispose"),
7420            bootstrap_module: None,
7421            compile_cache_root: None,
7422        });
7423        assert_ne!(first.context_id, second.context_id);
7424    }
7425
7426    #[test]
7427    fn javascript_limits_are_read_from_typed_fields_and_env_is_inert() {
7428        // Misleading env values: a reader that still consulted `AGENTOS_*` would
7429        // observe these instead of the typed wire limits.
7430        let env = std::collections::BTreeMap::from([
7431            (
7432                String::from("AGENTOS_V8_HEAP_LIMIT_MB"),
7433                String::from("999999"),
7434            ),
7435            (
7436                String::from("AGENTOS_V8_CPU_TIME_LIMIT_MS"),
7437                String::from("999999"),
7438            ),
7439            (
7440                String::from("AGENTOS_V8_WALL_CLOCK_LIMIT_MS"),
7441                String::from("999999"),
7442            ),
7443            (
7444                String::from("AGENTOS_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS"),
7445                String::from("999999"),
7446            ),
7447            (
7448                String::from(NODE_SYNC_RPC_WAIT_TIMEOUT_MS_ENV),
7449                String::from("999999"),
7450            ),
7451        ]);
7452        let request = StartJavascriptExecutionRequest {
7453            argv0: None,
7454            guest_runtime: Default::default(),
7455            vm_id: String::from("vm-js"),
7456            context_id: String::from("ctx-js"),
7457            argv: vec![String::from("/entry.mjs")],
7458            env,
7459            cwd: std::path::PathBuf::from("/tmp"),
7460            limits: JavascriptExecutionLimits {
7461                v8_heap_limit_mb: Some(64),
7462                sync_rpc_wait_timeout_ms: Some(2_000),
7463                cpu_time_limit_ms: Some(750),
7464                wall_clock_limit_ms: Some(500),
7465                import_cache_materialize_timeout_ms: Some(125),
7466                max_timers: Some(321),
7467                reactor_work_quantum: Some(64),
7468                bridge_call_timeout_ms: Some(15_000),
7469            },
7470            wasm_module_bytes: None,
7471            inline_code: None,
7472        };
7473
7474        assert_eq!(
7475            javascript_heap_limit_mb(&request),
7476            64,
7477            "heap must come from the typed wire limit, not AGENTOS_V8_HEAP_LIMIT_MB"
7478        );
7479        assert_eq!(
7480            javascript_sync_rpc_timeout(&request),
7481            std::time::Duration::from_millis(2_000),
7482            "sync-rpc wait must come from the typed wire limit, not env"
7483        );
7484        assert_eq!(
7485            javascript_cpu_time_limit_ms(&request),
7486            750,
7487            "CPU budget must come from the typed wire limit, not env"
7488        );
7489        assert_eq!(
7490            javascript_wall_clock_limit_ms(&request),
7491            500,
7492            "wall-clock budget must come from the typed wire limit, not env"
7493        );
7494        assert_eq!(
7495            javascript_import_cache_materialize_timeout(&request),
7496            std::time::Duration::from_millis(125),
7497            "import-cache timeout must come from the typed wire limit, not env"
7498        );
7499        assert_eq!(javascript_max_timers(&request), 321);
7500        assert_eq!(
7501            javascript_reactor_work_quantum(
7502                &request,
7503                &default_test_runtime_context().expect("test runtime")
7504            )
7505            .expect("typed reactor work quantum"),
7506            64
7507        );
7508    }
7509
7510    #[test]
7511    fn vm_scoped_reactor_work_quantum_is_required_and_nonzero() {
7512        let process = default_test_runtime_context().expect("test runtime context");
7513        let resources = Arc::new(agentos_runtime::accounting::ResourceLedger::child(
7514            "javascript-reactor-work-quantum-test",
7515            std::iter::empty::<(
7516                agentos_runtime::accounting::ResourceClass,
7517                agentos_runtime::accounting::ResourceLimit,
7518            )>(),
7519            Arc::clone(process.resources()),
7520        ));
7521        let runtime = process.scoped_for_vm(resources, 9_001);
7522        let mut request = StartJavascriptExecutionRequest {
7523            guest_runtime: Default::default(),
7524            vm_id: String::from("vm-js"),
7525            context_id: String::from("ctx-js"),
7526            argv0: None,
7527            argv: vec![String::from("/entry.mjs")],
7528            env: BTreeMap::new(),
7529            cwd: PathBuf::from("/tmp"),
7530            limits: JavascriptExecutionLimits::default(),
7531            wasm_module_bytes: None,
7532            inline_code: None,
7533        };
7534
7535        let missing = javascript_reactor_work_quantum(&request, &runtime)
7536            .expect_err("VM execution must carry its work quantum");
7537        assert!(missing
7538            .to_string()
7539            .contains("limits.reactor.workQuantum is required"));
7540
7541        request.limits.reactor_work_quantum = Some(0);
7542        let zero = javascript_reactor_work_quantum(&request, &runtime)
7543            .expect_err("zero VM work quantum must fail closed");
7544        assert!(zero
7545            .to_string()
7546            .contains("limits.reactor.workQuantum must be greater than zero"));
7547    }
7548
7549    #[test]
7550    fn javascript_limits_fall_back_to_defaults_when_unset() {
7551        let request = StartJavascriptExecutionRequest {
7552            argv0: None,
7553            guest_runtime: Default::default(),
7554            vm_id: String::from("vm-js"),
7555            context_id: String::from("ctx-js"),
7556            argv: vec![String::from("/entry.mjs")],
7557            env: std::collections::BTreeMap::new(),
7558            cwd: std::path::PathBuf::from("/tmp"),
7559            limits: JavascriptExecutionLimits::default(),
7560            wasm_module_bytes: None,
7561            inline_code: None,
7562        };
7563
7564        assert_eq!(
7565            javascript_heap_limit_mb(&request),
7566            0,
7567            "0 selects the engine default heap"
7568        );
7569        assert_eq!(
7570            javascript_sync_rpc_timeout(&request),
7571            std::time::Duration::from_millis(NODE_SYNC_RPC_DEFAULT_WAIT_TIMEOUT_MS),
7572        );
7573        assert_eq!(
7574            javascript_cpu_time_limit_ms(&request),
7575            DEFAULT_V8_CPU_TIME_LIMIT_MS
7576        );
7577        assert_eq!(
7578            javascript_wall_clock_limit_ms(&request),
7579            DEFAULT_V8_WALL_CLOCK_LIMIT_MS
7580        );
7581        assert_eq!(javascript_max_timers(&request), MAX_TIMERS_PER_EXECUTION);
7582        assert_eq!(
7583            javascript_import_cache_materialize_timeout(&request),
7584            std::time::Duration::from_millis(DEFAULT_NODE_IMPORT_CACHE_MATERIALIZE_TIMEOUT_MS)
7585        );
7586    }
7587
7588    #[test]
7589    fn inline_code_module_detection_prefers_commonjs_when_import_only_appears_in_comment() {
7590        let source = "// import { x } from 'y';\nmodule.exports = { foo: 1 };";
7591        assert!(!inline_code_uses_module_mode(source));
7592    }
7593
7594    #[test]
7595    fn inline_code_module_detection_ignores_import_inside_string_literal() {
7596        let source = "const msg = \"run: import x from 'y'\";\nmodule.exports.msg = msg;";
7597        assert!(!inline_code_uses_module_mode(source));
7598    }
7599
7600    #[test]
7601    fn inline_code_module_detection_accepts_multiline_import_statements() {
7602        let source = "import\n  { default as foo }\nfrom 'bar';\nconsole.log(foo);";
7603        assert!(inline_code_uses_module_mode(source));
7604    }
7605
7606    #[test]
7607    fn inline_code_module_detection_accepts_real_esm_source() {
7608        let source = "import { foo } from 'bar';\nexport const baz = 1;\nconsole.log(foo, baz);";
7609        assert!(inline_code_uses_module_mode(source));
7610    }
7611
7612    #[test]
7613    fn inline_code_module_detection_is_deterministic_for_empty_comment_only_and_template_cases() {
7614        assert!(!inline_code_uses_module_mode(""));
7615        assert!(!inline_code_uses_module_mode(
7616            "// import x from 'y';\n/* export const z = 1; */"
7617        ));
7618        assert!(!inline_code_uses_module_mode(
7619            "const msg = `export const nope = 1;`;"
7620        ));
7621    }
7622
7623    #[test]
7624    fn javascript_sync_rpc_timeout_writes_clear_error_response() {
7625        let (reader_fd, writer_fd) = pipe2(OFlag::O_CLOEXEC).expect("create pipe");
7626        let reader = File::from(reader_fd);
7627        let writer = File::from(writer_fd);
7628        let response_writer =
7629            JavascriptSyncRpcResponseWriter::new(writer, Duration::from_millis(50));
7630        let pending = Arc::new(Mutex::new(Some(PendingSyncRpcState::Pending(7))));
7631
7632        spawn_javascript_sync_rpc_timeout(
7633            7,
7634            Duration::from_millis(20),
7635            pending.clone(),
7636            Some(response_writer),
7637        );
7638
7639        let mut line = String::new();
7640        let mut reader = BufReader::new(reader);
7641        reader.read_line(&mut line).expect("read timeout response");
7642
7643        let response: Value = serde_json::from_str(line.trim()).expect("parse timeout response");
7644        assert_eq!(response["id"], Value::from(7));
7645        assert_eq!(response["ok"], Value::from(false));
7646        assert_eq!(
7647            response["error"]["code"],
7648            Value::String(String::from("ERR_AGENTOS_NODE_SYNC_RPC_TIMEOUT"))
7649        );
7650        assert!(response["error"]["message"]
7651            .as_str()
7652            .expect("timeout message")
7653            .contains("timed out after 20ms"));
7654        assert_eq!(
7655            *pending.lock().expect("pending state lock"),
7656            Some(PendingSyncRpcState::TimedOut(7))
7657        );
7658    }
7659
7660    #[test]
7661    fn javascript_sync_rpc_response_writer_times_out_when_queue_is_full() {
7662        let (sender, _receiver) = mpsc::sync_channel(1);
7663        let writer = JavascriptSyncRpcResponseWriter {
7664            sender,
7665            timeout: Duration::from_millis(30),
7666        };
7667
7668        writer
7669            .send(b"first\n".to_vec())
7670            .expect("queue first response");
7671
7672        let started = Instant::now();
7673        let error = writer
7674            .send(b"second\n".to_vec())
7675            .expect_err("full queue should time out");
7676        assert!(
7677            started.elapsed() >= Duration::from_millis(30),
7678            "send should wait for the configured timeout"
7679        );
7680        assert!(error
7681            .to_string()
7682            .contains("timed out after 30ms while queueing JavaScript sync RPC response"));
7683    }
7684
7685    #[test]
7686    fn javascript_wait_capture_rejects_output_over_limit() {
7687        let mut stdout = vec![b'x'; JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES - 1];
7688        append_captured_output(&mut stdout, vec![b'y'], "stdout").expect("fill to limit");
7689        assert_eq!(stdout.len(), JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES);
7690
7691        let error = append_captured_output(&mut stdout, vec![b'z'], "stdout")
7692            .expect_err("captured output over limit should fail");
7693        assert!(matches!(
7694            error,
7695            JavascriptExecutionError::OutputBufferExceeded {
7696                stream: "stdout",
7697                limit: JAVASCRIPT_CAPTURED_OUTPUT_LIMIT_BYTES,
7698            }
7699        ));
7700    }
7701
7702    #[test]
7703    fn kernel_stdin_bridge_rejects_buffer_over_limit_and_closed_writes() {
7704        let bridge = LocalKernelStdinBridge::default();
7705        bridge
7706            .write(&vec![b'x'; KERNEL_STDIN_BUFFER_LIMIT_BYTES])
7707            .expect("fill stdin buffer to limit");
7708
7709        let error = bridge
7710            .write(b"y")
7711            .expect_err("stdin buffer over limit should fail");
7712        assert!(matches!(error, JavascriptExecutionError::Stdin(_)));
7713
7714        let bridge = LocalKernelStdinBridge::default();
7715        bridge.close();
7716        let error = bridge
7717            .write(b"x")
7718            .expect_err("write after stdin close should fail");
7719        assert!(matches!(error, JavascriptExecutionError::StdinClosed));
7720    }
7721
7722    #[test]
7723    fn kernel_stdin_bridge_null_timeout_waits_for_readiness_without_polling() {
7724        let bridge = Arc::new(LocalKernelStdinBridge::default());
7725        let reader = Arc::clone(&bridge);
7726        let (sender, receiver) = std::sync::mpsc::channel();
7727        let thread = std::thread::spawn(move || {
7728            sender
7729                .send(reader.read(&[json!(64), Value::Null]))
7730                .expect("publish stdin result");
7731        });
7732
7733        assert!(receiver.recv_timeout(Duration::from_millis(20)).is_err());
7734        bridge.write(b"ready").expect("make stdin readable");
7735        let value = receiver
7736            .recv_timeout(Duration::from_secs(1))
7737            .expect("readiness should wake the parked read");
7738        assert_eq!(
7739            value["dataBase64"],
7740            Value::String(v8_runtime::base64_encode_pub(b"ready"))
7741        );
7742        thread.join().expect("stdin reader exits");
7743    }
7744
7745    #[test]
7746    fn javascript_event_sender_reports_closed_receiver() {
7747        let (sender, receiver) = flume::bounded(1);
7748        drop(receiver);
7749        let gauge = register_queue(TrackedLimit::JavascriptEventChannel, 1);
7750        assert!(!send_javascript_event(
7751            &sender,
7752            &gauge,
7753            None,
7754            JavascriptExecutionEvent::Exited(1)
7755        ));
7756    }
7757
7758    // Regression: a full event channel must apply backpressure, not destroy the
7759    // session. The old code called `v8_session.destroy()` on the first `Full`,
7760    // truncating the stream and tearing the session down.
7761    #[test]
7762    fn javascript_event_sender_backpressures_instead_of_destroying_when_full() {
7763        let gauge = register_queue(TrackedLimit::JavascriptEventChannel, 1);
7764        let (sender, event_receiver) = flume::bounded(1);
7765
7766        // Drain slowly on another thread so the producer is forced onto the
7767        // blocking-backpressure path the old destroy-on-full code never reached.
7768        let drainer = std::thread::spawn(move || {
7769            let mut drained = 0usize;
7770            while event_receiver.recv().is_ok() {
7771                drained += 1;
7772                std::thread::sleep(std::time::Duration::from_millis(1));
7773            }
7774            drained
7775        });
7776
7777        // Far more events than the 1-slot channel holds; every send must succeed.
7778        const SENDS: usize = 16;
7779        for _ in 0..SENDS {
7780            assert!(send_javascript_event(
7781                &sender,
7782                &gauge,
7783                None,
7784                JavascriptExecutionEvent::Stdout(Vec::new())
7785            ));
7786        }
7787        drop(sender);
7788        let drained = drainer.join().expect("drainer thread panicked");
7789        assert_eq!(drained, SENDS, "every event must survive backpressure");
7790    }
7791
7792    #[test]
7793    fn javascript_event_sender_chunks_oversized_output_without_data_loss() {
7794        let (sender, event_receiver) = flume::bounded(JAVASCRIPT_EVENT_CHANNEL_CAPACITY);
7795        let gauge = register_queue(
7796            TrackedLimit::JavascriptEventChannel,
7797            JAVASCRIPT_EVENT_CHANNEL_CAPACITY,
7798        );
7799        let payload = vec![b'x'; JAVASCRIPT_EVENT_PAYLOAD_LIMIT_BYTES + 17];
7800
7801        assert!(send_javascript_event(
7802            &sender,
7803            &gauge,
7804            None,
7805            JavascriptExecutionEvent::Stdout(payload.clone())
7806        ));
7807
7808        let first = event_receiver.recv().expect("first chunk");
7809        let second = event_receiver.recv().expect("second chunk");
7810        let joined = [first, second]
7811            .into_iter()
7812            .flat_map(|event| match event {
7813                JavascriptExecutionEvent::Stdout(chunk) => chunk,
7814                other => panic!("unexpected event: {other:?}"),
7815            })
7816            .collect::<Vec<_>>();
7817        assert_eq!(joined, payload);
7818    }
7819
7820    #[test]
7821    fn internal_bridge_host_context_resolves_relative_module_path() {
7822        let unique = SystemTime::now()
7823            .duration_since(UNIX_EPOCH)
7824            .expect("system time")
7825            .as_nanos();
7826        let root = std::env::temp_dir().join(format!(
7827            "secure-exec-module-bridge-{}-{unique}",
7828            std::process::id()
7829        ));
7830        let bin_dir = root.join("node_modules/next/dist/bin");
7831        let cli_dir = root.join("node_modules/next/dist/cli");
7832        fs::create_dir_all(&bin_dir).expect("create bin dir");
7833        fs::create_dir_all(&cli_dir).expect("create cli dir");
7834        fs::write(
7835            root.join("node_modules/next/package.json"),
7836            r#"{"name":"next"}"#,
7837        )
7838        .expect("write package.json");
7839        fs::write(bin_dir.join("next"), "#!/usr/bin/env node\n").expect("write next bin");
7840        fs::write(cli_dir.join("next-build.js"), "module.exports = 1;\n")
7841            .expect("write next-build.js");
7842
7843        let env = BTreeMap::new();
7844        let result = handle_internal_bridge_call_from_host_context(
7845            &root,
7846            "/",
7847            &env,
7848            "_resolveModule",
7849            &[
7850                Value::String(String::from("../cli/next-build.js")),
7851                Value::String(String::from("/node_modules/next/dist/bin/next")),
7852                Value::String(String::from("import")),
7853            ],
7854        );
7855
7856        assert_eq!(
7857            result,
7858            Some(Value::String(String::from(
7859                "/node_modules/next/dist/cli/next-build.js"
7860            )))
7861        );
7862
7863        fs::remove_dir_all(&root).expect("remove temp module tree");
7864    }
7865
7866    #[test]
7867    fn register_v8_session_deregisters_on_create_session_failure() {
7868        let runtime = default_test_runtime_context().expect("test runtime context");
7869        let host = V8RuntimeHost::spawn(&runtime).expect("spawn V8 runtime host");
7870        let session_id = format!(
7871            "v8-register-failure-{}",
7872            SystemTime::now()
7873                .duration_since(UNIX_EPOCH)
7874                .expect("system time")
7875                .as_nanos()
7876        );
7877
7878        let error = match register_v8_session(
7879            &host,
7880            &runtime,
7881            session_id.clone(),
7882            0,
7883            0,
7884            0,
7885            None,
7886            |_command| {
7887                Err(std::io::Error::new(
7888                    std::io::ErrorKind::BrokenPipe,
7889                    "simulated CreateSession send failure",
7890                ))
7891            },
7892        ) {
7893            Ok(_) => panic!("register_v8_session should surface create-session send failures"),
7894            Err(error) => error,
7895        };
7896
7897        match error {
7898            JavascriptExecutionError::Spawn(inner) => {
7899                assert_eq!(inner.kind(), std::io::ErrorKind::BrokenPipe);
7900            }
7901            other => panic!("unexpected error: {other:?}"),
7902        }
7903        let receiver = host
7904            .register_session(&session_id, &runtime)
7905            .expect("failed registration should not leak the session output receiver");
7906        drop(receiver);
7907        host.unregister_session(&session_id);
7908    }
7909
7910    #[test]
7911    fn javascript_cpu_time_limit_defaults_to_bounded_value() {
7912        let request = StartJavascriptExecutionRequest {
7913            limits: Default::default(),
7914            argv0: None,
7915            guest_runtime: Default::default(),
7916            vm_id: String::from("vm-js-default-cpu"),
7917            context_id: String::from("ctx-js-default-cpu"),
7918            argv: vec![String::from("./entry.mjs")],
7919            env: BTreeMap::new(),
7920            cwd: std::path::PathBuf::from("/tmp"),
7921            wasm_module_bytes: None,
7922            inline_code: None,
7923        };
7924
7925        assert_eq!(
7926            javascript_cpu_time_limit_ms(&request),
7927            30_000,
7928            "unset JavaScript CPU budget must be bounded by default"
7929        );
7930    }
7931
7932    #[test]
7933    fn javascript_execution_drop_keeps_normal_v8_session_cleanup() {
7934        let temp = tempdir().expect("create temp dir");
7935        let mut engine = JavascriptExecutionEngine::default();
7936        let context = engine.create_context(CreateJavascriptContextRequest {
7937            vm_id: String::from("vm-drop-cleanup"),
7938            bootstrap_module: None,
7939            compile_cache_root: None,
7940        });
7941
7942        let execution = engine
7943            .start_execution(StartJavascriptExecutionRequest {
7944                limits: Default::default(),
7945                argv0: None,
7946                guest_runtime: Default::default(),
7947                vm_id: String::from("vm-drop-cleanup"),
7948                context_id: context.context_id,
7949                argv: vec![String::from("./entry.mjs")],
7950                env: BTreeMap::new(),
7951                cwd: temp.path().to_path_buf(),
7952                wasm_module_bytes: None,
7953                inline_code: Some(String::from("globalThis.__agentOSDropCleanup = true;")),
7954            })
7955            .expect("start JavaScript execution");
7956        let session_id = execution.v8_session.session_id().to_owned();
7957        let runtime = engine.runtime_context().expect("engine runtime").clone();
7958        let host = engine.v8_host.as_ref().expect("shared V8 runtime host");
7959
7960        drop(execution);
7961
7962        let receiver = host
7963            .register_session(&session_id, &runtime)
7964            .expect("execution drop should still destroy and deregister the session");
7965        drop(receiver);
7966        host.unregister_session(&session_id);
7967    }
7968
7969    #[test]
7970    fn prepared_execution_does_not_enqueue_guest_code_until_started() {
7971        let temp = tempdir().expect("create temp dir");
7972        let mut engine = JavascriptExecutionEngine::default();
7973        let context = engine.create_context(CreateJavascriptContextRequest {
7974            vm_id: String::from("vm-deferred-exec"),
7975            bootstrap_module: None,
7976            compile_cache_root: None,
7977        });
7978
7979        let mut execution = engine
7980            .prepare_execution(StartJavascriptExecutionRequest {
7981                limits: Default::default(),
7982                argv0: None,
7983                guest_runtime: Default::default(),
7984                vm_id: String::from("vm-deferred-exec"),
7985                context_id: context.context_id,
7986                argv: vec![String::from("./entry.mjs")],
7987                env: BTreeMap::new(),
7988                cwd: temp.path().to_path_buf(),
7989                wasm_module_bytes: None,
7990                inline_code: Some(String::from("process.stdout.write('started\\n');")),
7991            })
7992            .expect("prepare JavaScript execution");
7993
7994        assert!(execution.is_prepared_for_start());
7995        assert_eq!(
7996            execution
7997                .poll_event_blocking(Duration::ZERO)
7998                .expect("poll prepared execution"),
7999            None,
8000            "preparation must not enqueue any guest code"
8001        );
8002
8003        execution
8004            .start_prepared()
8005            .expect("start prepared execution");
8006        assert!(!execution.is_prepared_for_start());
8007        let result = execution.wait().expect("wait for prepared execution");
8008        assert_eq!(result.exit_code, 0);
8009        assert_eq!(result.stdout, b"started\n");
8010    }
8011
8012    // --- Timer cancellation / cap regression tests (U4: H2 bridge timers, M3
8013    // kernel timers). These assert the *safeguards firing* (delay clamped, timer
8014    // entry reclaimed, callback suppressed) and never spawn unbounded threads. ---
8015
8016    #[test]
8017    fn timer_delay_is_clamped_to_the_cap() {
8018        // A guest can pass an arbitrarily large delay (up to u64::MAX ms); without
8019        // a cap the timer wheel could retain a session Arc behind a deadline that
8020        // is effectively forever away. The cap bounds that lifetime.
8021        assert_eq!(
8022            timer_delay_ms(Some(&json!(u64::MAX))),
8023            MAX_TIMER_DELAY_MS,
8024            "a u64::MAX delay must be clamped to MAX_TIMER_DELAY_MS"
8025        );
8026        assert_eq!(
8027            timer_delay_ms(Some(&json!(1.0e308_f64))),
8028            MAX_TIMER_DELAY_MS,
8029            "an enormous float delay must be clamped to the cap"
8030        );
8031        assert_eq!(
8032            timer_delay_ms(Some(&json!(MAX_TIMER_DELAY_MS + 1))),
8033            MAX_TIMER_DELAY_MS,
8034            "a delay one past the cap must clamp down to the cap"
8035        );
8036        // Below-cap values pass through unchanged so normal timers are unaffected.
8037        assert_eq!(timer_delay_ms(Some(&json!(250))), 250);
8038        assert_eq!(timer_delay_ms(Some(&json!(0))), 0);
8039    }
8040
8041    #[test]
8042    fn cleared_timer_is_suppressed_and_entry_reclaimed() {
8043        // Mirrors what a woken bridge/kernel timer action does after waiting: it
8044        // consults the shared map via `timer_should_fire`. When the entry has been
8045        // removed (clear or session teardown), the callback must be suppressed.
8046        let timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>> =
8047            Arc::new(Mutex::new(HashMap::new()));
8048        timers.lock().unwrap().insert(
8049            7,
8050            LocalTimerEntry {
8051                delay_ms: 1_000,
8052                generation: 0,
8053                repeat: false,
8054                _reservation: None,
8055            },
8056        );
8057
8058        // Simulate `kernelTimerClear` / teardown removing the entry before the
8059        // action wakes.
8060        timers.lock().unwrap().remove(&7);
8061
8062        assert!(
8063            !timer_should_fire(&timers, 7, 0),
8064            "a cleared timer must not fire"
8065        );
8066        assert!(
8067            timers.lock().unwrap().is_empty(),
8068            "tracking map stays empty after a cleared timer is evaluated"
8069        );
8070    }
8071
8072    #[test]
8073    fn rearmed_timer_generation_mismatch_suppresses_stale_action() {
8074        // The bridge/kernel timer action captures the generation at schedule time.
8075        // If the timer is re-armed (generation bumped) before the stale action
8076        // wakes, the stale action must observe the mismatch and suppress, while the
8077        // entry survives for the live generation.
8078        let timers: Arc<Mutex<HashMap<u64, LocalTimerEntry>>> =
8079            Arc::new(Mutex::new(HashMap::new()));
8080        timers.lock().unwrap().insert(
8081            3,
8082            LocalTimerEntry {
8083                delay_ms: 10,
8084                generation: 1,
8085                repeat: false,
8086                _reservation: None,
8087            },
8088        );
8089
8090        // Stale action captured generation 0; current entry is at generation 1.
8091        assert!(
8092            !timer_should_fire(&timers, 3, 0),
8093            "a stale generation must be suppressed"
8094        );
8095        assert!(
8096            timers.lock().unwrap().contains_key(&3),
8097            "the live entry must survive a stale-generation evaluation"
8098        );
8099
8100        // The matching (current) generation fires and reclaims the one-shot entry.
8101        assert!(
8102            timer_should_fire(&timers, 3, 1),
8103            "the current generation must fire"
8104        );
8105        assert!(
8106            timers.lock().unwrap().is_empty(),
8107            "a fired one-shot timer must reclaim its id from the map"
8108        );
8109    }
8110
8111    #[test]
8112    fn timer_registration_reserves_before_insert_and_releases_on_remove() {
8113        use agentos_runtime::accounting::{ResourceClass, ResourceLedger, ResourceLimit};
8114
8115        let ledger = Arc::new(ResourceLedger::root(
8116            "vm=test",
8117            [(
8118                ResourceClass::Timers,
8119                ResourceLimit::new(1, "limits.jsRuntime.maxTimers"),
8120            )],
8121        ));
8122        let mut state = LocalBridgeState::default();
8123        state.timer_resources = Some(Arc::clone(&ledger));
8124        state.max_timers = 2;
8125
8126        let first = state.register_timer(10, false).expect("first timer");
8127        assert_eq!(ledger.usage(ResourceClass::Timers).used, 1);
8128        let error = state
8129            .register_timer(10, false)
8130            .expect_err("second timer must hit the ledger bound");
8131        assert!(error.contains("limits.jsRuntime.maxTimers"), "{error}");
8132        assert_eq!(state.timers.lock().unwrap().len(), 1);
8133
8134        state.clear_kernel_timer(first);
8135        assert_eq!(ledger.usage(ResourceClass::Timers).used, 0);
8136        state
8137            .register_timer(10, false)
8138            .expect("released admission is reusable");
8139    }
8140
8141    #[test]
8142    fn bridge_timer_registration_is_tracked_and_drop_clears_timers() {
8143        // H2: the bridge-timer path must register its timer (so it is cancellable)
8144        // before queuing a wheel action, and session teardown
8145        // (dropping LocalBridgeState) must wipe the tracking map so in-flight timer
8146        // actions are cancelled.
8147        let mut state = LocalBridgeState::default();
8148        // Observe the same map the queued actions would consult.
8149        let timers = state.timers.clone();
8150
8151        let id_a = state
8152            .register_oneshot_timer(MAX_TIMER_DELAY_MS)
8153            .expect("register first timer");
8154        let id_b = state
8155            .register_oneshot_timer(500)
8156            .expect("register second timer");
8157        assert_ne!(id_a, id_b, "each bridge timer gets a fresh id");
8158        assert_eq!(
8159            timers.lock().unwrap().len(),
8160            2,
8161            "registered bridge timers are tracked in the shared map"
8162        );
8163        // A registered timer would fire for its captured generation (proving the
8164        // entry is real and consultable) ...
8165        assert!(timer_should_fire(&timers, id_a, 0));
8166        // ... and seeding a still-pending one before teardown:
8167        let id_c = state
8168            .register_oneshot_timer(1_000)
8169            .expect("register third timer");
8170
8171        // Session teardown: dropping the bridge state must clear every timer so any
8172        // queued action wakes to a missing entry and suppresses its callback.
8173        drop(state);
8174
8175        assert!(
8176            timers.lock().unwrap().is_empty(),
8177            "dropping LocalBridgeState must clear the timers map on teardown"
8178        );
8179        assert!(
8180            !timer_should_fire(&timers, id_c, 0),
8181            "a pending bridge timer is suppressed after teardown"
8182        );
8183    }
8184}