Skip to main content

secure_exec_vm_config/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6/// Canonical Rust-side VM config. Unknown fields must stay rejected here and in
7/// the TS preflight schema at
8/// `packages/core/src/node-runtime-options-schema.ts`; update both when a
9/// public `NodeRuntime.create(...)` option changes the generated VM config.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12#[ts(export, export_to = "../../../packages/core/src/generated/")]
13#[derive(Default)]
14pub struct CreateVmConfig {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    #[ts(optional)]
17    pub cwd: Option<String>,
18    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
19    #[ts(type = "Record<string, string>")]
20    pub env: BTreeMap<String, String>,
21    #[serde(default, rename = "rootFilesystem")]
22    pub root_filesystem: RootFilesystemConfig,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    #[ts(optional)]
25    pub permissions: Option<PermissionsPolicy>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    #[ts(optional)]
28    pub limits: Option<VmLimitsConfig>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    #[ts(optional)]
31    pub dns: Option<VmDnsConfig>,
32    #[serde(
33        default,
34        rename = "nativeRoot",
35        skip_serializing_if = "Option::is_none"
36    )]
37    #[ts(optional)]
38    pub native_root: Option<NativeRootFilesystemConfig>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    #[ts(optional)]
41    pub listen: Option<VmListenPolicyConfig>,
42    #[serde(
43        default,
44        rename = "loopbackExemptPorts",
45        skip_serializing_if = "Vec::is_empty"
46    )]
47    pub loopback_exempt_ports: Vec<u16>,
48    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
49    #[ts(optional)]
50    pub js_runtime: Option<JsRuntimeConfig>,
51}
52
53impl CreateVmConfig {
54    pub fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
55        if let Some(cwd) = self.cwd.as_deref() {
56            validate_guest_path("cwd", cwd)?;
57        }
58        self.root_filesystem.validate()?;
59        if let Some(native_root) = &self.native_root {
60            native_root.validate()?;
61        }
62        if self.native_root.is_some() && !self.root_filesystem.bootstrap_entries.is_empty() {
63            return Err(VmConfigError::new(
64                "nativeRoot does not support rootFilesystem.bootstrapEntries",
65            ));
66        }
67        if let Some(dns) = &self.dns {
68            dns.validate()?;
69        }
70        if let Some(listen) = &self.listen {
71            listen.validate()?;
72        }
73        if let Some(limits) = &self.limits {
74            limits.validate(max_frame_bytes)?;
75        }
76        if let Some(js_runtime) = &self.js_runtime {
77            js_runtime.validate()?;
78        }
79        Ok(())
80    }
81}
82
83/// Guest JavaScript host-environment configuration.
84///
85/// Selects which globals/builtins/module-resolution surface guest JS sees,
86/// modeled on esbuild's `platform`. Omitting this preserves full Node.js
87/// emulation (`platform = node`).
88#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90#[ts(export, export_to = "../../../packages/core/src/generated/")]
91pub struct JsRuntimeConfig {
92    /// Which host environment to emulate for guest JS. Default `node`.
93    #[serde(default)]
94    pub platform: JsRuntimePlatform,
95    /// How bare import specifiers resolve. Independent of `platform`.
96    /// Default `node`.
97    #[serde(default, rename = "moduleResolution")]
98    pub module_resolution: JsModuleResolution,
99    /// Node builtin-module allow-list. Only valid when `platform = node`.
100    /// `None` => engine default allow-list. `Some([])` => deny all builtins.
101    /// `Some([..])` => exactly those.
102    #[serde(
103        default,
104        rename = "allowedBuiltins",
105        skip_serializing_if = "Option::is_none"
106    )]
107    #[ts(optional)]
108    pub allowed_builtins: Option<Vec<String>>,
109    /// Opt in to a high-resolution monotonic guest clock. Default false keeps
110    /// the security-oriented 1ms timer resolution.
111    #[serde(
112        default,
113        rename = "highResolutionTime",
114        skip_serializing_if = "Option::is_none"
115    )]
116    #[ts(optional)]
117    pub high_resolution_time: Option<bool>,
118    /// Optional userland JS (an esbuild IIFE, e.g. a bundled agent SDK) to
119    /// evaluate into the per-sidecar V8 startup snapshot alongside the bridge, so
120    /// it is loaded once per sidecar and reused across sessions instead of
121    /// re-imported on every execution. The snapshot is cached process-wide keyed
122    /// by sha256(bridge + this code). Trusted client config; `None` keeps the
123    /// bridge-only snapshot. Must be snapshot-safe (no native/External handles,
124    /// fds, timers, or non-deterministic reads at module-init).
125    #[serde(
126        default,
127        rename = "snapshotUserlandCode",
128        skip_serializing_if = "Option::is_none"
129    )]
130    #[ts(optional)]
131    pub snapshot_userland_code: Option<String>,
132}
133
134impl JsRuntimeConfig {
135    fn validate(&self) -> Result<(), VmConfigError> {
136        if let Some(allowed) = &self.allowed_builtins {
137            if self.platform != JsRuntimePlatform::Node {
138                return Err(VmConfigError::new(
139                    "jsRuntime.allowedBuiltins is only valid when jsRuntime.platform is \"node\"",
140                ));
141            }
142            for name in allowed {
143                if !is_known_node_builtin(name) {
144                    return Err(VmConfigError::new(format!(
145                        "jsRuntime.allowedBuiltins contains unknown builtin {name:?}"
146                    )));
147                }
148            }
149        }
150        Ok(())
151    }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
155#[serde(rename_all = "lowercase")]
156#[ts(export, export_to = "../../../packages/core/src/generated/")]
157#[derive(Default)]
158pub enum JsRuntimePlatform {
159    /// Full Node.js host surface (process/Buffer/require, `node:*`, npm
160    /// resolution, virtual Node identity). Default.
161    #[default]
162    Node,
163    /// Web-platform globals (fetch/URL/WebCrypto/...), no Node surface.
164    Browser,
165    /// Universal primitives only (console, timers, queueMicrotask) — no web
166    /// platform, no Node surface.
167    Neutral,
168    /// Language-only: ECMAScript spec globals + WebAssembly. Nothing host-provided.
169    Bare,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
173#[serde(rename_all = "lowercase")]
174#[ts(export, export_to = "../../../packages/core/src/generated/")]
175#[derive(Default)]
176pub enum JsModuleResolution {
177    /// node_modules ancestor-walk + exports/imports/conditions + realpath. Default.
178    #[default]
179    Node,
180    /// Relative/absolute ESM from the VFS only; bare specifiers do not resolve.
181    Relative,
182    /// No resolution: any import/require (even relative) fails.
183    None,
184}
185
186/// Canonical set of recognized Node builtin module names (without the `node:`
187/// prefix), kept in sync with `normalize_builtin_specifier` in
188/// `crates/execution/src/javascript.rs`. Used to validate
189/// `jsRuntime.allowedBuiltins` entries.
190const KNOWN_NODE_BUILTINS: &[&str] = &[
191    "assert",
192    "async_hooks",
193    "buffer",
194    "child_process",
195    "cluster",
196    "console",
197    "constants",
198    "crypto",
199    "dgram",
200    "diagnostics_channel",
201    "dns",
202    "dns/promises",
203    "domain",
204    "events",
205    "fs",
206    "fs/promises",
207    "http",
208    "http2",
209    "https",
210    "inspector",
211    "module",
212    "net",
213    "os",
214    "path",
215    "path/posix",
216    "path/win32",
217    "perf_hooks",
218    "process",
219    "punycode",
220    "querystring",
221    "readline",
222    "repl",
223    "sqlite",
224    "stream",
225    "stream/consumers",
226    "stream/promises",
227    "stream/web",
228    "string_decoder",
229    "sys",
230    "timers",
231    "timers/promises",
232    "tls",
233    "trace_events",
234    "tty",
235    "url",
236    "util",
237    "util/types",
238    "v8",
239    "vm",
240    "wasi",
241    "worker_threads",
242    "zlib",
243];
244
245fn is_known_node_builtin(name: &str) -> bool {
246    let bare = name.strip_prefix("node:").unwrap_or(name);
247    KNOWN_NODE_BUILTINS.contains(&bare)
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
251#[serde(rename_all = "camelCase", deny_unknown_fields)]
252#[ts(export, export_to = "../../../packages/core/src/generated/")]
253pub struct RootFilesystemConfig {
254    #[serde(default)]
255    pub mode: RootFilesystemMode,
256    #[serde(default, rename = "disableDefaultBaseLayer")]
257    pub disable_default_base_layer: bool,
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub lowers: Vec<RootFilesystemLowerDescriptor>,
260    #[serde(
261        default,
262        rename = "bootstrapEntries",
263        skip_serializing_if = "Vec::is_empty"
264    )]
265    pub bootstrap_entries: Vec<RootFilesystemEntry>,
266}
267
268impl Default for RootFilesystemConfig {
269    fn default() -> Self {
270        Self {
271            mode: RootFilesystemMode::Ephemeral,
272            disable_default_base_layer: false,
273            lowers: Vec::new(),
274            bootstrap_entries: Vec::new(),
275        }
276    }
277}
278
279impl RootFilesystemConfig {
280    fn validate(&self) -> Result<(), VmConfigError> {
281        for lower in &self.lowers {
282            if let RootFilesystemLowerDescriptor::Snapshot { entries } = lower {
283                for entry in entries {
284                    entry.validate()?;
285                }
286            }
287        }
288        for entry in &self.bootstrap_entries {
289            entry.validate()?;
290        }
291        Ok(())
292    }
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
296#[serde(rename_all = "kebab-case")]
297#[ts(export, export_to = "../../../packages/core/src/generated/")]
298#[derive(Default)]
299pub enum RootFilesystemMode {
300    #[default]
301    Ephemeral,
302    ReadOnly,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
306#[serde(tag = "kind", rename_all = "camelCase")]
307#[ts(export, export_to = "../../../packages/core/src/generated/")]
308pub enum RootFilesystemLowerDescriptor {
309    Snapshot {
310        #[serde(default)]
311        entries: Vec<RootFilesystemEntry>,
312    },
313    BundledBaseFilesystem,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318#[ts(export, export_to = "../../../packages/core/src/generated/")]
319pub struct RootFilesystemEntry {
320    pub path: String,
321    pub kind: RootFilesystemEntryKind,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    #[ts(optional)]
324    pub mode: Option<u32>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    #[ts(optional)]
327    pub uid: Option<u32>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    #[ts(optional)]
330    pub gid: Option<u32>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    #[ts(optional)]
333    pub content: Option<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    #[ts(optional)]
336    pub encoding: Option<RootFilesystemEntryEncoding>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    #[ts(optional)]
339    pub target: Option<String>,
340    #[serde(default)]
341    pub executable: bool,
342}
343
344impl RootFilesystemEntry {
345    fn validate(&self) -> Result<(), VmConfigError> {
346        validate_guest_path("root filesystem entry path", &self.path)?;
347        match self.kind {
348            RootFilesystemEntryKind::File => {
349                if self.target.is_some() {
350                    return Err(VmConfigError::new(format!(
351                        "file entry {} must not include target",
352                        self.path
353                    )));
354                }
355            }
356            RootFilesystemEntryKind::Directory => {
357                if self.content.is_some() || self.encoding.is_some() || self.target.is_some() {
358                    return Err(VmConfigError::new(format!(
359                        "directory entry {} must not include content, encoding, or target",
360                        self.path
361                    )));
362                }
363            }
364            RootFilesystemEntryKind::Symlink => {
365                if self.target.as_deref().unwrap_or("").is_empty() {
366                    return Err(VmConfigError::new(format!(
367                        "symlink entry {} requires target",
368                        self.path
369                    )));
370                }
371                if self.content.is_some() || self.encoding.is_some() {
372                    return Err(VmConfigError::new(format!(
373                        "symlink entry {} must not include content or encoding",
374                        self.path
375                    )));
376                }
377            }
378        }
379        Ok(())
380    }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
384#[serde(rename_all = "lowercase")]
385#[ts(export, export_to = "../../../packages/core/src/generated/")]
386pub enum RootFilesystemEntryKind {
387    File,
388    Directory,
389    Symlink,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
393#[serde(rename_all = "lowercase")]
394#[ts(export, export_to = "../../../packages/core/src/generated/")]
395pub enum RootFilesystemEntryEncoding {
396    Utf8,
397    Base64,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
401#[serde(rename_all = "camelCase", deny_unknown_fields)]
402#[ts(export, export_to = "../../../packages/core/src/generated/")]
403pub struct NativeRootFilesystemConfig {
404    pub plugin: MountPluginDescriptor,
405    #[serde(default, rename = "readOnly")]
406    pub read_only: bool,
407}
408
409impl NativeRootFilesystemConfig {
410    fn validate(&self) -> Result<(), VmConfigError> {
411        if self.plugin.id.trim().is_empty() {
412            return Err(VmConfigError::new("nativeRoot.plugin.id is required"));
413        }
414        Ok(())
415    }
416}
417
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
419#[serde(rename_all = "camelCase", deny_unknown_fields)]
420#[ts(export, export_to = "../../../packages/core/src/generated/")]
421pub struct MountPluginDescriptor {
422    pub id: String,
423    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
424    #[ts(type = "import(\"../descriptors.js\").MountConfigJsonValue")]
425    pub config: serde_json::Value,
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
429#[serde(rename_all = "lowercase")]
430#[ts(export, export_to = "../../../packages/core/src/generated/")]
431pub enum PermissionMode {
432    Allow,
433    Ask,
434    Deny,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
438#[serde(untagged)]
439#[ts(export, export_to = "../../../packages/core/src/generated/")]
440pub enum FsPermissionScope {
441    Mode(PermissionMode),
442    Rules(FsPermissionRuleSet),
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
446#[serde(untagged)]
447#[ts(export, export_to = "../../../packages/core/src/generated/")]
448pub enum PatternPermissionScope {
449    Mode(PermissionMode),
450    Rules(PatternPermissionRuleSet),
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
454#[serde(rename_all = "camelCase", deny_unknown_fields)]
455#[ts(export, export_to = "../../../packages/core/src/generated/")]
456pub struct FsPermissionRuleSet {
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    #[ts(optional)]
459    pub default: Option<PermissionMode>,
460    #[serde(default)]
461    pub rules: Vec<FsPermissionRule>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
465#[serde(rename_all = "camelCase", deny_unknown_fields)]
466#[ts(export, export_to = "../../../packages/core/src/generated/")]
467pub struct PatternPermissionRuleSet {
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    #[ts(optional)]
470    pub default: Option<PermissionMode>,
471    #[serde(default)]
472    pub rules: Vec<PatternPermissionRule>,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
476#[serde(rename_all = "camelCase", deny_unknown_fields)]
477#[ts(export, export_to = "../../../packages/core/src/generated/")]
478pub struct FsPermissionRule {
479    pub mode: PermissionMode,
480    #[serde(default, skip_serializing_if = "Vec::is_empty")]
481    pub operations: Vec<String>,
482    #[serde(default, skip_serializing_if = "Vec::is_empty")]
483    pub paths: Vec<String>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
487#[serde(rename_all = "camelCase", deny_unknown_fields)]
488#[ts(export, export_to = "../../../packages/core/src/generated/")]
489pub struct PatternPermissionRule {
490    pub mode: PermissionMode,
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub operations: Vec<String>,
493    #[serde(default, skip_serializing_if = "Vec::is_empty")]
494    pub patterns: Vec<String>,
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
498#[serde(rename_all = "camelCase", deny_unknown_fields)]
499#[ts(export, export_to = "../../../packages/core/src/generated/")]
500pub struct PermissionsPolicy {
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    #[ts(optional)]
503    pub fs: Option<FsPermissionScope>,
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    #[ts(optional)]
506    pub network: Option<PatternPermissionScope>,
507    #[serde(
508        default,
509        rename = "childProcess",
510        skip_serializing_if = "Option::is_none"
511    )]
512    #[ts(optional)]
513    pub child_process: Option<PatternPermissionScope>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    #[ts(optional)]
516    pub process: Option<PatternPermissionScope>,
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    #[ts(optional)]
519    pub env: Option<PatternPermissionScope>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    #[ts(optional)]
522    pub binding: Option<PatternPermissionScope>,
523}
524
525#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
526#[serde(rename_all = "camelCase", deny_unknown_fields)]
527#[ts(export, export_to = "../../../packages/core/src/generated/")]
528pub struct VmLimitsConfig {
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    #[ts(optional)]
531    pub resources: Option<ResourceLimitsConfig>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    #[ts(optional)]
534    pub http: Option<HttpLimitsConfig>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    #[ts(optional)]
537    pub tools: Option<ToolLimitsConfig>,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    #[ts(optional)]
540    pub plugins: Option<PluginLimitsConfig>,
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    #[ts(optional)]
543    pub acp: Option<AcpLimitsConfig>,
544    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
545    #[ts(optional)]
546    pub js_runtime: Option<JsRuntimeLimitsConfig>,
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    #[ts(optional)]
549    pub python: Option<PythonLimitsConfig>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    #[ts(optional)]
552    pub wasm: Option<WasmLimitsConfig>,
553}
554
555impl VmLimitsConfig {
556    fn validate(&self, max_frame_bytes: usize) -> Result<(), VmConfigError> {
557        if let Some(http) = &self.http {
558            if let Some(max_fetch_response_bytes) = http.max_fetch_response_bytes {
559                if max_fetch_response_bytes == 0 {
560                    return Err(VmConfigError::new(
561                        "limits.http.maxFetchResponseBytes must be greater than zero",
562                    ));
563                }
564                if max_fetch_response_bytes as usize > max_frame_bytes {
565                    return Err(VmConfigError::new(format!(
566                        "limits.http.maxFetchResponseBytes ({max_fetch_response_bytes}) must be <= the sidecar wire frame cap ({max_frame_bytes})"
567                    )));
568                }
569            }
570        }
571        if let Some(tools) = &self.tools {
572            if let (Some(default), Some(max)) =
573                (tools.default_tool_timeout_ms, tools.max_tool_timeout_ms)
574            {
575                if default > max {
576                    return Err(VmConfigError::new(
577                        "limits.tools.defaultToolTimeoutMs must be <= limits.tools.maxToolTimeoutMs",
578                    ));
579                }
580            }
581        }
582        Ok(())
583    }
584}
585
586macro_rules! limits_struct {
587    ($name:ident { $($field:ident),* $(,)? }) => {
588        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
589        #[serde(rename_all = "camelCase", deny_unknown_fields)]
590        #[ts(export, export_to = "../../../packages/core/src/generated/")]
591        pub struct $name {
592            $(
593                #[serde(default, skip_serializing_if = "Option::is_none")]
594                #[ts(optional)]
595                #[ts(type = "number")]
596                pub $field: Option<u64>,
597            )*
598        }
599    };
600}
601
602limits_struct!(ResourceLimitsConfig {
603    cpu_count,
604    max_processes,
605    max_open_fds,
606    max_pipes,
607    max_ptys,
608    max_sockets,
609    max_connections,
610    max_socket_buffered_bytes,
611    max_socket_datagram_queue_len,
612    max_filesystem_bytes,
613    max_inode_count,
614    max_blocking_read_ms,
615    max_pread_bytes,
616    max_fd_write_bytes,
617    max_process_argv_bytes,
618    max_process_env_bytes,
619    max_readdir_entries,
620    max_wasm_fuel,
621    max_wasm_memory_bytes,
622    max_wasm_stack_bytes,
623});
624
625limits_struct!(HttpLimitsConfig {
626    max_fetch_response_bytes,
627});
628
629limits_struct!(ToolLimitsConfig {
630    default_tool_timeout_ms,
631    max_tool_timeout_ms,
632    max_registered_toolkits,
633    max_registered_tools_per_vm,
634    max_tools_per_toolkit,
635    max_tool_schema_bytes,
636    max_tool_examples_per_tool,
637    max_tool_example_input_bytes,
638});
639
640limits_struct!(PluginLimitsConfig {
641    max_persisted_manifest_bytes,
642    max_persisted_manifest_file_bytes,
643});
644
645limits_struct!(AcpLimitsConfig {
646    max_read_line_bytes,
647    stdout_buffer_byte_limit,
648});
649
650limits_struct!(JsRuntimeLimitsConfig {
651    v8_heap_limit_mb,
652    sync_rpc_wait_timeout_ms,
653    captured_output_limit_bytes,
654    stdin_buffer_limit_bytes,
655    event_payload_limit_bytes,
656    v8_ipc_max_frame_bytes,
657});
658
659limits_struct!(PythonLimitsConfig {
660    output_buffer_max_bytes,
661    execution_timeout_ms,
662    max_old_space_mb,
663    vfs_rpc_timeout_ms,
664});
665
666limits_struct!(WasmLimitsConfig {
667    max_module_file_bytes,
668    captured_output_limit_bytes,
669    sync_read_limit_bytes,
670});
671
672#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
673#[serde(rename_all = "camelCase", deny_unknown_fields)]
674#[ts(export, export_to = "../../../packages/core/src/generated/")]
675pub struct VmDnsConfig {
676    #[serde(default, rename = "nameServers", skip_serializing_if = "Vec::is_empty")]
677    pub name_servers: Vec<String>,
678    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
679    pub overrides: BTreeMap<String, Vec<String>>,
680}
681
682impl VmDnsConfig {
683    fn validate(&self) -> Result<(), VmConfigError> {
684        for entry in &self.name_servers {
685            if entry.trim().is_empty() {
686                return Err(VmConfigError::new(
687                    "dns.nameServers entries must not be empty",
688                ));
689            }
690        }
691        for (host, addresses) in &self.overrides {
692            if host.trim().is_empty() {
693                return Err(VmConfigError::new("dns.overrides keys must not be empty"));
694            }
695            if addresses.is_empty() {
696                return Err(VmConfigError::new(format!(
697                    "dns.overrides.{host} must contain at least one address"
698                )));
699            }
700        }
701        Ok(())
702    }
703}
704
705#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
706#[serde(rename_all = "camelCase", deny_unknown_fields)]
707#[ts(export, export_to = "../../../packages/core/src/generated/")]
708pub struct VmListenPolicyConfig {
709    #[serde(default, rename = "portMin", skip_serializing_if = "Option::is_none")]
710    #[ts(optional)]
711    pub port_min: Option<u16>,
712    #[serde(default, rename = "portMax", skip_serializing_if = "Option::is_none")]
713    #[ts(optional)]
714    pub port_max: Option<u16>,
715    #[serde(
716        default,
717        rename = "allowPrivileged",
718        skip_serializing_if = "Option::is_none"
719    )]
720    #[ts(optional)]
721    pub allow_privileged: Option<bool>,
722}
723
724impl VmListenPolicyConfig {
725    fn validate(&self) -> Result<(), VmConfigError> {
726        if self.port_min == Some(0) {
727            return Err(VmConfigError::new(
728                "listen.portMin must be between 1 and 65535",
729            ));
730        }
731        if self.port_max == Some(0) {
732            return Err(VmConfigError::new(
733                "listen.portMax must be between 1 and 65535",
734            ));
735        }
736        if let (Some(min), Some(max)) = (self.port_min, self.port_max) {
737            if min > max {
738                return Err(VmConfigError::new(
739                    "listen.portMin must be <= listen.portMax",
740                ));
741            }
742        }
743        Ok(())
744    }
745}
746
747#[derive(Debug, Clone, PartialEq, Eq)]
748pub struct VmConfigError {
749    message: String,
750}
751
752impl VmConfigError {
753    pub fn new(message: impl Into<String>) -> Self {
754        Self {
755            message: message.into(),
756        }
757    }
758}
759
760impl std::fmt::Display for VmConfigError {
761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
762        f.write_str(&self.message)
763    }
764}
765
766impl std::error::Error for VmConfigError {}
767
768fn validate_guest_path(label: &str, path: &str) -> Result<(), VmConfigError> {
769    if !path.starts_with('/') {
770        return Err(VmConfigError::new(format!("{label} must be absolute")));
771    }
772    if path.split('/').any(|part| part == "..") {
773        return Err(VmConfigError::new(format!("{label} must not contain '..'")));
774    }
775    Ok(())
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn default_config_round_trips() {
784        let config = CreateVmConfig::default();
785        let json = serde_json::to_string(&config).expect("serialize config");
786        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("decode config");
787        assert_eq!(decoded, config);
788    }
789
790    #[test]
791    fn unknown_fields_are_rejected() {
792        let error =
793            serde_json::from_str::<CreateVmConfig>(r#"{"rootFilesystem":{},"surprise":true}"#)
794                .expect_err("unknown fields should fail");
795        assert!(error.to_string().contains("unknown field"));
796    }
797
798    #[test]
799    fn validate_rejects_fetch_limit_above_frame_cap() {
800        let config = CreateVmConfig {
801            limits: Some(VmLimitsConfig {
802                http: Some(HttpLimitsConfig {
803                    max_fetch_response_bytes: Some(2048),
804                }),
805                ..VmLimitsConfig::default()
806            }),
807            ..CreateVmConfig::default()
808        };
809        assert!(config.validate(1024).is_err());
810    }
811
812    fn js_runtime_config(value: serde_json::Value) -> Result<CreateVmConfig, serde_json::Error> {
813        serde_json::from_value(serde_json::json!({ "jsRuntime": value }))
814    }
815
816    #[test]
817    fn js_runtime_defaults_to_node() {
818        let config: CreateVmConfig =
819            serde_json::from_value(serde_json::json!({ "jsRuntime": {} })).expect("decode");
820        let js = config.js_runtime.expect("jsRuntime present");
821        assert_eq!(js.platform, JsRuntimePlatform::Node);
822        assert_eq!(js.module_resolution, JsModuleResolution::Node);
823        assert!(js.allowed_builtins.is_none());
824        assert!(js.high_resolution_time.is_none());
825    }
826
827    #[test]
828    fn js_runtime_high_resolution_time_defaults_off_and_round_trips() {
829        let defaulted = js_runtime_config(serde_json::json!({})).unwrap();
830        assert!(defaulted.js_runtime.unwrap().high_resolution_time.is_none());
831
832        let enabled = js_runtime_config(serde_json::json!({
833            "highResolutionTime": true,
834        }))
835        .unwrap();
836        assert_eq!(
837            enabled.js_runtime.as_ref().unwrap().high_resolution_time,
838            Some(true)
839        );
840        let json = serde_json::to_string(&enabled).expect("serialize");
841        assert!(json.contains("highResolutionTime"));
842        let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
843        assert_eq!(decoded, enabled);
844    }
845
846    #[test]
847    fn js_runtime_all_platform_resolution_combos_round_trip() {
848        for platform in ["node", "browser", "neutral", "bare"] {
849            for resolution in ["node", "relative", "none"] {
850                let config = js_runtime_config(serde_json::json!({
851                    "platform": platform,
852                    "moduleResolution": resolution,
853                }))
854                .unwrap_or_else(|err| panic!("decode {platform}/{resolution}: {err}"));
855                let json = serde_json::to_string(&config).expect("serialize");
856                let decoded: CreateVmConfig = serde_json::from_str(&json).expect("re-decode");
857                assert_eq!(decoded, config);
858                assert!(config.validate(usize::MAX).is_ok());
859            }
860        }
861    }
862
863    #[test]
864    fn js_runtime_allowed_builtins_tri_state() {
865        // None => omitted.
866        let none = js_runtime_config(serde_json::json!({ "platform": "node" })).unwrap();
867        assert!(none.js_runtime.unwrap().allowed_builtins.is_none());
868        // Some([]) => deny all (representable, distinct from None).
869        let empty = js_runtime_config(serde_json::json!({ "allowedBuiltins": [] })).unwrap();
870        assert_eq!(empty.js_runtime.unwrap().allowed_builtins, Some(Vec::new()));
871        // Some([..]) => explicit.
872        let some = js_runtime_config(serde_json::json!({ "allowedBuiltins": ["path", "node:fs"] }))
873            .unwrap();
874        assert_eq!(
875            some.js_runtime.unwrap().allowed_builtins,
876            Some(vec!["path".to_owned(), "node:fs".to_owned()])
877        );
878    }
879
880    #[test]
881    fn js_runtime_rejects_allowed_builtins_under_non_node_platform() {
882        for platform in ["browser", "neutral", "bare"] {
883            let config = js_runtime_config(serde_json::json!({
884                "platform": platform,
885                "allowedBuiltins": ["path"],
886            }))
887            .unwrap();
888            let error = config
889                .validate(usize::MAX)
890                .expect_err("allowedBuiltins under non-node must reject");
891            assert!(error.to_string().contains("allowedBuiltins"));
892        }
893    }
894
895    #[test]
896    fn js_runtime_rejects_unknown_builtin_names() {
897        let config = js_runtime_config(serde_json::json!({
898            "platform": "node",
899            "allowedBuiltins": ["path", "totally_not_a_builtin"],
900        }))
901        .unwrap();
902        let error = config
903            .validate(usize::MAX)
904            .expect_err("unknown builtin must reject");
905        assert!(error.to_string().contains("unknown builtin"));
906    }
907
908    #[test]
909    fn js_runtime_accepts_empty_allow_list_under_node() {
910        let config =
911            js_runtime_config(serde_json::json!({ "platform": "node", "allowedBuiltins": [] }))
912                .unwrap();
913        assert!(config.validate(usize::MAX).is_ok());
914    }
915
916    #[test]
917    fn js_runtime_rejects_unknown_fields() {
918        let error = js_runtime_config(serde_json::json!({ "surprise": true }))
919            .expect_err("unknown jsRuntime field should fail");
920        assert!(error.to_string().contains("unknown field"));
921    }
922}