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 pub destination: Option<String>,
109 pub target: Option<String>,
111 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 pub port: Option<u16>,
127 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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct PythonExecutionLimits {
276 pub output_buffer_max_bytes: Option<usize>,
278 pub execution_timeout_ms: Option<u64>,
281 pub max_old_space_mb: Option<usize>,
284 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 pub limits: PythonExecutionLimits,
298 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 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
420fn 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 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 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 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 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 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) => 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 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}