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