Skip to main content

agentos_execution/
python.rs

1use crate::common::{encode_json_string, frozen_time_ms};
2use crate::javascript::{
3    CreateJavascriptContextRequest, GuestRuntimeConfig, JavascriptExecution,
4    JavascriptExecutionEngine, JavascriptExecutionError, JavascriptExecutionEvent,
5    JavascriptExecutionLimits, JavascriptSyncRpcRequest, StartJavascriptExecutionRequest,
6};
7use crate::node_import_cache::{NodeImportCache, NODE_IMPORT_CACHE_ASSET_ROOT_ENV};
8use crate::runtime_support::{
9    env_flag_enabled, file_fingerprint, resolve_execution_path, warmup_marker_path,
10    NODE_DISABLE_COMPILE_CACHE_ENV, NODE_FROZEN_TIME_ENV,
11};
12use crate::v8_runtime;
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17use std::fmt;
18use std::fs;
19use std::os::unix::fs::MetadataExt;
20use std::path::{Component, Path, PathBuf};
21use std::sync::{Arc, Mutex};
22use std::thread;
23use std::time::{Duration, Instant};
24const NODE_ALLOW_PROCESS_BINDINGS_ENV: &str = "AGENTOS_ALLOW_PROCESS_BINDINGS";
25const NODE_GUEST_PATH_MAPPINGS_ENV: &str = "AGENTOS_GUEST_PATH_MAPPINGS";
26const NODE_SYNC_RPC_DATA_BYTES_ENV: &str = "AGENTOS_NODE_SYNC_RPC_DATA_BYTES";
27const PYODIDE_INDEX_URL_ENV: &str = "AGENTOS_PYODIDE_INDEX_URL";
28const PYODIDE_PACKAGE_BASE_URL_ENV: &str = "AGENTOS_PYODIDE_PACKAGE_BASE_URL";
29const PYODIDE_PACKAGE_CACHE_DIR_ENV: &str = "AGENTOS_PYODIDE_PACKAGE_CACHE_DIR";
30const PYODIDE_GUEST_ROOT: &str = "/__agentos_pyodide";
31const PYODIDE_CACHE_GUEST_ROOT: &str = "/__agentos_pyodide_cache";
32const PYTHON_CODE_ENV: &str = "AGENTOS_PYTHON_CODE";
33const PYTHON_FILE_ENV: &str = "AGENTOS_PYTHON_FILE";
34const PYTHON_PREWARM_ONLY_ENV: &str = "AGENTOS_PYTHON_PREWARM_ONLY";
35const PYTHON_WARMUP_DEBUG_ENV: &str = "AGENTOS_PYTHON_WARMUP_DEBUG";
36const PYTHON_WARMUP_METRICS_PREFIX: &str = "__AGENTOS_PYTHON_WARMUP_METRICS__:";
37const PYTHON_WARMUP_MARKER_VERSION: &str = "2";
38const DEFAULT_PYTHON_OUTPUT_BUFFER_MAX_BYTES: usize = 1024 * 1024;
39const DEFAULT_PYTHON_EXECUTION_TIMEOUT_MS: u64 = 5 * 60 * 1000;
40const DEFAULT_PYTHON_MAX_OLD_SPACE_MB: usize = 0;
41const DEFAULT_PYTHON_VFS_RPC_TIMEOUT_MS: u64 = 30_000;
42const PYTHON_SYNC_RPC_DATA_BYTES: usize = 20 * 1024 * 1024;
43const PYTHON_SYNC_RPC_WAIT_TIMEOUT_MS: u64 = 120_000;
44const PYTHON_PREWARM_TIMEOUT: Duration = Duration::from_secs(120);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PythonVfsRpcMethod {
48    Read,
49    Write,
50    Stat,
51    Lstat,
52    ReadDir,
53    Mkdir,
54    Unlink,
55    Rmdir,
56    Rename,
57    Symlink,
58    ReadLink,
59    Setattr,
60    HttpRequest,
61    DnsLookup,
62    SubprocessRun,
63    SocketConnect,
64    SocketSend,
65    SocketRecv,
66    SocketClose,
67    UdpCreate,
68    UdpSendto,
69    UdpRecvfrom,
70}
71
72impl PythonVfsRpcMethod {
73    fn from_wire(value: &str) -> Option<Self> {
74        match value {
75            "fsRead" => Some(Self::Read),
76            "fsWrite" => Some(Self::Write),
77            "fsStat" => Some(Self::Stat),
78            "fsLstat" => Some(Self::Lstat),
79            "fsReaddir" => Some(Self::ReadDir),
80            "fsMkdir" => Some(Self::Mkdir),
81            "fsUnlink" => Some(Self::Unlink),
82            "fsRmdir" => Some(Self::Rmdir),
83            "fsRename" => Some(Self::Rename),
84            "fsSymlink" => Some(Self::Symlink),
85            "fsReadlink" => Some(Self::ReadLink),
86            "fsSetattr" => Some(Self::Setattr),
87            "httpRequest" => Some(Self::HttpRequest),
88            "dnsLookup" => Some(Self::DnsLookup),
89            "subprocessRun" => Some(Self::SubprocessRun),
90            "socketConnect" => Some(Self::SocketConnect),
91            "socketSend" => Some(Self::SocketSend),
92            "socketRecv" => Some(Self::SocketRecv),
93            "socketClose" => Some(Self::SocketClose),
94            "udpCreate" => Some(Self::UdpCreate),
95            "udpSendto" => Some(Self::UdpSendto),
96            "udpRecvfrom" => Some(Self::UdpRecvfrom),
97            _ => None,
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PythonVfsRpcRequest {
104    pub id: u64,
105    pub method: PythonVfsRpcMethod,
106    pub path: String,
107    /// Second path for `Rename` (the destination); `None` for other methods.
108    pub destination: Option<String>,
109    /// Symlink target (the path the link points at), for `Symlink`.
110    pub target: Option<String>,
111    /// `Setattr` metadata fields (each applied only when present).
112    pub mode: Option<u32>,
113    pub uid: Option<u32>,
114    pub gid: Option<u32>,
115    pub atime_ms: Option<u64>,
116    pub mtime_ms: Option<u64>,
117    pub content_base64: Option<String>,
118    pub recursive: bool,
119    pub url: Option<String>,
120    pub http_method: Option<String>,
121    pub headers: BTreeMap<String, String>,
122    pub body_base64: Option<String>,
123    pub hostname: Option<String>,
124    pub family: Option<u8>,
125    /// Port for socket connect/sendto.
126    pub port: Option<u16>,
127    /// Socket handle for send/recv/close/sendto/recvfrom.
128    pub socket_id: Option<u64>,
129    pub command: Option<String>,
130    pub args: Vec<String>,
131    pub cwd: Option<String>,
132    pub env: BTreeMap<String, String>,
133    pub shell: bool,
134    pub max_buffer: Option<usize>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct PythonVfsRpcStat {
139    pub mode: u32,
140    pub size: u64,
141    pub is_directory: bool,
142    pub is_symbolic_link: bool,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum PythonVfsRpcResponsePayload {
147    Empty,
148    Read {
149        content_base64: String,
150    },
151    Stat {
152        stat: PythonVfsRpcStat,
153    },
154    ReadDir {
155        entries: Vec<String>,
156    },
157    Http {
158        status: u16,
159        reason: String,
160        url: String,
161        headers: BTreeMap<String, Vec<String>>,
162        body_base64: String,
163    },
164    DnsLookup {
165        addresses: Vec<String>,
166    },
167    SubprocessRun {
168        exit_code: i32,
169        stdout: String,
170        stderr: String,
171        max_buffer_exceeded: bool,
172    },
173    SocketCreated {
174        socket_id: u64,
175    },
176    SocketSent {
177        bytes_sent: usize,
178    },
179    SocketReceived {
180        data_base64: String,
181        closed: bool,
182        timed_out: bool,
183    },
184    UdpReceived {
185        data_base64: String,
186        host: String,
187        port: u16,
188        timed_out: bool,
189    },
190    SymlinkTarget {
191        target: String,
192    },
193}
194
195#[derive(Debug, Deserialize)]
196#[serde(rename_all = "camelCase")]
197struct PythonVfsBridgeRequestWire {
198    method: String,
199    #[serde(default)]
200    path: String,
201    #[serde(default)]
202    destination: Option<String>,
203    #[serde(default)]
204    target: Option<String>,
205    // JS numbers cross the bridge as f64; accept that and narrow below.
206    #[serde(default)]
207    mode: Option<f64>,
208    #[serde(default)]
209    uid: Option<f64>,
210    #[serde(default)]
211    gid: Option<f64>,
212    #[serde(default, rename = "atimeMs")]
213    atime_ms: Option<f64>,
214    #[serde(default, rename = "mtimeMs")]
215    mtime_ms: Option<f64>,
216    #[serde(default)]
217    content_base64: Option<String>,
218    #[serde(default)]
219    recursive: bool,
220    #[serde(default)]
221    url: Option<String>,
222    #[serde(default, rename = "httpMethod")]
223    http_method: Option<String>,
224    #[serde(default)]
225    headers: BTreeMap<String, String>,
226    #[serde(default, rename = "bodyBase64")]
227    body_base64: Option<String>,
228    #[serde(default)]
229    hostname: Option<String>,
230    #[serde(default)]
231    family: Option<u8>,
232    #[serde(default)]
233    port: Option<u16>,
234    #[serde(default, rename = "socketId")]
235    socket_id: Option<u64>,
236    #[serde(default)]
237    command: Option<String>,
238    #[serde(default)]
239    args: Vec<String>,
240    #[serde(default)]
241    cwd: Option<String>,
242    #[serde(default)]
243    env: BTreeMap<String, String>,
244    #[serde(default)]
245    shell: bool,
246    #[serde(default, rename = "maxBuffer")]
247    max_buffer: Option<usize>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251#[serde(rename_all = "camelCase")]
252struct PythonGuestPathMappingWire {
253    guest_path: String,
254    host_path: String,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct CreatePythonContextRequest {
259    pub vm_id: String,
260    pub pyodide_dist_path: PathBuf,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct PythonContext {
265    pub context_id: String,
266    pub vm_id: String,
267    pub pyodide_dist_path: PathBuf,
268}
269
270/// Per-execution Python runtime limits, carried as typed fields rather than
271/// `AGENTOS_*` env vars. Populated by the sidecar from the per-VM `VmLimits`
272/// (originating from `CreateVmConfig` on the BARE wire); `None` selects the
273/// engine default. See the env-vs-wire rule in `crates/sidecar/CLAUDE.md`.
274#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct PythonExecutionLimits {
276    /// Captured-output buffer cap in bytes. `None` keeps the engine default.
277    pub output_buffer_max_bytes: Option<usize>,
278    /// Execution wall-clock cap in ms. `None` keeps the engine default;
279    /// `Some(0)` disables the timeout.
280    pub execution_timeout_ms: Option<u64>,
281    /// Pyodide V8 old-space cap in MB (`0` keeps the V8 default). `None` keeps
282    /// the engine default.
283    pub max_old_space_mb: Option<usize>,
284    /// VFS sync-RPC wait ceiling in ms. `None` keeps the engine default.
285    pub vfs_rpc_timeout_ms: Option<u64>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct StartPythonExecutionRequest {
290    pub vm_id: String,
291    pub context_id: String,
292    pub code: String,
293    pub file_path: Option<PathBuf>,
294    pub env: BTreeMap<String, String>,
295    pub cwd: PathBuf,
296    /// Per-execution runtime limits (see [`PythonExecutionLimits`]).
297    pub limits: PythonExecutionLimits,
298    /// Per-execution guest-runtime config, forwarded to the Pyodide runner's JS
299    /// execution (see [`GuestRuntimeConfig`]).
300    pub guest_runtime: GuestRuntimeConfig,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum PythonExecutionEvent {
305    Stdout(Vec<u8>),
306    Stderr(Vec<u8>),
307    JavascriptSyncRpcRequest(JavascriptSyncRpcRequest),
308    VfsRpcRequest(Box<PythonVfsRpcRequest>),
309    Exited(i32),
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct PythonExecutionResult {
314    pub execution_id: String,
315    pub exit_code: i32,
316    pub stdout: Vec<u8>,
317    pub stderr: Vec<u8>,
318}
319
320#[derive(Debug)]
321pub enum PythonExecutionError {
322    MissingContext(String),
323    VmMismatch {
324        expected: String,
325        found: String,
326    },
327    /// Guest Python is unavailable because this build was compiled without the
328    /// bundled Pyodide runtime assets (the published crate excludes them).
329    RuntimeUnavailable,
330    PrepareRuntime(std::io::Error),
331    PrepareWarmPath(std::io::Error),
332    WarmupFailed {
333        exit_code: i32,
334        stderr: String,
335    },
336    Spawn(std::io::Error),
337    StdinClosed,
338    Stdin(std::io::Error),
339    Kill(std::io::Error),
340    TimedOut(Duration),
341    PendingVfsRpcRequest(u64),
342    RpcResponse(String),
343    OutputBufferExceeded {
344        stream: &'static str,
345        limit: usize,
346    },
347    EventChannelClosed,
348}
349
350impl fmt::Display for PythonExecutionError {
351    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352        match self {
353            Self::MissingContext(context_id) => {
354                write!(f, "unknown guest Python context: {context_id}")
355            }
356            Self::VmMismatch { expected, found } => {
357                write!(
358                    f,
359                    "guest Python context belongs to vm {expected}, not {found}"
360                )
361            }
362            Self::RuntimeUnavailable => write!(
363                f,
364                "guest Python execution is unavailable: this build of agentos-execution \
365                 was compiled without the bundled Pyodide runtime assets"
366            ),
367            Self::PrepareRuntime(err) => {
368                write!(f, "failed to prepare guest Python runtime assets: {err}")
369            }
370            Self::PrepareWarmPath(err) => {
371                write!(f, "failed to prepare guest Python warm path: {err}")
372            }
373            Self::WarmupFailed { exit_code, stderr } => {
374                if stderr.trim().is_empty() {
375                    write!(f, "guest Python warmup exited with status {exit_code}")
376                } else {
377                    write!(
378                        f,
379                        "guest Python warmup exited with status {exit_code}: {}",
380                        stderr.trim()
381                    )
382                }
383            }
384            Self::Spawn(err) => write!(f, "failed to start guest Python runtime: {err}"),
385            Self::StdinClosed => f.write_str("guest Python stdin is already closed"),
386            Self::Stdin(err) => write!(f, "failed to write guest stdin: {err}"),
387            Self::Kill(err) => write!(f, "failed to kill guest Python runtime: {err}"),
388            Self::TimedOut(timeout) => write!(
389                f,
390                "guest Python runtime timed out after {}ms",
391                timeout.as_millis()
392            ),
393            Self::PendingVfsRpcRequest(id) => {
394                write!(
395                    f,
396                    "guest Python execution requires servicing pending VFS RPC request {id}"
397                )
398            }
399            Self::RpcResponse(message) => {
400                write!(
401                    f,
402                    "failed to reply to guest Python VFS RPC request: {message}"
403                )
404            }
405            Self::OutputBufferExceeded { stream, limit } => {
406                write!(
407                    f,
408                    "guest Python {stream} exceeded the captured output limit of {limit} bytes"
409                )
410            }
411            Self::EventChannelClosed => {
412                f.write_str("guest Python event channel closed unexpectedly")
413            }
414        }
415    }
416}
417
418impl std::error::Error for PythonExecutionError {}
419
420/// Returns an error when this build was compiled without the bundled Pyodide
421/// runtime assets (the published crate excludes them; see `build.rs`). In the
422/// workspace build the in-tree assets are present and this is a no-op.
423fn ensure_pyodide_available() -> Result<(), PythonExecutionError> {
424    #[cfg(agentos_pyodide_unavailable)]
425    {
426        return Err(PythonExecutionError::RuntimeUnavailable);
427    }
428    #[cfg(not(agentos_pyodide_unavailable))]
429    {
430        Ok(())
431    }
432}
433
434#[derive(Debug)]
435pub struct PythonExecution {
436    execution_id: String,
437    child_pid: u32,
438    inner: JavascriptExecution,
439    pyodide_dist_path: PathBuf,
440    pending_vfs_rpc: Arc<Mutex<Option<PendingVfsRpcState>>>,
441    v8_session: crate::v8_host::V8SessionHandle,
442    output_buffer_max_bytes: usize,
443    execution_timeout: Option<Duration>,
444    vfs_rpc_timeout: Duration,
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448enum PendingVfsRpcState {
449    Pending(u64),
450    TimedOut(u64),
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454enum PendingVfsRpcResolution {
455    Pending,
456    TimedOut,
457    Missing,
458}
459
460impl PythonExecution {
461    pub fn execution_id(&self) -> &str {
462        &self.execution_id
463    }
464
465    pub fn child_pid(&self) -> u32 {
466        self.child_pid
467    }
468
469    pub fn uses_shared_v8_runtime(&self) -> bool {
470        self.inner.uses_shared_v8_runtime()
471    }
472
473    pub fn write_stdin(&mut self, chunk: &[u8]) -> Result<(), PythonExecutionError> {
474        self.inner
475            .write_kernel_stdin_only(chunk)
476            .map_err(map_javascript_error)
477    }
478
479    pub fn close_stdin(&mut self) -> Result<(), PythonExecutionError> {
480        self.inner.close_kernel_stdin_only();
481        Ok(())
482    }
483
484    pub fn cancel(&mut self) -> Result<(), PythonExecutionError> {
485        self.kill()
486    }
487
488    pub fn kill(&mut self) -> Result<(), PythonExecutionError> {
489        self.close_stdin()?;
490        self.inner.terminate().map_err(map_javascript_error)
491    }
492
493    pub fn respond_vfs_rpc_success(
494        &mut self,
495        id: u64,
496        payload: PythonVfsRpcResponsePayload,
497    ) -> Result<(), PythonExecutionError> {
498        match self.clear_pending_vfs_rpc(id)? {
499            PendingVfsRpcResolution::Pending => {}
500            PendingVfsRpcResolution::TimedOut => {
501                return Err(PythonExecutionError::RpcResponse(format!(
502                    "VFS RPC request {id} is no longer pending"
503                )));
504            }
505            PendingVfsRpcResolution::Missing => {}
506        }
507
508        let result = match payload {
509            PythonVfsRpcResponsePayload::Empty => json!({}),
510            PythonVfsRpcResponsePayload::Read { content_base64 } => {
511                json!({ "contentBase64": content_base64 })
512            }
513            PythonVfsRpcResponsePayload::Stat { stat } => json!({
514                "stat": {
515                    "mode": stat.mode,
516                    "size": stat.size,
517                    "isDirectory": stat.is_directory,
518                    "isSymbolicLink": stat.is_symbolic_link,
519                }
520            }),
521            PythonVfsRpcResponsePayload::ReadDir { entries } => {
522                json!({ "entries": entries })
523            }
524            PythonVfsRpcResponsePayload::Http {
525                status,
526                reason,
527                url,
528                headers,
529                body_base64,
530            } => json!({
531                "status": status,
532                "reason": reason,
533                "url": url,
534                "headers": headers,
535                "bodyBase64": body_base64,
536            }),
537            PythonVfsRpcResponsePayload::DnsLookup { addresses } => {
538                json!({ "addresses": addresses })
539            }
540            PythonVfsRpcResponsePayload::SubprocessRun {
541                exit_code,
542                stdout,
543                stderr,
544                max_buffer_exceeded,
545            } => json!({
546                "exitCode": exit_code,
547                "stdout": stdout,
548                "stderr": stderr,
549                "maxBufferExceeded": max_buffer_exceeded,
550            }),
551            PythonVfsRpcResponsePayload::SocketCreated { socket_id } => json!({
552                "socketId": socket_id,
553            }),
554            PythonVfsRpcResponsePayload::SocketSent { bytes_sent } => json!({
555                "bytesSent": bytes_sent,
556            }),
557            PythonVfsRpcResponsePayload::SocketReceived {
558                data_base64,
559                closed,
560                timed_out,
561            } => json!({
562                "dataBase64": data_base64,
563                "closed": closed,
564                "timedOut": timed_out,
565            }),
566            PythonVfsRpcResponsePayload::UdpReceived {
567                data_base64,
568                host,
569                port,
570                timed_out,
571            } => json!({
572                "dataBase64": data_base64,
573                "host": host,
574                "port": port,
575                "timedOut": timed_out,
576            }),
577            PythonVfsRpcResponsePayload::SymlinkTarget { target } => json!({
578                "target": target,
579            }),
580        };
581
582        self.inner
583            .respond_sync_rpc_success(id, result)
584            .map_err(map_javascript_error)
585    }
586
587    pub fn respond_vfs_rpc_error(
588        &mut self,
589        id: u64,
590        code: impl Into<String>,
591        message: impl Into<String>,
592    ) -> Result<(), PythonExecutionError> {
593        match self.clear_pending_vfs_rpc(id)? {
594            PendingVfsRpcResolution::Pending => {}
595            PendingVfsRpcResolution::TimedOut => {
596                return Err(PythonExecutionError::RpcResponse(format!(
597                    "VFS RPC request {id} is no longer pending"
598                )));
599            }
600            PendingVfsRpcResolution::Missing => {}
601        }
602
603        self.inner
604            .respond_sync_rpc_error(id, code, message)
605            .map_err(map_javascript_error)
606    }
607
608    pub fn respond_javascript_sync_rpc_success(
609        &mut self,
610        id: u64,
611        result: Value,
612    ) -> Result<(), PythonExecutionError> {
613        self.inner
614            .respond_sync_rpc_success(id, result)
615            .map_err(map_javascript_error)
616    }
617
618    pub fn respond_javascript_sync_rpc_error(
619        &mut self,
620        id: u64,
621        code: impl Into<String>,
622        message: impl Into<String>,
623    ) -> Result<(), PythonExecutionError> {
624        self.inner
625            .respond_sync_rpc_error(id, code, message)
626            .map_err(map_javascript_error)
627    }
628
629    pub async fn poll_event(
630        &mut self,
631        timeout: Duration,
632    ) -> Result<Option<PythonExecutionEvent>, PythonExecutionError> {
633        let started = Instant::now();
634        loop {
635            let remaining = if timeout.is_zero() {
636                Duration::ZERO
637            } else {
638                timeout.saturating_sub(started.elapsed())
639            };
640            match self
641                .inner
642                .poll_event(remaining)
643                .await
644                .map_err(map_javascript_error)?
645            {
646                Some(event) => {
647                    if let Some(event) = self.translate_javascript_event(event)? {
648                        return Ok(Some(event));
649                    }
650                }
651                None => return Ok(None),
652            }
653        }
654    }
655
656    /// Service a module-resolution JS sync RPC host-directly via the underlying
657    /// JS execution's translator. For consumers driving `poll_event_blocking`
658    /// manually without a kernel/service loop.
659    pub fn try_service_standalone_module_sync_rpc(
660        &mut self,
661        request: &JavascriptSyncRpcRequest,
662    ) -> Result<bool, PythonExecutionError> {
663        self.inner
664            .try_service_standalone_module_sync_rpc(request)
665            .map_err(map_javascript_error)
666    }
667
668    pub fn poll_event_blocking(
669        &mut self,
670        timeout: Duration,
671    ) -> Result<Option<PythonExecutionEvent>, PythonExecutionError> {
672        let deadline = Instant::now() + timeout;
673        loop {
674            let remaining = deadline.saturating_duration_since(Instant::now());
675            match self
676                .inner
677                .poll_event_blocking(remaining)
678                .map_err(map_javascript_error)?
679            {
680                Some(event) => {
681                    if let Some(event) = self.translate_javascript_event(event)? {
682                        return Ok(Some(event));
683                    }
684                }
685                None => {
686                    if Instant::now() >= deadline {
687                        return Ok(None);
688                    }
689                }
690            }
691        }
692    }
693
694    pub fn wait(
695        mut self,
696        timeout: Option<Duration>,
697    ) -> Result<PythonExecutionResult, PythonExecutionError> {
698        self.close_stdin()?;
699
700        let mut stdout = PythonOutputBuffer::new(self.output_buffer_max_bytes);
701        let mut stderr = PythonOutputBuffer::new(self.output_buffer_max_bytes);
702        let started = Instant::now();
703        let timeout = match (timeout, self.execution_timeout) {
704            (Some(requested), Some(configured)) => Some(requested.min(configured)),
705            (Some(requested), None) => Some(requested),
706            (None, Some(configured)) => Some(configured),
707            (None, None) => None,
708        };
709        let runtime = tokio::runtime::Builder::new_current_thread()
710            .enable_all()
711            .build()
712            .expect("python wait runtime");
713
714        loop {
715            let poll_timeout = timeout
716                .map(|limit| {
717                    let elapsed = started.elapsed();
718                    if elapsed >= limit {
719                        Duration::ZERO
720                    } else {
721                        limit.saturating_sub(elapsed).min(Duration::from_millis(50))
722                    }
723                })
724                .unwrap_or_else(|| Duration::from_millis(50));
725
726            let event = runtime.block_on(self.poll_event(poll_timeout))?;
727
728            match event {
729                Some(PythonExecutionEvent::Stdout(chunk)) => stdout.extend(&chunk),
730                Some(PythonExecutionEvent::Stderr(chunk)) => stderr.extend(&chunk),
731                Some(PythonExecutionEvent::JavascriptSyncRpcRequest(request)) => {
732                    // Module-resolution sync RPCs are serviced host-directly via
733                    // the JS execution's own translator (the standalone Python
734                    // wait loop runs without a kernel/service loop).
735                    if self
736                        .inner
737                        .try_service_standalone_module_sync_rpc(&request)
738                        .map_err(map_javascript_error)?
739                    {
740                        continue;
741                    }
742                    if let Some((code, message)) = python_javascript_sync_rpc_error(&request) {
743                        self.inner
744                            .respond_sync_rpc_error(request.id, code, message)
745                            .map_err(map_javascript_error)?;
746                        continue;
747                    }
748                    return Err(PythonExecutionError::RpcResponse(format!(
749                        "guest Python execution requires servicing pending JavaScript sync RPC request {} {} {:?}",
750                        request.id, request.method, request.args
751                    )));
752                }
753                Some(PythonExecutionEvent::VfsRpcRequest(request)) => {
754                    return Err(PythonExecutionError::PendingVfsRpcRequest(request.id));
755                }
756                Some(PythonExecutionEvent::Exited(exit_code)) => {
757                    return Ok(PythonExecutionResult {
758                        execution_id: self.execution_id.clone(),
759                        exit_code,
760                        stdout: stdout.into_inner(),
761                        stderr: stderr.into_inner(),
762                    });
763                }
764                None => {}
765            }
766
767            if let Some(limit) = timeout {
768                if started.elapsed() >= limit {
769                    self.kill()?;
770                    return Err(PythonExecutionError::TimedOut(limit));
771                }
772            }
773        }
774    }
775
776    fn clear_pending_vfs_rpc(
777        &self,
778        id: u64,
779    ) -> Result<PendingVfsRpcResolution, PythonExecutionError> {
780        let mut pending = self
781            .pending_vfs_rpc
782            .lock()
783            .map_err(|_| PythonExecutionError::EventChannelClosed)?;
784        match *pending {
785            Some(PendingVfsRpcState::Pending(current)) if current == id => {
786                *pending = None;
787                Ok(PendingVfsRpcResolution::Pending)
788            }
789            Some(PendingVfsRpcState::TimedOut(current)) if current == id => {
790                Ok(PendingVfsRpcResolution::TimedOut)
791            }
792            _ => Ok(PendingVfsRpcResolution::Missing),
793        }
794    }
795
796    fn translate_javascript_event(
797        &mut self,
798        event: JavascriptExecutionEvent,
799    ) -> Result<Option<PythonExecutionEvent>, PythonExecutionError> {
800        match event {
801            JavascriptExecutionEvent::Stdout(chunk) => {
802                Ok(Some(PythonExecutionEvent::Stdout(chunk)))
803            }
804            JavascriptExecutionEvent::Stderr(chunk) => {
805                Ok(Some(PythonExecutionEvent::Stderr(chunk)))
806            }
807            JavascriptExecutionEvent::Exited(code) => Ok(Some(PythonExecutionEvent::Exited(code))),
808            JavascriptExecutionEvent::SignalState { .. } => Ok(None),
809            JavascriptExecutionEvent::SyncRpcRequest(request) => {
810                if request.method == "_pythonRpc" {
811                    let request = parse_python_bridge_sync_rpc_request(&request)?;
812                    set_pending_vfs_rpc_state(&self.pending_vfs_rpc, request.id)?;
813                    spawn_python_vfs_rpc_timeout(
814                        request.id,
815                        self.vfs_rpc_timeout,
816                        self.pending_vfs_rpc.clone(),
817                        self.v8_session.clone(),
818                    );
819                    Ok(Some(PythonExecutionEvent::VfsRpcRequest(Box::new(request))))
820                } else {
821                    if self.try_service_standalone_module_sync_rpc(&request)? {
822                        return Ok(None);
823                    }
824                    if let Some(action) =
825                        python_javascript_sync_rpc_action(&self.pyodide_dist_path, &request)?
826                    {
827                        respond_python_javascript_sync_rpc_action(
828                            &mut self.inner,
829                            request.id,
830                            action,
831                        )?;
832                        Ok(None)
833                    } else {
834                        Ok(Some(PythonExecutionEvent::JavascriptSyncRpcRequest(
835                            request,
836                        )))
837                    }
838                }
839            }
840        }
841    }
842}
843
844impl Drop for PythonExecution {
845    fn drop(&mut self) {
846        let _ = self.close_stdin();
847        let _ = self.inner.terminate();
848    }
849}
850
851#[derive(Debug, Default)]
852pub struct PythonExecutionEngine {
853    next_context_id: usize,
854    next_execution_id: usize,
855    contexts: BTreeMap<String, PythonContext>,
856    import_caches: BTreeMap<String, NodeImportCache>,
857    javascript_context_ids: BTreeMap<String, String>,
858    javascript_engine: JavascriptExecutionEngine,
859}
860
861impl PythonExecutionEngine {
862    pub fn bundled_pyodide_dist_path_for_vm(
863        &mut self,
864        vm_id: &str,
865    ) -> Result<PathBuf, PythonExecutionError> {
866        ensure_pyodide_available()?;
867        let import_cache = self.import_caches.entry(vm_id.to_owned()).or_default();
868        import_cache
869            .ensure_materialized()
870            .map_err(PythonExecutionError::PrepareRuntime)?;
871        Ok(import_cache.pyodide_dist_path().to_path_buf())
872    }
873
874    pub fn create_context(&mut self, request: CreatePythonContextRequest) -> PythonContext {
875        self.next_context_id += 1;
876        self.import_caches.entry(request.vm_id.clone()).or_default();
877        let javascript_context =
878            self.javascript_engine
879                .create_context(CreateJavascriptContextRequest {
880                    vm_id: request.vm_id.clone(),
881                    bootstrap_module: None,
882                    compile_cache_root: None,
883                });
884
885        let context = PythonContext {
886            context_id: format!("python-ctx-{}", self.next_context_id),
887            vm_id: request.vm_id,
888            pyodide_dist_path: request.pyodide_dist_path,
889        };
890        self.javascript_context_ids
891            .insert(context.context_id.clone(), javascript_context.context_id);
892        self.contexts
893            .insert(context.context_id.clone(), context.clone());
894        context
895    }
896
897    pub fn start_execution(
898        &mut self,
899        request: StartPythonExecutionRequest,
900    ) -> Result<PythonExecution, PythonExecutionError> {
901        ensure_pyodide_available()?;
902        let context = self
903            .contexts
904            .get(&request.context_id)
905            .cloned()
906            .ok_or_else(|| PythonExecutionError::MissingContext(request.context_id.clone()))?;
907
908        if context.vm_id != request.vm_id {
909            return Err(PythonExecutionError::VmMismatch {
910                expected: context.vm_id,
911                found: request.vm_id,
912            });
913        }
914
915        let frozen_time_ms = frozen_time_ms();
916        let javascript_context =
917            self.javascript_engine
918                .create_context(CreateJavascriptContextRequest {
919                    vm_id: request.vm_id.clone(),
920                    bootstrap_module: None,
921                    compile_cache_root: None,
922                });
923        let javascript_context_id = javascript_context.context_id.clone();
924        self.javascript_context_ids
925            .insert(context.context_id.clone(), javascript_context_id.clone());
926        let warmup_metrics = {
927            let import_cache = self.import_caches.entry(context.vm_id.clone()).or_default();
928            import_cache
929                .ensure_materialized()
930                .map_err(PythonExecutionError::PrepareRuntime)?;
931            prewarm_python_path(
932                import_cache,
933                &mut self.javascript_engine,
934                &javascript_context_id,
935                &context,
936                &request,
937                frozen_time_ms,
938            )?
939        };
940
941        self.next_execution_id += 1;
942        let execution_id = format!("exec-{}", self.next_execution_id);
943        let import_cache = self
944            .import_caches
945            .get(&context.vm_id)
946            .expect("vm import cache should exist after materialization");
947        let pyodide_dist_path =
948            resolved_pyodide_dist_path(&context.pyodide_dist_path, &request.cwd);
949        let javascript_execution = start_python_javascript_execution(
950            &mut self.javascript_engine,
951            import_cache,
952            &javascript_context_id,
953            &context,
954            &request,
955            PythonJavascriptExecutionOptions {
956                frozen_time_ms,
957                prewarm_only: false,
958                warmup_metrics: warmup_metrics.as_deref(),
959            },
960        )?;
961        let pending_vfs_rpc = Arc::new(Mutex::new(None));
962        let vfs_rpc_timeout = python_vfs_rpc_timeout(&request);
963
964        Ok(PythonExecution {
965            execution_id,
966            child_pid: javascript_execution.child_pid(),
967            v8_session: javascript_execution.v8_session_handle(),
968            inner: javascript_execution,
969            pyodide_dist_path,
970            pending_vfs_rpc,
971            output_buffer_max_bytes: python_output_buffer_max_bytes(&request),
972            execution_timeout: python_execution_timeout(&request),
973            vfs_rpc_timeout,
974        })
975    }
976
977    pub fn dispose_vm(&mut self, vm_id: &str) {
978        self.contexts.retain(|_, context| context.vm_id != vm_id);
979        self.javascript_context_ids
980            .retain(|python_context_id, _| self.contexts.contains_key(python_context_id));
981        self.import_caches.remove(vm_id);
982        self.javascript_engine.dispose_vm(vm_id);
983    }
984}
985
986fn set_pending_vfs_rpc_state(
987    pending_vfs_rpc: &Arc<Mutex<Option<PendingVfsRpcState>>>,
988    id: u64,
989) -> Result<(), PythonExecutionError> {
990    let mut pending = pending_vfs_rpc
991        .lock()
992        .map_err(|_| PythonExecutionError::EventChannelClosed)?;
993    *pending = Some(PendingVfsRpcState::Pending(id));
994    Ok(())
995}
996
997fn map_javascript_error(error: JavascriptExecutionError) -> PythonExecutionError {
998    match error {
999        JavascriptExecutionError::EmptyArgv => PythonExecutionError::Spawn(std::io::Error::new(
1000            std::io::ErrorKind::InvalidInput,
1001            "guest Python bootstrap requires a JavaScript entrypoint",
1002        )),
1003        JavascriptExecutionError::MissingContext(context_id) => {
1004            PythonExecutionError::MissingContext(context_id)
1005        }
1006        JavascriptExecutionError::VmMismatch { expected, found } => {
1007            PythonExecutionError::VmMismatch { expected, found }
1008        }
1009        JavascriptExecutionError::PrepareImportCache(error) => {
1010            PythonExecutionError::PrepareRuntime(error)
1011        }
1012        JavascriptExecutionError::Spawn(error) => PythonExecutionError::Spawn(error),
1013        JavascriptExecutionError::PendingSyncRpcRequest(id) => {
1014            PythonExecutionError::PendingVfsRpcRequest(id)
1015        }
1016        JavascriptExecutionError::ExpiredSyncRpcRequest(id) => {
1017            PythonExecutionError::RpcResponse(format!("VFS RPC request {id} is no longer pending"))
1018        }
1019        JavascriptExecutionError::RpcResponse(message) => {
1020            PythonExecutionError::RpcResponse(message)
1021        }
1022        JavascriptExecutionError::Terminate(error) => PythonExecutionError::Kill(error),
1023        JavascriptExecutionError::StdinClosed => PythonExecutionError::StdinClosed,
1024        JavascriptExecutionError::Stdin(error) => PythonExecutionError::Stdin(error),
1025        JavascriptExecutionError::OutputBufferExceeded { stream, limit } => {
1026            PythonExecutionError::OutputBufferExceeded { stream, limit }
1027        }
1028        JavascriptExecutionError::EventChannelClosed => PythonExecutionError::EventChannelClosed,
1029    }
1030}
1031
1032struct PythonJavascriptExecutionOptions<'a> {
1033    frozen_time_ms: u128,
1034    prewarm_only: bool,
1035    warmup_metrics: Option<&'a [u8]>,
1036}
1037
1038fn start_python_javascript_execution(
1039    javascript_engine: &mut JavascriptExecutionEngine,
1040    import_cache: &NodeImportCache,
1041    javascript_context_id: &str,
1042    context: &PythonContext,
1043    request: &StartPythonExecutionRequest,
1044    options: PythonJavascriptExecutionOptions<'_>,
1045) -> Result<JavascriptExecution, PythonExecutionError> {
1046    let internal_env = build_python_internal_env(
1047        import_cache,
1048        context,
1049        request,
1050        options.frozen_time_ms,
1051        options.prewarm_only,
1052    );
1053    let inline_code =
1054        build_python_runner_module_source(import_cache, &internal_env, options.warmup_metrics)?;
1055    let mut env = request.env.clone();
1056    env.extend(internal_env);
1057
1058    // The Pyodide runner is itself a V8 execution. Its heap cap (the Python
1059    // `maxOldSpaceMb` knob) and sync-RPC wait ceiling ride the typed runner
1060    // limits, not env — the JS engine reads them from `limits`, not `AGENTOS_*`.
1061    let max_old_space_mb = python_max_old_space_mb(request);
1062    let runner_limits = JavascriptExecutionLimits {
1063        v8_heap_limit_mb: (max_old_space_mb > 0).then_some(max_old_space_mb as u32),
1064        sync_rpc_wait_timeout_ms: Some(PYTHON_SYNC_RPC_WAIT_TIMEOUT_MS),
1065        ..JavascriptExecutionLimits::default()
1066    };
1067
1068    javascript_engine
1069        .start_execution(StartJavascriptExecutionRequest {
1070            vm_id: request.vm_id.clone(),
1071            context_id: javascript_context_id.to_owned(),
1072            argv: vec![import_cache.python_runner_path().display().to_string()],
1073            env,
1074            cwd: request.cwd.clone(),
1075            limits: runner_limits,
1076            // Forward the guest-runtime identity so the runner's shim sets
1077            // process.* from typed config rather than env.
1078            guest_runtime: request.guest_runtime.clone(),
1079            wasm_module_bytes: None,
1080            inline_code: Some(inline_code),
1081        })
1082        .map_err(map_javascript_error)
1083}
1084
1085fn build_python_internal_env(
1086    import_cache: &NodeImportCache,
1087    context: &PythonContext,
1088    request: &StartPythonExecutionRequest,
1089    frozen_time_ms: u128,
1090    prewarm_only: bool,
1091) -> BTreeMap<String, String> {
1092    let mut internal_env = request
1093        .env
1094        .iter()
1095        .filter(|(key, _)| key.starts_with("AGENTOS_"))
1096        .map(|(key, value)| (key.clone(), value.clone()))
1097        .collect::<BTreeMap<_, _>>();
1098    let pyodide_dist_path = resolved_pyodide_dist_path(&context.pyodide_dist_path, &request.cwd);
1099
1100    add_python_guest_path_mapping(&mut internal_env, &pyodide_dist_path);
1101
1102    internal_env.insert(
1103        PYODIDE_INDEX_URL_ENV.to_string(),
1104        String::from(PYODIDE_GUEST_ROOT),
1105    );
1106    internal_env.insert(
1107        PYODIDE_PACKAGE_BASE_URL_ENV.to_string(),
1108        request
1109            .env
1110            .get(PYODIDE_PACKAGE_BASE_URL_ENV)
1111            .cloned()
1112            .unwrap_or_else(|| String::from(PYODIDE_GUEST_ROOT)),
1113    );
1114    internal_env.insert(
1115        PYODIDE_PACKAGE_CACHE_DIR_ENV.to_string(),
1116        String::from(PYODIDE_CACHE_GUEST_ROOT),
1117    );
1118    internal_env.insert(
1119        NODE_IMPORT_CACHE_ASSET_ROOT_ENV.to_string(),
1120        import_cache.asset_root().display().to_string(),
1121    );
1122    internal_env.insert(
1123        NODE_ALLOW_PROCESS_BINDINGS_ENV.to_string(),
1124        String::from("1"),
1125    );
1126    internal_env.insert(
1127        NODE_SYNC_RPC_DATA_BYTES_ENV.to_string(),
1128        PYTHON_SYNC_RPC_DATA_BYTES.to_string(),
1129    );
1130    internal_env.insert(
1131        NODE_DISABLE_COMPILE_CACHE_ENV.to_string(),
1132        String::from("1"),
1133    );
1134    // The runner's V8 heap cap and sync-RPC wait timeout are carried as typed
1135    // `JavascriptExecutionLimits` on the runner request (see the launch site),
1136    // not as `AGENTOS_V8_HEAP_LIMIT_MB` / `AGENTOS_NODE_SYNC_RPC_WAIT_TIMEOUT_MS`
1137    // env knobs, which the JS engine no longer reads.
1138    internal_env.insert(PYTHON_CODE_ENV.to_string(), request.code.clone());
1139    internal_env.insert(NODE_FROZEN_TIME_ENV.to_string(), frozen_time_ms.to_string());
1140    if prewarm_only {
1141        internal_env.insert(PYTHON_PREWARM_ONLY_ENV.to_string(), String::from("1"));
1142    } else {
1143        internal_env.insert(String::from("AGENTOS_KEEP_STDIN_OPEN"), String::from("1"));
1144        internal_env.remove(PYTHON_PREWARM_ONLY_ENV);
1145    }
1146    if let Some(file_path) = &request.file_path {
1147        internal_env.insert(PYTHON_FILE_ENV.to_string(), file_path.display().to_string());
1148    } else {
1149        internal_env.remove(PYTHON_FILE_ENV);
1150    }
1151
1152    internal_env
1153}
1154
1155fn add_python_guest_path_mapping(
1156    internal_env: &mut BTreeMap<String, String>,
1157    pyodide_dist_path: &Path,
1158) {
1159    let pyodide_cache_path = pyodide_cache_path(pyodide_dist_path);
1160    let mut mappings = internal_env
1161        .get(NODE_GUEST_PATH_MAPPINGS_ENV)
1162        .and_then(|value| serde_json::from_str::<Vec<PythonGuestPathMappingWire>>(value).ok())
1163        .unwrap_or_default();
1164
1165    mappings.retain(|mapping| {
1166        mapping.guest_path != PYODIDE_GUEST_ROOT && mapping.guest_path != PYODIDE_CACHE_GUEST_ROOT
1167    });
1168    mappings.push(PythonGuestPathMappingWire {
1169        guest_path: String::from(PYODIDE_GUEST_ROOT),
1170        host_path: pyodide_dist_path.display().to_string(),
1171    });
1172    mappings.push(PythonGuestPathMappingWire {
1173        guest_path: String::from(PYODIDE_CACHE_GUEST_ROOT),
1174        host_path: pyodide_cache_path.display().to_string(),
1175    });
1176
1177    let serialized = serde_json::to_string(&mappings).unwrap_or_else(|_| String::from("[]"));
1178    internal_env.insert(String::from(NODE_GUEST_PATH_MAPPINGS_ENV), serialized);
1179}
1180
1181fn pyodide_cache_path(pyodide_dist_path: &Path) -> PathBuf {
1182    let base = pyodide_dist_path
1183        .parent()
1184        .and_then(|parent| {
1185            if parent.file_name().is_some_and(|name| name == "assets") {
1186                parent.parent()
1187            } else {
1188                Some(parent)
1189            }
1190        })
1191        .unwrap_or(pyodide_dist_path);
1192
1193    base.join("pyodide-package-cache")
1194}
1195
1196fn build_python_runner_module_source(
1197    import_cache: &NodeImportCache,
1198    internal_env: &BTreeMap<String, String>,
1199    warmup_metrics: Option<&[u8]>,
1200) -> Result<String, PythonExecutionError> {
1201    let runner_source = fs::read_to_string(import_cache.python_runner_path())
1202        .map_err(PythonExecutionError::PrepareRuntime)?;
1203    let runner_source =
1204        format!("import * as __agentOSConstantsBinding from 'node:constants';\n{runner_source}");
1205    let bootstrap = build_python_runner_bootstrap(internal_env, warmup_metrics);
1206    Ok(insert_python_runner_bootstrap(&runner_source, &bootstrap))
1207}
1208
1209fn build_python_runner_bootstrap(
1210    internal_env: &BTreeMap<String, String>,
1211    warmup_metrics: Option<&[u8]>,
1212) -> String {
1213    let internal_env_json =
1214        serde_json::to_string(internal_env).unwrap_or_else(|_| String::from("{}"));
1215    let warmup_metrics_json = warmup_metrics.map(|bytes| {
1216        serde_json::to_string(&String::from_utf8_lossy(bytes).to_string())
1217            .unwrap_or_else(|_| String::from("\"\""))
1218    });
1219
1220    match warmup_metrics_json {
1221        Some(warmup_metrics_json) => format!(
1222            "globalThis.__agentOSPythonInternalEnv = {internal_env_json};\n\
1223if (typeof process !== 'undefined') {{\n  process.env = {{ ...(process.env || {{}}), ...globalThis.__agentOSPythonInternalEnv }};\n}}\n\
1224if (typeof process?.stderr?.write === 'function') {{\n  process.stderr.write({warmup_metrics_json});\n}}\n"
1225        ),
1226        None => format!(
1227            "globalThis.__agentOSPythonInternalEnv = {internal_env_json};\n\
1228if (typeof process !== 'undefined') {{\n  process.env = {{ ...(process.env || {{}}), ...globalThis.__agentOSPythonInternalEnv }};\n}}\n"
1229        ),
1230    }
1231}
1232
1233fn insert_python_runner_bootstrap(source: &str, bootstrap: &str) -> String {
1234    let mut insert_at = 0usize;
1235    let mut saw_import = false;
1236    for line in source.split_inclusive('\n') {
1237        let trimmed = line.trim_start();
1238        if trimmed.starts_with("import ") || (saw_import && trimmed.is_empty()) {
1239            insert_at += line.len();
1240            saw_import = saw_import || trimmed.starts_with("import ");
1241            continue;
1242        }
1243        break;
1244    }
1245
1246    format!(
1247        "{}{}{}",
1248        &source[..insert_at],
1249        bootstrap,
1250        &source[insert_at..]
1251    )
1252}
1253
1254fn parse_python_bridge_sync_rpc_request(
1255    request: &JavascriptSyncRpcRequest,
1256) -> Result<PythonVfsRpcRequest, PythonExecutionError> {
1257    if request.method != "_pythonRpc" {
1258        return Err(PythonExecutionError::RpcResponse(format!(
1259            "unexpected JavaScript sync RPC method for guest Python runtime: {}",
1260            request.method
1261        )));
1262    }
1263
1264    let payload = request.args.first().ok_or_else(|| {
1265        PythonExecutionError::RpcResponse(String::from(
1266            "guest Python bridge call did not include a request payload",
1267        ))
1268    })?;
1269
1270    let wire: PythonVfsBridgeRequestWire =
1271        serde_json::from_value(payload.clone()).map_err(|error| {
1272            PythonExecutionError::RpcResponse(format!(
1273                "invalid guest Python bridge request payload: {error}"
1274            ))
1275        })?;
1276
1277    let method = PythonVfsRpcMethod::from_wire(&wire.method).ok_or_else(|| {
1278        PythonExecutionError::RpcResponse(format!(
1279            "unsupported agentos python rpc method {} for {}",
1280            wire.method, request.id
1281        ))
1282    })?;
1283
1284    Ok(PythonVfsRpcRequest {
1285        id: request.id,
1286        method,
1287        path: wire.path,
1288        destination: wire.destination,
1289        target: wire.target,
1290        mode: wire.mode.map(|value| value as u32),
1291        uid: wire.uid.map(|value| value as u32),
1292        gid: wire.gid.map(|value| value as u32),
1293        atime_ms: wire.atime_ms.map(|value| value as u64),
1294        mtime_ms: wire.mtime_ms.map(|value| value as u64),
1295        content_base64: wire.content_base64,
1296        recursive: wire.recursive,
1297        url: wire.url,
1298        http_method: wire.http_method,
1299        headers: wire.headers,
1300        body_base64: wire.body_base64,
1301        hostname: wire.hostname,
1302        family: wire.family,
1303        port: wire.port,
1304        socket_id: wire.socket_id,
1305        command: wire.command,
1306        args: wire.args,
1307        cwd: wire.cwd,
1308        env: wire.env,
1309        shell: wire.shell,
1310        max_buffer: wire.max_buffer,
1311    })
1312}
1313
1314#[derive(Debug)]
1315struct PythonOutputBuffer {
1316    bytes: Vec<u8>,
1317    max_bytes: usize,
1318}
1319
1320impl PythonOutputBuffer {
1321    fn new(max_bytes: usize) -> Self {
1322        Self {
1323            bytes: Vec::new(),
1324            max_bytes,
1325        }
1326    }
1327
1328    fn extend(&mut self, chunk: &[u8]) {
1329        if self.bytes.len() >= self.max_bytes {
1330            return;
1331        }
1332
1333        let remaining = self.max_bytes - self.bytes.len();
1334        let take = remaining.min(chunk.len());
1335        self.bytes.extend_from_slice(&chunk[..take]);
1336    }
1337
1338    fn into_inner(self) -> Vec<u8> {
1339        self.bytes
1340    }
1341}
1342
1343fn python_output_buffer_max_bytes(request: &StartPythonExecutionRequest) -> usize {
1344    request
1345        .limits
1346        .output_buffer_max_bytes
1347        .unwrap_or(DEFAULT_PYTHON_OUTPUT_BUFFER_MAX_BYTES)
1348}
1349
1350fn python_execution_timeout(request: &StartPythonExecutionRequest) -> Option<Duration> {
1351    match request.limits.execution_timeout_ms {
1352        // `Some(0)` explicitly disables the timeout.
1353        Some(0) => None,
1354        Some(value) => Some(Duration::from_millis(value)),
1355        None => Some(Duration::from_millis(DEFAULT_PYTHON_EXECUTION_TIMEOUT_MS)),
1356    }
1357}
1358
1359fn python_max_old_space_mb(request: &StartPythonExecutionRequest) -> usize {
1360    request
1361        .limits
1362        .max_old_space_mb
1363        .filter(|value| *value > 0)
1364        .unwrap_or(DEFAULT_PYTHON_MAX_OLD_SPACE_MB)
1365}
1366
1367fn python_vfs_rpc_timeout(request: &StartPythonExecutionRequest) -> Duration {
1368    Duration::from_millis(
1369        request
1370            .limits
1371            .vfs_rpc_timeout_ms
1372            .filter(|value| *value > 0)
1373            .unwrap_or(DEFAULT_PYTHON_VFS_RPC_TIMEOUT_MS),
1374    )
1375}
1376
1377fn spawn_python_vfs_rpc_timeout(
1378    id: u64,
1379    timeout: Duration,
1380    pending: Arc<Mutex<Option<PendingVfsRpcState>>>,
1381    v8_session: crate::v8_host::V8SessionHandle,
1382) {
1383    thread::spawn(move || {
1384        thread::sleep(timeout);
1385        let should_timeout = match pending.lock() {
1386            Ok(mut guard) if *guard == Some(PendingVfsRpcState::Pending(id)) => {
1387                *guard = Some(PendingVfsRpcState::TimedOut(id));
1388                true
1389            }
1390            Ok(_) => false,
1391            Err(_) => false,
1392        };
1393
1394        if !should_timeout {
1395            return;
1396        }
1397
1398        let _ = v8_session.send_bridge_response(
1399            id,
1400            1,
1401            format!(
1402                "ERR_AGENTOS_PYTHON_VFS_RPC_TIMEOUT: guest Python VFS RPC request {id} timed out after {}ms",
1403                timeout.as_millis()
1404            )
1405            .into_bytes(),
1406        );
1407    });
1408}
1409
1410fn resolved_pyodide_dist_path(path: &Path, cwd: &Path) -> PathBuf {
1411    resolve_execution_path(path, cwd)
1412}
1413
1414fn prewarm_python_path(
1415    import_cache: &NodeImportCache,
1416    javascript_engine: &mut JavascriptExecutionEngine,
1417    javascript_context_id: &str,
1418    context: &PythonContext,
1419    request: &StartPythonExecutionRequest,
1420    frozen_time_ms: u128,
1421) -> Result<Option<Vec<u8>>, PythonExecutionError> {
1422    let debug_enabled = python_warmup_metrics_enabled(request);
1423    let marker_contents = warmup_marker_contents(import_cache, context, request);
1424    let marker_path = warmup_marker_path(
1425        import_cache.prewarm_marker_dir(),
1426        "python-runner-prewarm",
1427        PYTHON_WARMUP_MARKER_VERSION,
1428        &marker_contents,
1429    );
1430    let marker_exists = marker_path.exists();
1431
1432    let started = Instant::now();
1433    let mut prewarm_execution = start_python_javascript_execution(
1434        javascript_engine,
1435        import_cache,
1436        javascript_context_id,
1437        context,
1438        request,
1439        PythonJavascriptExecutionOptions {
1440            frozen_time_ms,
1441            prewarm_only: true,
1442            warmup_metrics: None,
1443        },
1444    )?;
1445    let mut stdout = Vec::new();
1446    let mut stderr = Vec::new();
1447    let mut sync_rpc_log = Vec::new();
1448    let result = loop {
1449        match prewarm_execution
1450            .poll_event_blocking(PYTHON_PREWARM_TIMEOUT)
1451            .map_err(map_javascript_error)?
1452        {
1453            Some(JavascriptExecutionEvent::Stdout(chunk)) => stdout.extend(chunk),
1454            Some(JavascriptExecutionEvent::Stderr(chunk)) => stderr.extend(chunk),
1455            Some(JavascriptExecutionEvent::Exited(exit_code)) => {
1456                break PythonExecutionResult {
1457                    execution_id: String::from("python-prewarm"),
1458                    exit_code,
1459                    stdout,
1460                    stderr,
1461                };
1462            }
1463            Some(JavascriptExecutionEvent::SignalState { .. }) => {}
1464            Some(JavascriptExecutionEvent::SyncRpcRequest(sync_request)) => {
1465                sync_rpc_log.push(format!(
1466                    "{} {} {:?}",
1467                    sync_request.id, sync_request.method, sync_request.args
1468                ));
1469                // The Python runner module imports node builtins and the pyodide
1470                // ESM entry; module-resolution sync RPCs are now serviced
1471                // host-directly here (the prewarm has no kernel/service loop),
1472                // using the execution's own translator (with pyodide path
1473                // mappings) so the runner module loader resolves correctly.
1474                if prewarm_execution
1475                    .try_service_standalone_module_sync_rpc(&sync_request)
1476                    .map_err(map_javascript_error)?
1477                {
1478                    sync_rpc_log.push(format!("responded {} (module)", sync_request.id));
1479                    continue;
1480                }
1481                let pyodide_dist_path =
1482                    resolved_pyodide_dist_path(&context.pyodide_dist_path, &request.cwd);
1483                if let Some(action) =
1484                    python_javascript_sync_rpc_action(&pyodide_dist_path, &sync_request)?
1485                {
1486                    respond_python_javascript_sync_rpc_action(
1487                        &mut prewarm_execution,
1488                        sync_request.id,
1489                        action,
1490                    )?;
1491                    sync_rpc_log.push(format!("responded {}", sync_request.id));
1492                    continue;
1493                }
1494                if let Some((code, message)) = python_javascript_sync_rpc_error(&sync_request) {
1495                    prewarm_execution
1496                        .respond_sync_rpc_error(sync_request.id, code, message)
1497                        .map_err(map_javascript_error)?;
1498                    sync_rpc_log.push(format!("errored {}", sync_request.id));
1499                    continue;
1500                }
1501                if sync_request.method == "_pythonRpc" {
1502                    let request = parse_python_bridge_sync_rpc_request(&sync_request)?;
1503                    return Err(PythonExecutionError::WarmupFailed {
1504                        exit_code: 1,
1505                        stderr: format!(
1506                            "unexpected Python prewarm VFS RPC request {} {} {:?}",
1507                            request.id, request.path, request.method
1508                        ),
1509                    });
1510                }
1511                return Err(PythonExecutionError::WarmupFailed {
1512                    exit_code: 1,
1513                    stderr: format!(
1514                        "unexpected Python prewarm JavaScript sync RPC request {} {} {:?}",
1515                        sync_request.id, sync_request.method, sync_request.args
1516                    ),
1517                });
1518            }
1519            None => {
1520                return Err(PythonExecutionError::WarmupFailed {
1521                    exit_code: 1,
1522                    stderr: format!(
1523                        "python prewarm timed out after {}s\nstdout:\n{}\nstderr:\n{}\nsync rpc:\n{}",
1524                        PYTHON_PREWARM_TIMEOUT.as_secs(),
1525                        String::from_utf8_lossy(&stdout),
1526                        String::from_utf8_lossy(&stderr),
1527                        sync_rpc_log.join("\n"),
1528                    ),
1529                });
1530            }
1531        }
1532    };
1533    let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
1534
1535    if result.exit_code != 0 {
1536        return Err(PythonExecutionError::WarmupFailed {
1537            exit_code: result.exit_code,
1538            stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
1539        });
1540    }
1541
1542    if marker_exists {
1543        return Ok(warmup_metrics_line(
1544            debug_enabled,
1545            false,
1546            "cached",
1547            0.0,
1548            import_cache,
1549            context,
1550            request,
1551        ));
1552    }
1553
1554    fs::write(&marker_path, marker_contents).map_err(PythonExecutionError::PrepareWarmPath)?;
1555    Ok(warmup_metrics_line(
1556        debug_enabled,
1557        true,
1558        "executed",
1559        duration_ms,
1560        import_cache,
1561        context,
1562        request,
1563    ))
1564}
1565
1566enum PythonJavascriptSyncRpcAction {
1567    Success(Value),
1568    Error { code: &'static str, message: String },
1569}
1570
1571fn python_javascript_sync_rpc_action(
1572    pyodide_dist_path: &Path,
1573    request: &JavascriptSyncRpcRequest,
1574) -> Result<Option<PythonJavascriptSyncRpcAction>, PythonExecutionError> {
1575    let Some(path) = request.args.first().and_then(Value::as_str) else {
1576        return Ok(None);
1577    };
1578    let path_kind = python_managed_path_kind(pyodide_dist_path, path);
1579    let Some(host_path) = path_kind.host_path() else {
1580        return Ok(None);
1581    };
1582
1583    Ok(Some(match request.method.as_str() {
1584        "fs.promises.readFile" | "fs.readFileSync" => {
1585            let bytes = match fs::read(&host_path) {
1586                Ok(bytes) => bytes,
1587                Err(error) => {
1588                    return python_sync_rpc_fs_action_error(path, "open", error).map(Some);
1589                }
1590            };
1591            let encoding = python_prewarm_sync_rpc_encoding(&request.args);
1592            match encoding.as_deref() {
1593                Some("utf8") | Some("utf-8") => PythonJavascriptSyncRpcAction::Success(
1594                    Value::String(String::from_utf8_lossy(&bytes).into_owned()),
1595                ),
1596                _ => PythonJavascriptSyncRpcAction::Success(json!({
1597                    "__agentOSType": "bytes",
1598                    "base64": v8_runtime::base64_encode_pub(&bytes),
1599                })),
1600            }
1601        }
1602        "fs.statSync" | "fs.promises.stat" => match fs::metadata(&host_path) {
1603            Ok(metadata) => {
1604                PythonJavascriptSyncRpcAction::Success(python_host_stat_value(&metadata))
1605            }
1606            Err(error) => return python_sync_rpc_fs_action_error(path, "stat", error).map(Some),
1607        },
1608        "fs.lstatSync" | "fs.promises.lstat" => match fs::symlink_metadata(&host_path) {
1609            Ok(metadata) => {
1610                PythonJavascriptSyncRpcAction::Success(python_host_stat_value(&metadata))
1611            }
1612            Err(error) => return python_sync_rpc_fs_action_error(path, "lstat", error).map(Some),
1613        },
1614        "fs.existsSync" => PythonJavascriptSyncRpcAction::Success(Value::Bool(host_path.exists())),
1615        "fs.accessSync" | "fs.promises.access" => match fs::metadata(&host_path) {
1616            Ok(_) => PythonJavascriptSyncRpcAction::Success(Value::Null),
1617            Err(error) => return python_sync_rpc_fs_action_error(path, "access", error).map(Some),
1618        },
1619        "fs.readdirSync" | "fs.promises.readdir" => match fs::read_dir(&host_path) {
1620            Ok(entries) => PythonJavascriptSyncRpcAction::Success(python_readdir_value(
1621                entries
1622                    .filter_map(|entry| entry.ok())
1623                    .filter_map(|entry| entry.file_name().into_string().ok())
1624                    .collect(),
1625            )),
1626            Err(error) => return python_sync_rpc_fs_action_error(path, "scandir", error).map(Some),
1627        },
1628        "fs.mkdirSync" | "fs.promises.mkdir" => {
1629            let recursive = python_sync_rpc_recursive_flag(&request.args);
1630            if recursive {
1631                fs::create_dir_all(&host_path).map_err(PythonExecutionError::PrepareRuntime)?;
1632            } else {
1633                match fs::create_dir(&host_path) {
1634                    Ok(()) => {}
1635                    Err(error) => {
1636                        return python_sync_rpc_fs_action_error(path, "mkdir", error).map(Some);
1637                    }
1638                }
1639            }
1640            PythonJavascriptSyncRpcAction::Success(Value::Null)
1641        }
1642        "fs.writeFileSync" | "fs.promises.writeFile" => {
1643            let contents = python_sync_rpc_bytes_arg(&request.args, 1)?;
1644            if let Some(parent) = host_path.parent() {
1645                fs::create_dir_all(parent).map_err(PythonExecutionError::PrepareRuntime)?;
1646            }
1647            fs::write(&host_path, contents).map_err(PythonExecutionError::PrepareRuntime)?;
1648            PythonJavascriptSyncRpcAction::Success(Value::Null)
1649        }
1650        "fs.realpathSync" | "fs.realpathSync.native" => match fs::canonicalize(&host_path) {
1651            Ok(canonical) => PythonJavascriptSyncRpcAction::Success(Value::String(
1652                path_kind.render_path(pyodide_dist_path, &canonical, path),
1653            )),
1654            Err(error) => {
1655                return python_sync_rpc_fs_action_error(path, "realpath", error).map(Some);
1656            }
1657        },
1658        _ => return Ok(None),
1659    }))
1660}
1661
1662fn python_sync_rpc_fs_action_error(
1663    path: &str,
1664    syscall: &str,
1665    error: std::io::Error,
1666) -> Result<PythonJavascriptSyncRpcAction, PythonExecutionError> {
1667    let action = match error.kind() {
1668        std::io::ErrorKind::NotFound => PythonJavascriptSyncRpcAction::Error {
1669            code: "ENOENT",
1670            message: format!("ENOENT: no such file or directory, {syscall} '{path}'"),
1671        },
1672        std::io::ErrorKind::AlreadyExists => PythonJavascriptSyncRpcAction::Error {
1673            code: "EEXIST",
1674            message: format!("EEXIST: file already exists, {syscall} '{path}'"),
1675        },
1676        std::io::ErrorKind::PermissionDenied => PythonJavascriptSyncRpcAction::Error {
1677            code: "EACCES",
1678            message: format!("EACCES: permission denied, {syscall} '{path}'"),
1679        },
1680        _ => {
1681            return Err(PythonExecutionError::PrepareRuntime(std::io::Error::new(
1682                error.kind(),
1683                error.to_string(),
1684            )));
1685        }
1686    };
1687    Ok(action)
1688}
1689
1690fn respond_python_javascript_sync_rpc_action(
1691    execution: &mut JavascriptExecution,
1692    id: u64,
1693    action: PythonJavascriptSyncRpcAction,
1694) -> Result<(), PythonExecutionError> {
1695    match action {
1696        PythonJavascriptSyncRpcAction::Success(value) => execution
1697            .respond_sync_rpc_success(id, value)
1698            .map_err(map_javascript_error),
1699        PythonJavascriptSyncRpcAction::Error { code, message } => execution
1700            .respond_sync_rpc_error(id, code, message)
1701            .map_err(map_javascript_error),
1702    }
1703}
1704
1705#[derive(Debug, Clone)]
1706enum PythonManagedPathKind {
1707    GuestPyodide,
1708    GuestCache,
1709    HostManaged,
1710    Unmanaged,
1711}
1712
1713impl PythonManagedPathKind {
1714    fn render_path(&self, pyodide_dist_path: &Path, canonical: &Path, original: &str) -> String {
1715        match self {
1716            Self::GuestPyodide | Self::GuestCache => {
1717                python_host_path_to_guest(pyodide_dist_path, canonical)
1718                    .unwrap_or_else(|| original.to_owned())
1719            }
1720            Self::HostManaged => canonical.display().to_string(),
1721            Self::Unmanaged => original.to_owned(),
1722        }
1723    }
1724}
1725
1726fn python_managed_path_kind(pyodide_dist_path: &Path, path: &str) -> PythonManagedResolvedPath {
1727    let cache_path = pyodide_cache_path(pyodide_dist_path);
1728
1729    if let Some(normalized) = strip_guest_managed_root(path, PYODIDE_GUEST_ROOT) {
1730        let root = canonicalize_existing_or_self(pyodide_dist_path);
1731        let relative = normalize_relative_guest_suffix(normalized);
1732        let host_path = if relative.as_os_str().is_empty() {
1733            root.clone()
1734        } else {
1735            root.join(relative)
1736        };
1737        if confined_managed_path(&host_path, &root) {
1738            return PythonManagedResolvedPath {
1739                kind: PythonManagedPathKind::GuestPyodide,
1740                host_path: Some(host_path),
1741            };
1742        }
1743        return PythonManagedResolvedPath {
1744            kind: PythonManagedPathKind::Unmanaged,
1745            host_path: None,
1746        };
1747    }
1748
1749    if let Some(normalized) = strip_guest_managed_root(path, PYODIDE_CACHE_GUEST_ROOT) {
1750        let root = canonicalize_existing_or_self(&cache_path);
1751        let relative = normalize_relative_guest_suffix(normalized);
1752        let host_path = if relative.as_os_str().is_empty() {
1753            root.clone()
1754        } else {
1755            root.join(relative)
1756        };
1757        if confined_managed_path(&host_path, &root) {
1758            return PythonManagedResolvedPath {
1759                kind: PythonManagedPathKind::GuestCache,
1760                host_path: Some(host_path),
1761            };
1762        }
1763        return PythonManagedResolvedPath {
1764            kind: PythonManagedPathKind::Unmanaged,
1765            host_path: None,
1766        };
1767    }
1768
1769    let candidate = PathBuf::from(path);
1770    let pyodide_root = canonicalize_existing_or_self(pyodide_dist_path);
1771    let cache_root = canonicalize_existing_or_self(&cache_path);
1772    if candidate.is_absolute()
1773        && !path_has_parent_or_prefix_component(&candidate)
1774        && confined_managed_path(&candidate, &pyodide_root)
1775    {
1776        return PythonManagedResolvedPath {
1777            kind: PythonManagedPathKind::HostManaged,
1778            host_path: Some(candidate),
1779        };
1780    }
1781    if candidate.is_absolute()
1782        && !path_has_parent_or_prefix_component(&candidate)
1783        && confined_managed_path(&candidate, &cache_root)
1784    {
1785        return PythonManagedResolvedPath {
1786            kind: PythonManagedPathKind::HostManaged,
1787            host_path: Some(candidate),
1788        };
1789    }
1790
1791    PythonManagedResolvedPath {
1792        kind: PythonManagedPathKind::Unmanaged,
1793        host_path: None,
1794    }
1795}
1796
1797#[derive(Debug, Clone)]
1798struct PythonManagedResolvedPath {
1799    kind: PythonManagedPathKind,
1800    host_path: Option<PathBuf>,
1801}
1802
1803impl PythonManagedResolvedPath {
1804    fn host_path(&self) -> Option<PathBuf> {
1805        self.host_path.clone()
1806    }
1807
1808    fn render_path(&self, pyodide_dist_path: &Path, canonical: &Path, original: &str) -> String {
1809        self.kind
1810            .render_path(pyodide_dist_path, canonical, original)
1811    }
1812}
1813
1814fn strip_guest_managed_root<'a>(path: &'a str, root: &str) -> Option<&'a str> {
1815    if path == root {
1816        return Some("");
1817    }
1818    path.strip_prefix(root)?.strip_prefix('/')
1819}
1820
1821fn normalize_relative_guest_suffix(suffix: &str) -> PathBuf {
1822    let mut normalized = PathBuf::new();
1823    for segment in suffix.trim_start_matches('/').split('/') {
1824        if segment.is_empty() || segment == "." {
1825            continue;
1826        }
1827        if segment == ".." {
1828            normalized.pop();
1829        } else {
1830            normalized.push(segment);
1831        }
1832    }
1833    normalized
1834}
1835
1836fn path_has_parent_or_prefix_component(path: &Path) -> bool {
1837    path.components()
1838        .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
1839}
1840
1841fn canonicalize_existing_or_self(path: &Path) -> PathBuf {
1842    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1843}
1844
1845fn confined_managed_path(path: &Path, root: &Path) -> bool {
1846    let canonical_root = canonicalize_existing_or_self(root);
1847    let Some(canonical_path) = canonicalize_managed_candidate(path) else {
1848        return false;
1849    };
1850
1851    canonical_path == canonical_root || canonical_path.starts_with(canonical_root)
1852}
1853
1854fn canonicalize_managed_candidate(path: &Path) -> Option<PathBuf> {
1855    let mut missing_components = Vec::new();
1856    let mut current = path;
1857    loop {
1858        match fs::canonicalize(current) {
1859            Ok(mut canonical) => {
1860                for component in missing_components.iter().rev() {
1861                    canonical.push(component);
1862                }
1863                return Some(canonical);
1864            }
1865            Err(_) => {
1866                let file_name = current.file_name()?.to_owned();
1867                if Path::new(&file_name)
1868                    .components()
1869                    .any(|component| !matches!(component, Component::Normal(_)))
1870                {
1871                    return None;
1872                }
1873                missing_components.push(file_name);
1874                current = current.parent()?;
1875            }
1876        }
1877    }
1878}
1879
1880fn python_host_path_to_guest(pyodide_dist_path: &Path, host_path: &Path) -> Option<String> {
1881    if let Ok(relative) = host_path.strip_prefix(pyodide_dist_path) {
1882        let suffix = relative.to_string_lossy().replace('\\', "/");
1883        return Some(if suffix.is_empty() {
1884            String::from(PYODIDE_GUEST_ROOT)
1885        } else {
1886            format!("{PYODIDE_GUEST_ROOT}/{suffix}")
1887        });
1888    }
1889
1890    let cache_path = pyodide_cache_path(pyodide_dist_path);
1891    let relative = host_path.strip_prefix(cache_path).ok()?;
1892    let suffix = relative.to_string_lossy().replace('\\', "/");
1893    Some(if suffix.is_empty() {
1894        String::from(PYODIDE_CACHE_GUEST_ROOT)
1895    } else {
1896        format!("{PYODIDE_CACHE_GUEST_ROOT}/{suffix}")
1897    })
1898}
1899
1900fn python_host_stat_value(metadata: &fs::Metadata) -> Value {
1901    json!({
1902        "mode": metadata.mode(),
1903        "size": metadata.size(),
1904        "blocks": metadata.blocks(),
1905        "dev": metadata.dev(),
1906        "rdev": metadata.rdev(),
1907        "isDirectory": metadata.is_dir(),
1908        "isSymbolicLink": metadata.file_type().is_symlink(),
1909        "atimeMs": metadata.atime() * 1000 + (metadata.atime_nsec() / 1_000_000),
1910        "mtimeMs": metadata.mtime() * 1000 + (metadata.mtime_nsec() / 1_000_000),
1911        "ctimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
1912        "birthtimeMs": metadata.ctime() * 1000 + (metadata.ctime_nsec() / 1_000_000),
1913        "ino": metadata.ino(),
1914        "nlink": metadata.nlink(),
1915        "uid": metadata.uid(),
1916        "gid": metadata.gid(),
1917    })
1918}
1919
1920fn python_readdir_value(entries: Vec<String>) -> Value {
1921    json!(entries
1922        .into_iter()
1923        .filter(|entry| entry != "." && entry != "..")
1924        .collect::<Vec<_>>())
1925}
1926
1927fn python_sync_rpc_recursive_flag(args: &[Value]) -> bool {
1928    args.get(1)
1929        .and_then(|value| {
1930            value
1931                .as_bool()
1932                .or_else(|| value.get("recursive").and_then(Value::as_bool))
1933        })
1934        .unwrap_or(false)
1935}
1936
1937fn python_sync_rpc_bytes_arg(
1938    args: &[Value],
1939    index: usize,
1940) -> Result<Vec<u8>, PythonExecutionError> {
1941    let Some(value) = args.get(index) else {
1942        return Err(PythonExecutionError::RpcResponse(format!(
1943            "sync RPC argument {index} is required"
1944        )));
1945    };
1946
1947    if let Some(text) = value.as_str() {
1948        return Ok(text.as_bytes().to_vec());
1949    }
1950
1951    let Some(base64_value) = value
1952        .get("__agentOSType")
1953        .and_then(Value::as_str)
1954        .filter(|kind| *kind == "bytes")
1955        .and_then(|_| value.get("base64"))
1956        .and_then(Value::as_str)
1957    else {
1958        return Err(PythonExecutionError::RpcResponse(format!(
1959            "sync RPC argument {index} must be a string or encoded bytes payload"
1960        )));
1961    };
1962
1963    base64::engine::general_purpose::STANDARD
1964        .decode(base64_value)
1965        .map_err(|error| {
1966            PythonExecutionError::RpcResponse(format!(
1967                "sync RPC argument {index} contains invalid base64: {error}"
1968            ))
1969        })
1970}
1971
1972fn python_prewarm_sync_rpc_encoding(args: &[Value]) -> Option<String> {
1973    args.get(1).and_then(|value| {
1974        value.as_str().map(str::to_owned).or_else(|| {
1975            value
1976                .get("encoding")
1977                .and_then(Value::as_str)
1978                .map(str::to_owned)
1979        })
1980    })
1981}
1982
1983fn python_javascript_sync_rpc_error(
1984    request: &JavascriptSyncRpcRequest,
1985) -> Option<(&'static str, String)> {
1986    if matches!(
1987        request.method.as_str(),
1988        "net.connect"
1989            | "net.createConnection"
1990            | "dns.lookup"
1991            | "dns.resolve"
1992            | "dns.resolve4"
1993            | "dns.resolve6"
1994            | "dns.reverse"
1995            | "dgram.send"
1996            | "http.request"
1997            | "https.request"
1998            | "tls.connect"
1999    ) {
2000        return Some((
2001            "ERR_ACCESS_DENIED",
2002            String::from(
2003                "network access is not available during standalone guest Python execution",
2004            ),
2005        ));
2006    }
2007
2008    None
2009}
2010
2011fn warmup_marker_contents(
2012    import_cache: &NodeImportCache,
2013    context: &PythonContext,
2014    request: &StartPythonExecutionRequest,
2015) -> String {
2016    let pyodide_dist_path = resolved_pyodide_dist_path(&context.pyodide_dist_path, &request.cwd);
2017    let compile_cache_dir = import_cache.shared_compile_cache_dir();
2018
2019    [
2020        env!("CARGO_PKG_NAME").to_string(),
2021        env!("CARGO_PKG_VERSION").to_string(),
2022        PYTHON_WARMUP_MARKER_VERSION.to_string(),
2023        String::from("secure-exec-v8"),
2024        python_max_old_space_mb(request).to_string(),
2025        compile_cache_dir.display().to_string(),
2026        pyodide_dist_path.display().to_string(),
2027        file_fingerprint(&pyodide_dist_path.join("pyodide.mjs")),
2028        file_fingerprint(&pyodide_dist_path.join("pyodide-lock.json")),
2029        file_fingerprint(&pyodide_dist_path.join("pyodide.asm.js")),
2030        file_fingerprint(&pyodide_dist_path.join("pyodide.asm.wasm")),
2031        file_fingerprint(&pyodide_dist_path.join("python_stdlib.zip")),
2032    ]
2033    .join("\n")
2034}
2035
2036fn python_warmup_metrics_enabled(request: &StartPythonExecutionRequest) -> bool {
2037    env_flag_enabled(&request.env, PYTHON_WARMUP_DEBUG_ENV)
2038}
2039
2040fn warmup_metrics_line(
2041    debug_enabled: bool,
2042    executed: bool,
2043    reason: &str,
2044    duration_ms: f64,
2045    import_cache: &NodeImportCache,
2046    context: &PythonContext,
2047    request: &StartPythonExecutionRequest,
2048) -> Option<Vec<u8>> {
2049    if !debug_enabled {
2050        return None;
2051    }
2052
2053    let compile_cache_dir = import_cache.shared_compile_cache_dir();
2054    let pyodide_dist_path = resolved_pyodide_dist_path(&context.pyodide_dist_path, &request.cwd);
2055
2056    Some(
2057        format!(
2058            "{PYTHON_WARMUP_METRICS_PREFIX}{{\"phase\":\"prewarm\",\"executed\":{},\"reason\":{},\"durationMs\":{duration_ms:.3},\"heapLimitMb\":{},\"compileCacheDir\":{},\"pyodideDistPath\":{}}}\n",
2059            if executed { "true" } else { "false" },
2060            encode_json_string(reason),
2061            python_max_old_space_mb(request),
2062            encode_json_string(&compile_cache_dir.display().to_string()),
2063            encode_json_string(&pyodide_dist_path.display().to_string()),
2064        )
2065        .into_bytes(),
2066    )
2067}
2068
2069#[cfg(test)]
2070mod tests {
2071    use super::{
2072        python_managed_path_kind, PythonManagedPathKind, PYODIDE_CACHE_GUEST_ROOT,
2073        PYODIDE_GUEST_ROOT,
2074    };
2075    use std::fs;
2076    #[cfg(unix)]
2077    use std::os::unix::fs::symlink;
2078    use tempfile::tempdir;
2079
2080    #[test]
2081    fn python_managed_guest_paths_normalize_dot_dot_inside_root() {
2082        let temp = tempdir().expect("create temp dir");
2083        let pyodide = temp.path().join("pyodide");
2084        fs::create_dir_all(pyodide.join("lib")).expect("create pyodide lib");
2085
2086        let resolved = python_managed_path_kind(
2087            &pyodide,
2088            &format!("{PYODIDE_GUEST_ROOT}/lib/../pyodide.mjs"),
2089        );
2090
2091        assert!(matches!(resolved.kind, PythonManagedPathKind::GuestPyodide));
2092        assert_eq!(
2093            resolved.host_path().expect("host path"),
2094            pyodide.join("pyodide.mjs")
2095        );
2096    }
2097
2098    #[test]
2099    fn python_managed_guest_paths_clamp_dot_dot_escape_to_root() {
2100        let temp = tempdir().expect("create temp dir");
2101        let pyodide = temp.path().join("pyodide");
2102        fs::create_dir_all(&pyodide).expect("create pyodide root");
2103
2104        let resolved =
2105            python_managed_path_kind(&pyodide, &format!("{PYODIDE_GUEST_ROOT}/../../outside.txt"));
2106
2107        assert!(matches!(resolved.kind, PythonManagedPathKind::GuestPyodide));
2108        assert_eq!(
2109            resolved.host_path().expect("host path"),
2110            pyodide.join("outside.txt")
2111        );
2112    }
2113
2114    #[cfg(unix)]
2115    #[test]
2116    fn python_managed_guest_paths_reject_symlink_escape() {
2117        let temp = tempdir().expect("create temp dir");
2118        let pyodide = temp.path().join("pyodide");
2119        let outside = temp.path().join("outside");
2120        fs::create_dir_all(&pyodide).expect("create pyodide root");
2121        fs::create_dir_all(&outside).expect("create outside dir");
2122        symlink(&outside, pyodide.join("escape")).expect("create escape symlink");
2123
2124        let resolved =
2125            python_managed_path_kind(&pyodide, &format!("{PYODIDE_GUEST_ROOT}/escape/file.txt"));
2126
2127        assert!(matches!(resolved.kind, PythonManagedPathKind::Unmanaged));
2128        assert!(resolved.host_path().is_none());
2129    }
2130
2131    #[cfg(unix)]
2132    #[test]
2133    fn python_managed_guest_paths_reject_symlink_escape_to_missing_descendant() {
2134        let temp = tempdir().expect("create temp dir");
2135        let pyodide = temp.path().join("pyodide");
2136        let outside = temp.path().join("outside");
2137        fs::create_dir_all(&pyodide).expect("create pyodide root");
2138        fs::create_dir_all(&outside).expect("create outside dir");
2139        symlink(&outside, pyodide.join("escape")).expect("create escape symlink");
2140
2141        let resolved = python_managed_path_kind(
2142            &pyodide,
2143            &format!("{PYODIDE_GUEST_ROOT}/escape/missing/file.txt"),
2144        );
2145
2146        assert!(matches!(resolved.kind, PythonManagedPathKind::Unmanaged));
2147        assert!(resolved.host_path().is_none());
2148    }
2149
2150    #[test]
2151    fn python_managed_host_paths_accept_canonical_root_descendants() {
2152        let temp = tempdir().expect("create temp dir");
2153        let pyodide = temp.path().join("pyodide");
2154        fs::create_dir_all(pyodide.join("pkg")).expect("create pyodide package dir");
2155        let candidate = pyodide.join("pkg/module.py");
2156
2157        let resolved = python_managed_path_kind(&pyodide, &candidate.display().to_string());
2158
2159        assert!(matches!(resolved.kind, PythonManagedPathKind::HostManaged));
2160        assert_eq!(resolved.host_path().expect("host path"), candidate);
2161    }
2162
2163    #[test]
2164    fn python_managed_host_paths_reject_unresolved_dot_dot_escape() {
2165        let temp = tempdir().expect("create temp dir");
2166        let pyodide = temp.path().join("pyodide");
2167        fs::create_dir_all(&pyodide).expect("create pyodide root");
2168        let candidate = pyodide.join("missing/../../outside.txt");
2169
2170        let resolved = python_managed_path_kind(&pyodide, &candidate.display().to_string());
2171
2172        assert!(matches!(resolved.kind, PythonManagedPathKind::Unmanaged));
2173        assert!(resolved.host_path().is_none());
2174    }
2175
2176    #[test]
2177    fn python_managed_cache_guest_paths_resolve_inside_cache_root() {
2178        let temp = tempdir().expect("create temp dir");
2179        let pyodide = temp.path().join("pyodide");
2180        fs::create_dir_all(&pyodide).expect("create pyodide root");
2181
2182        let resolved = python_managed_path_kind(
2183            &pyodide,
2184            &format!("{PYODIDE_CACHE_GUEST_ROOT}/wheels/pkg.whl"),
2185        );
2186        let host_path = resolved.host_path().expect("host path");
2187
2188        assert!(matches!(resolved.kind, PythonManagedPathKind::GuestCache));
2189        assert!(host_path.ends_with("pyodide-package-cache/wheels/pkg.whl"));
2190    }
2191}