Skip to main content

agentos_client/
config.rs

1//! Configuration types: `AgentOsConfig` (= TS `AgentOsOptions`), the permissions tree, root
2//! filesystem config, mount config, and the schedule-driver abstraction.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (`AgentOsOptions`), `runtime.ts` (`Permissions`),
5//! `layers.ts` / `overlay-filesystem.ts` (root/overlay), and `cron/` (schedule driver).
6//!
7//! Non-serializable parameters (`MountConfig::Plain.driver`, `CronAction::Callback`) are in-process
8//! only and become `Arc<dyn ...>` trait objects; they cannot cross the wire and are gated exactly as
9//! the actor layer gates them.
10
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use crate::fs::VirtualFileSystem;
17pub use agentos_vm_config::{
18    VmGroupConfig, VmSqliteCallbackRequest, VmSqliteCallbackResponse, VmSqliteDescriptor,
19    VmSqliteQueryResult, VmSqliteStatement, VmSqliteValue, VmUserAccountConfig, VmUserConfig,
20    VM_SQLITE_CALLBACK_NAMESPACE,
21};
22
23pub type SidecarSqliteCallback = Arc<
24    dyn Fn(
25            VmSqliteCallbackRequest,
26        ) -> futures::future::BoxFuture<'static, Result<VmSqliteCallbackResponse, String>>
27        + Send
28        + Sync,
29>;
30
31/// Resolved client options (= TS `AgentOsOptions`). All fields optional with documented defaults.
32///
33/// Keep this Rust mirror in sync with `packages/core/src/agent-os.ts::AgentOsOptions`
34/// and `packages/core/src/options-schema.ts::agentOsOptionsSchema`.
35#[derive(Default)]
36pub struct AgentOsConfig {
37    /// VM-scoped SQLite backend shared by VFS metadata/storage and agentOS
38    /// durable state.
39    pub database: Option<agentos_vm_config::VmSqliteDescriptor>,
40    /// Trusted callback for [`VmSqliteDescriptor::HostCallback`]. Hosted actors
41    /// use this to execute VM database operations through Rivet SQLite.
42    pub sidecar_sqlite_callback: Option<SidecarSqliteCallback>,
43    /// Initial virtual Linux credentials and account record. Defaults to `1000:1000` (`agentos`).
44    pub user: Option<VmUserConfig>,
45    /// Complete initial VM environment. `None` selects the sidecar's bundled base environment;
46    /// `Some(empty)` deliberately starts with an empty environment.
47    pub environment: Option<BTreeMap<String, String>>,
48    /// Software packages to install (flattened). Default `[]`.
49    pub software: Vec<SoftwareInput>,
50    /// Package directories to project into the VM's `/opt/agentos` tree (the
51    /// agentos package projection). Each entry is a host dir containing an
52    /// `agentos-package.json` manifest + the package payload. Default `[]`.
53    pub packages: Vec<PackageRef>,
54    /// Guest mount point for the package projection. Default `/opt/agentos`
55    /// (agentos's `OPT_AGENTOS_ROOT`) when `None`.
56    pub packages_mount_at: Option<String>,
57    /// Trusted Core package acquisition limits and local-test transport policy.
58    /// Hosted actor callers cannot configure this process-owned policy.
59    pub package_resolver: Option<crate::software::PackageResolverOptions>,
60    /// Registry packages and URL or path sources installed after boot, before
61    /// [`crate::AgentOs::create`] returns (= TS `software` registry and URL
62    /// entries). Registry names resolve before the VM is created. Default `[]`.
63    pub software_sources: Vec<crate::software_registry::SoftwareSource>,
64    /// Where registry sources resolve (= TS `softwareRegistry`). Default:
65    /// `https://releases.rivet.dev/agent-os/` at this client's release.
66    pub software_registry: Option<crate::software_registry::SoftwareRegistryOptions>,
67    /// Loopback ports exempt from the default outbound-to-host block.
68    pub loopback_exempt_ports: Vec<u16>,
69    /// Allowed Node.js builtins. Default: the hardened native-bridge set.
70    pub allowed_node_builtins: Option<Vec<String>>,
71    /// Opt in to a high-resolution monotonic clock for guest JavaScript.
72    /// `None` selects the hardened millisecond-resolution default.
73    pub high_resolution_time: Option<bool>,
74    /// Root filesystem configuration. Default: overlay + bundled base snapshot.
75    pub root_filesystem: RootFilesystemConfig,
76    /// Additional mounts.
77    pub mounts: Vec<MountConfig>,
78    /// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`].
79    pub schedule_driver: Option<Arc<dyn ScheduleDriver>>,
80    /// Host function collections to register, keyed by collection name.
81    pub host_functions: HostFunctionCollections,
82    /// Rust-only sidecar callback handler for `js_bridge`-style plugin requests.
83    pub sidecar_js_bridge_callback: Option<SidecarJsBridgeCallback>,
84    /// Permission policy. Default: allow-all.
85    pub permissions: Option<Permissions>,
86    /// Operator-tunable VM limits. Default: sidecar/kernel built-ins.
87    pub limits: Option<AgentOsLimits>,
88    /// Sidecar placement/config. Default: shared `default` pool.
89    pub sidecar: Option<AgentOsSidecarConfig>,
90    /// Absolute path to the `agentos-native-sidecar` binary, resolved from the npm
91    /// package on the TypeScript side. Threaded to `SidecarProcess::spawn`
92    /// instead of relying on the
93    /// `AGENTOS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`.
94    pub sidecar_binary_path: Option<String>,
95}
96
97/// Builder for [`AgentOsConfig`].
98#[derive(Default)]
99pub struct AgentOsConfigBuilder {
100    config: AgentOsConfig,
101}
102
103impl AgentOsConfigBuilder {
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    pub fn database(mut self, database: agentos_vm_config::VmSqliteDescriptor) -> Self {
109        self.config.database = Some(database);
110        self
111    }
112
113    pub fn sidecar_sqlite_callback(mut self, callback: SidecarSqliteCallback) -> Self {
114        self.config.sidecar_sqlite_callback = Some(callback);
115        self
116    }
117
118    pub fn packages(mut self, packages: Vec<PackageRef>) -> Self {
119        self.config.packages = packages;
120        self
121    }
122
123    pub fn packages_mount_at(mut self, mount_at: impl Into<String>) -> Self {
124        self.config.packages_mount_at = Some(mount_at.into());
125        self
126    }
127
128    pub fn package_resolver(mut self, options: crate::software::PackageResolverOptions) -> Self {
129        self.config.package_resolver = Some(options);
130        self
131    }
132
133    pub fn software_sources(
134        mut self,
135        sources: Vec<crate::software_registry::SoftwareSource>,
136    ) -> Self {
137        self.config.software_sources = sources;
138        self
139    }
140
141    pub fn software_registry(
142        mut self,
143        options: crate::software_registry::SoftwareRegistryOptions,
144    ) -> Self {
145        self.config.software_registry = Some(options);
146        self
147    }
148
149    pub fn loopback_exempt_ports(mut self, ports: Vec<u16>) -> Self {
150        self.config.loopback_exempt_ports = ports;
151        self
152    }
153
154    pub fn allowed_node_builtins(mut self, builtins: Vec<String>) -> Self {
155        self.config.allowed_node_builtins = Some(builtins);
156        self
157    }
158
159    pub fn user(mut self, user: VmUserConfig) -> Self {
160        self.config.user = Some(user);
161        self
162    }
163
164    pub fn environment(mut self, environment: BTreeMap<String, String>) -> Self {
165        self.config.environment = Some(environment);
166        self
167    }
168
169    pub fn high_resolution_time(mut self, enabled: bool) -> Self {
170        self.config.high_resolution_time = Some(enabled);
171        self
172    }
173
174    pub fn root_filesystem(mut self, root: RootFilesystemConfig) -> Self {
175        self.config.root_filesystem = root;
176        self
177    }
178
179    pub fn mounts(mut self, mounts: Vec<MountConfig>) -> Self {
180        self.config.mounts = mounts;
181        self
182    }
183
184    pub fn schedule_driver(mut self, driver: Arc<dyn ScheduleDriver>) -> Self {
185        self.config.schedule_driver = Some(driver);
186        self
187    }
188
189    pub fn host_functions(mut self, host_functions: HostFunctionCollections) -> Self {
190        self.config.host_functions = host_functions;
191        self
192    }
193
194    pub fn sidecar_js_bridge_callback(mut self, callback: SidecarJsBridgeCallback) -> Self {
195        self.config.sidecar_js_bridge_callback = Some(callback);
196        self
197    }
198
199    pub fn permissions(mut self, permissions: Permissions) -> Self {
200        self.config.permissions = Some(permissions);
201        self
202    }
203
204    pub fn limits(mut self, limits: AgentOsLimits) -> Self {
205        self.config.limits = Some(limits);
206        self
207    }
208
209    pub fn sidecar(mut self, sidecar: AgentOsSidecarConfig) -> Self {
210        self.config.sidecar = Some(sidecar);
211        self
212    }
213
214    pub fn sidecar_binary_path(mut self, path: impl Into<String>) -> Self {
215        self.config.sidecar_binary_path = Some(path.into());
216        self
217    }
218
219    pub fn build(self) -> AgentOsConfig {
220        self.config
221    }
222}
223
224impl AgentOsConfig {
225    /// Validate the complete VM configuration without starting a sidecar.
226    ///
227    /// Hosted adapters use this to reject invalid creation input before Rivet
228    /// persists actor state. The same serializer is used by [`AgentOs::create`](crate::AgentOs::create),
229    /// so validation cannot drift into an actor-owned copy.
230    pub fn validate(&self) -> Result<(), crate::ClientError> {
231        crate::agent_os::validate_config(self)
232    }
233}
234
235/// The kind of a software package, which decides how it is mounted into the VM. Mirrors the TS
236/// descriptor `type` discriminator (`packages/core/src/packages.ts`).
237#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
239#[serde(rename_all = "kebab-case")]
240pub enum SoftwareKind {
241    /// A directory of wasm command binaries. Mounted at `/__agentos/commands/{index}/` so the
242    /// sidecar's command discovery can resolve guest commands (`echo`, `sh`, `grep`, ...).
243    #[default]
244    WasmCommands,
245    /// A host-function package. Not mounted as a command directory.
246    HostFunction,
247}
248
249/// A flattened software package input.
250#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct SoftwareInput {
253    pub package: String,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub version: Option<String>,
256    /// How the package is mounted into the VM. Defaults to [`SoftwareKind::WasmCommands`].
257    #[serde(default)]
258    pub kind: SoftwareKind,
259}
260
261/// A reference to a packed `.aospkg` package for the `/opt/agentos`
262/// projection. A directory path remains accepted for local transition
263/// fixtures.
264#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct PackageRef {
267    /// Serialized as `packagePath` — the single package-ref spelling on every
268    /// JSON boundary (registry exports, actor config, client config).
269    #[serde(rename = "packagePath")]
270    pub path: String,
271}
272
273/// A host-side host function callback. Receives the validated JSON input, returns a JSON result or an
274/// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the
275/// sidecar host-callback channel.
276pub type HostFunctionCallback = Arc<
277    dyn Fn(
278            serde_json::Value,
279        ) -> futures::future::BoxFuture<'static, Result<serde_json::Value, String>>
280        + Send
281        + Sync,
282>;
283
284/// A sidecar-initiated `js_bridge`-style filesystem callback.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct SidecarJsBridgeCall {
287    pub call_id: String,
288    pub mount_id: String,
289    pub operation: String,
290    pub args: serde_json::Value,
291}
292
293/// Host-side handler for sidecar `JsBridgeCallRequest` payloads.
294///
295/// This is Rust-only and intentionally not JSON-serializable. Managed services
296/// use it to bind native sidecar storage to their durable database without
297/// teaching agentOS about the hosting framework.
298pub type SidecarJsBridgeCallback = Arc<
299    dyn Fn(
300            SidecarJsBridgeCall,
301        )
302            -> futures::future::BoxFuture<'static, Result<Option<serde_json::Value>, String>>
303        + Send
304        + Sync,
305>;
306
307/// A single host function. The key it is registered under names it, and the
308/// input schema's `description` documents it for the agent.
309#[derive(Clone)]
310pub struct HostFunction {
311    /// JSON Schema for the host function input (forwarded to the sidecar `register_host_callbacks` definition).
312    pub input_schema: serde_json::Value,
313    pub timeout_ms: Option<u64>,
314    /// Host-side implementation, invoked when the guest calls `<collection>:<function>`.
315    pub execute: HostFunctionCallback,
316}
317
318/// One collection of host functions, keyed by function name. Each key becomes a
319/// subcommand of the collection's CLI binary and a method on its guest global.
320pub type HostFunctionCollection = BTreeMap<String, HostFunction>;
321
322/// Host function collections, keyed by collection name (in-process;
323/// implementations stay host-side). Each key becomes the CLI binary
324/// `agentos-{name}` and a frozen guest global; functions are exposed to the
325/// guest as `<collection>:<function>` and dispatched back to
326/// [`HostFunction::execute`] via the sidecar host-callback channel.
327pub type HostFunctionCollections = BTreeMap<String, HostFunctionCollection>;
328
329/// A host function resolved to the names the VM uses.
330#[derive(Clone)]
331pub struct ResolvedHostFunction {
332    /// Kebab-case command name. Becomes the CLI subcommand.
333    pub name: String,
334    /// Taken from the input schema's `description`.
335    pub description: String,
336    pub input_schema: serde_json::Value,
337    pub timeout_ms: Option<u64>,
338    pub execute: HostFunctionCallback,
339}
340
341/// A collection resolved to the names the VM uses. Keys arrive as identifiers
342/// and are converted once here, so the rest of the client and the sidecar only
343/// ever see kebab-case command names.
344#[derive(Clone)]
345pub struct ResolvedHostFunctions {
346    /// Kebab-case collection name. Becomes the CLI suffix: `agentos-{name}`.
347    pub name: String,
348    pub functions: Vec<ResolvedHostFunction>,
349}
350
351/// Convert a registration key to its command name. `listOrders` and
352/// `list-orders` both become `list-orders`, so the guest sees one spelling
353/// whichever the caller wrote. Mirrors `hostFunctionCommandName` in the
354/// TypeScript client.
355pub fn host_function_command_name(key: &str) -> String {
356    let mut out = String::with_capacity(key.len() + 4);
357    let chars: Vec<char> = key.chars().collect();
358    for (index, character) in chars.iter().enumerate() {
359        if character.is_ascii_uppercase() && index > 0 {
360            let previous = chars[index - 1];
361            let next_is_lower = chars
362                .get(index + 1)
363                .is_some_and(|next| next.is_ascii_lowercase());
364            if previous.is_ascii_lowercase()
365                || previous.is_ascii_digit()
366                || (previous.is_ascii_uppercase() && next_is_lower)
367            {
368                out.push('-');
369            }
370        }
371        out.extend(character.to_lowercase());
372    }
373    out
374}
375
376fn is_command_name(name: &str) -> bool {
377    !name.is_empty()
378        && !name.starts_with('-')
379        && !name.ends_with('-')
380        && !name.contains("--")
381        && name.chars().all(|character| {
382            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
383        })
384}
385
386fn to_command_name(kind: &str, key: &str) -> Result<String, String> {
387    let name = host_function_command_name(key);
388    if is_command_name(&name) {
389        Ok(name)
390    } else {
391        Err(format!(
392            "{kind} name \"{key}\" must be alphanumeric, written in camelCase or with single hyphen separators"
393        ))
394    }
395}
396
397/// The description the agent sees, taken from the input schema's `description`.
398fn schema_description(input_schema: &serde_json::Value) -> String {
399    input_schema
400        .get("description")
401        .and_then(serde_json::Value::as_str)
402        .unwrap_or_default()
403        .to_string()
404}
405
406/// Resolve the caller's collections into the shape the client and sidecar use.
407/// Fails on a key that cannot become a command name, and on two keys that
408/// collide once converted.
409pub fn resolve_host_functions(
410    collections: &HostFunctionCollections,
411) -> Result<Vec<ResolvedHostFunctions>, String> {
412    let mut resolved = Vec::with_capacity(collections.len());
413    let mut seen_collections: BTreeMap<String, String> = BTreeMap::new();
414
415    for (collection_key, collection) in collections {
416        let name = to_command_name("Host function collection", collection_key)?;
417        if let Some(collided) = seen_collections.get(&name) {
418            return Err(format!(
419                "Host function collections \"{collided}\" and \"{collection_key}\" both resolve to the command name \"{name}\""
420            ));
421        }
422        seen_collections.insert(name.clone(), collection_key.clone());
423
424        let mut functions = Vec::with_capacity(collection.len());
425        let mut seen_functions: BTreeMap<String, String> = BTreeMap::new();
426        for (function_key, definition) in collection {
427            let function_name = to_command_name("Host function", function_key)?;
428            if let Some(collided) = seen_functions.get(&function_name) {
429                return Err(format!(
430                    "Host functions \"{collided}\" and \"{function_key}\" in collection \"{collection_key}\" both resolve to the command name \"{function_name}\""
431                ));
432            }
433            seen_functions.insert(function_name.clone(), function_key.clone());
434            functions.push(ResolvedHostFunction {
435                name: function_name,
436                description: schema_description(&definition.input_schema),
437                input_schema: definition.input_schema.clone(),
438                timeout_ms: definition.timeout_ms,
439                execute: definition.execute.clone(),
440            });
441        }
442
443        resolved.push(ResolvedHostFunctions { name, functions });
444    }
445
446    Ok(resolved)
447}
448
449// ---------------------------------------------------------------------------
450// VM limits (agent-os.ts AgentOsLimits / sidecar/limits.ts)
451// ---------------------------------------------------------------------------
452
453/// Operator-tunable runtime limits for a VM. Every field is optional; unset fields fall back to the
454/// sidecar defaults.
455#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
456#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
457#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(deny_unknown_fields)]
459pub struct AgentOsLimits {
460    #[serde(
461        default,
462        rename = "agentosPackages",
463        skip_serializing_if = "Option::is_none"
464    )]
465    #[cfg_attr(feature = "contract", ts(optional))]
466    pub agentos_packages: Option<AgentOsPackageLimits>,
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    #[cfg_attr(feature = "contract", ts(optional))]
469    pub reactor: Option<ReactorLimits>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub resources: Option<ResourceLimits>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub http: Option<HttpLimits>,
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    #[cfg_attr(feature = "contract", ts(optional))]
476    pub tls: Option<TlsLimits>,
477    #[serde(
478        default,
479        rename = "hostFunctions",
480        skip_serializing_if = "Option::is_none"
481    )]
482    pub host_functions: Option<HostFunctionLimits>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub plugins: Option<PluginLimits>,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub sqlite: Option<SqliteLimits>,
487    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
488    pub js_runtime: Option<JsRuntimeLimits>,
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub python: Option<PythonLimits>,
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub wasm: Option<WasmLimits>,
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    #[cfg_attr(feature = "contract", ts(optional))]
495    pub execution: Option<ExecutionLimits>,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub process: Option<ProcessLimits>,
498}
499
500#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
501#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
502#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "camelCase", deny_unknown_fields)]
504pub struct AgentOsPackageLimits {
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    #[cfg_attr(feature = "contract", ts(optional))]
507    pub max_mounts: Option<u64>,
508}
509
510/// Sidecar reactor queues, work quanta, and deadlines. Omitted fields use the sidecar default.
511#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
512#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
513#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "camelCase", deny_unknown_fields)]
515pub struct ReactorLimits {
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    #[cfg_attr(feature = "contract", ts(optional))]
518    pub max_capabilities: Option<u64>,
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    #[cfg_attr(feature = "contract", ts(optional))]
521    pub max_ready_handles: Option<u64>,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    #[cfg_attr(feature = "contract", ts(optional))]
524    pub max_tasks: Option<u64>,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    #[cfg_attr(feature = "contract", ts(optional))]
527    pub work_quantum: Option<u64>,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    #[cfg_attr(feature = "contract", ts(optional))]
530    pub byte_quantum: Option<u64>,
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    #[cfg_attr(feature = "contract", ts(optional))]
533    pub max_handle_commands: Option<u64>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    #[cfg_attr(feature = "contract", ts(optional))]
536    pub max_handle_command_bytes: Option<u64>,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    #[cfg_attr(feature = "contract", ts(optional))]
539    pub max_bridge_calls: Option<u64>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    #[cfg_attr(feature = "contract", ts(optional))]
542    pub max_bridge_request_bytes: Option<u64>,
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    #[cfg_attr(feature = "contract", ts(optional))]
545    pub max_bridge_response_bytes: Option<u64>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    #[cfg_attr(feature = "contract", ts(optional))]
548    pub max_async_completions: Option<u64>,
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    #[cfg_attr(feature = "contract", ts(optional))]
551    pub max_async_completion_bytes: Option<u64>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    #[cfg_attr(feature = "contract", ts(optional))]
554    pub max_blocking_jobs: Option<u64>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    #[cfg_attr(feature = "contract", ts(optional))]
557    pub max_blocking_bytes: Option<u64>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    #[cfg_attr(feature = "contract", ts(optional))]
560    pub per_handle_operation_quantum: Option<u64>,
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    #[cfg_attr(feature = "contract", ts(optional))]
563    pub accept_quantum: Option<u64>,
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    #[cfg_attr(feature = "contract", ts(optional))]
566    pub datagram_quantum: Option<u64>,
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    #[cfg_attr(feature = "contract", ts(optional))]
569    pub completion_quantum: Option<u64>,
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    #[cfg_attr(feature = "contract", ts(optional))]
572    pub signal_quantum: Option<u64>,
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    #[cfg_attr(feature = "contract", ts(optional))]
575    pub shutdown_deadline_ms: Option<u64>,
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    #[cfg_attr(feature = "contract", ts(optional))]
578    pub operation_deadline_ms: Option<u64>,
579}
580
581#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
582#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
583#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(deny_unknown_fields)]
585pub struct ResourceLimits {
586    #[serde(default, rename = "cpuCount", skip_serializing_if = "Option::is_none")]
587    pub cpu_count: Option<u64>,
588    #[serde(
589        default,
590        rename = "maxProcesses",
591        skip_serializing_if = "Option::is_none"
592    )]
593    pub max_processes: Option<u64>,
594    #[serde(
595        default,
596        rename = "maxOpenFds",
597        skip_serializing_if = "Option::is_none"
598    )]
599    pub max_open_fds: Option<u64>,
600    #[serde(default, rename = "maxPipes", skip_serializing_if = "Option::is_none")]
601    pub max_pipes: Option<u64>,
602    #[serde(default, rename = "maxPtys", skip_serializing_if = "Option::is_none")]
603    pub max_ptys: Option<u64>,
604    #[serde(
605        default,
606        rename = "maxSockets",
607        skip_serializing_if = "Option::is_none"
608    )]
609    pub max_sockets: Option<u64>,
610    #[serde(
611        default,
612        rename = "maxConnections",
613        skip_serializing_if = "Option::is_none"
614    )]
615    pub max_connections: Option<u64>,
616    #[serde(
617        default,
618        rename = "maxSocketBufferedBytes",
619        skip_serializing_if = "Option::is_none"
620    )]
621    pub max_socket_buffered_bytes: Option<u64>,
622    #[serde(
623        default,
624        rename = "maxSocketDatagramQueueLen",
625        skip_serializing_if = "Option::is_none"
626    )]
627    pub max_socket_datagram_queue_len: Option<u64>,
628    #[serde(
629        default,
630        rename = "maxFilesystemBytes",
631        skip_serializing_if = "Option::is_none"
632    )]
633    pub max_filesystem_bytes: Option<u64>,
634    #[serde(
635        default,
636        rename = "maxInodeCount",
637        skip_serializing_if = "Option::is_none"
638    )]
639    pub max_inode_count: Option<u64>,
640    #[serde(
641        default,
642        rename = "maxBlockingReadMs",
643        skip_serializing_if = "Option::is_none"
644    )]
645    pub max_blocking_read_ms: Option<u64>,
646    #[serde(
647        default,
648        rename = "maxPreadBytes",
649        skip_serializing_if = "Option::is_none"
650    )]
651    pub max_pread_bytes: Option<u64>,
652    #[serde(
653        default,
654        rename = "maxFdWriteBytes",
655        skip_serializing_if = "Option::is_none"
656    )]
657    pub max_fd_write_bytes: Option<u64>,
658    #[serde(
659        default,
660        rename = "maxProcessArgvBytes",
661        skip_serializing_if = "Option::is_none"
662    )]
663    pub max_process_argv_bytes: Option<u64>,
664    #[serde(
665        default,
666        rename = "maxProcessEnvBytes",
667        skip_serializing_if = "Option::is_none"
668    )]
669    pub max_process_env_bytes: Option<u64>,
670    #[serde(
671        default,
672        rename = "maxReaddirEntries",
673        skip_serializing_if = "Option::is_none"
674    )]
675    pub max_readdir_entries: Option<u64>,
676    #[serde(
677        default,
678        rename = "maxWasmFuel",
679        skip_serializing_if = "Option::is_none"
680    )]
681    pub max_wasm_fuel: Option<u64>,
682    #[serde(
683        default,
684        rename = "maxWasmMemoryBytes",
685        skip_serializing_if = "Option::is_none"
686    )]
687    pub max_wasm_memory_bytes: Option<u64>,
688    #[serde(
689        default,
690        rename = "maxWasmStackBytes",
691        skip_serializing_if = "Option::is_none"
692    )]
693    pub max_wasm_stack_bytes: Option<u64>,
694}
695
696#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
697#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
698#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
699#[serde(deny_unknown_fields)]
700pub struct HttpLimits {
701    #[serde(
702        default,
703        rename = "maxFetchResponseBytes",
704        skip_serializing_if = "Option::is_none"
705    )]
706    pub max_fetch_response_bytes: Option<u64>,
707}
708
709/// Per-VM TLS plaintext buffering overrides. Omitted fields use sidecar defaults.
710#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
711#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
712#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(rename_all = "camelCase", deny_unknown_fields)]
714pub struct TlsLimits {
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    #[cfg_attr(feature = "contract", ts(optional))]
717    pub max_buffered_bytes: Option<u64>,
718}
719
720/// Per-VM language-execution retention and warning overrides.
721#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
722#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
723#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
724#[serde(rename_all = "camelCase", deny_unknown_fields)]
725pub struct ExecutionLimits {
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    #[cfg_attr(feature = "contract", ts(optional))]
728    pub completed_ttl_ms: Option<u64>,
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    #[cfg_attr(feature = "contract", ts(optional))]
731    pub max_completed_executions: Option<u64>,
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    #[cfg_attr(feature = "contract", ts(optional))]
734    pub live_execution_warning_threshold: Option<u64>,
735}
736
737#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
738#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
739#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(deny_unknown_fields)]
741pub struct HostFunctionLimits {
742    #[serde(
743        default,
744        rename = "defaultTimeoutMs",
745        skip_serializing_if = "Option::is_none"
746    )]
747    pub default_timeout_ms: Option<u64>,
748    #[serde(
749        default,
750        rename = "maxTimeoutMs",
751        skip_serializing_if = "Option::is_none"
752    )]
753    pub max_timeout_ms: Option<u64>,
754    #[serde(
755        default,
756        rename = "maxRegisteredCollections",
757        skip_serializing_if = "Option::is_none"
758    )]
759    pub max_registered_collections: Option<u64>,
760    #[serde(
761        default,
762        rename = "maxRegisteredFunctionsPerVm",
763        skip_serializing_if = "Option::is_none"
764    )]
765    pub max_registered_functions_per_vm: Option<u64>,
766    #[serde(
767        default,
768        rename = "maxFunctionsPerCollection",
769        skip_serializing_if = "Option::is_none"
770    )]
771    pub max_functions_per_collection: Option<u64>,
772    #[serde(
773        default,
774        rename = "maxSchemaBytes",
775        skip_serializing_if = "Option::is_none"
776    )]
777    pub max_schema_bytes: Option<u64>,
778    #[serde(
779        default,
780        rename = "maxExamplesPerFunction",
781        skip_serializing_if = "Option::is_none"
782    )]
783    pub max_examples_per_function: Option<u64>,
784    #[serde(
785        default,
786        rename = "maxExampleInputBytes",
787        skip_serializing_if = "Option::is_none"
788    )]
789    pub max_example_input_bytes: Option<u64>,
790}
791
792#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
793#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
794#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(deny_unknown_fields)]
796pub struct PluginLimits {
797    #[serde(
798        default,
799        rename = "maxPersistedManifestBytes",
800        skip_serializing_if = "Option::is_none"
801    )]
802    pub max_persisted_manifest_bytes: Option<u64>,
803    #[serde(
804        default,
805        rename = "maxPersistedManifestFileBytes",
806        skip_serializing_if = "Option::is_none"
807    )]
808    pub max_persisted_manifest_file_bytes: Option<u64>,
809}
810
811#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
812#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
813#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
814#[serde(deny_unknown_fields)]
815pub struct SqliteLimits {
816    #[serde(
817        default,
818        rename = "maxResultBytes",
819        skip_serializing_if = "Option::is_none"
820    )]
821    pub max_result_bytes: Option<u64>,
822}
823
824#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
825#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
826#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(deny_unknown_fields)]
828pub struct JsRuntimeLimits {
829    #[serde(
830        default,
831        rename = "v8HeapLimitMb",
832        skip_serializing_if = "Option::is_none"
833    )]
834    pub v8_heap_limit_mb: Option<u64>,
835    #[serde(
836        default,
837        rename = "syncRpcWaitTimeoutMs",
838        skip_serializing_if = "Option::is_none"
839    )]
840    pub sync_rpc_wait_timeout_ms: Option<u64>,
841    #[serde(
842        default,
843        rename = "cpuTimeLimitMs",
844        skip_serializing_if = "Option::is_none"
845    )]
846    pub cpu_time_limit_ms: Option<u64>,
847    #[serde(
848        default,
849        rename = "wallClockLimitMs",
850        skip_serializing_if = "Option::is_none"
851    )]
852    pub wall_clock_limit_ms: Option<u64>,
853    #[serde(
854        default,
855        rename = "importCacheMaterializeTimeoutMs",
856        skip_serializing_if = "Option::is_none"
857    )]
858    pub import_cache_materialize_timeout_ms: Option<u64>,
859    #[serde(
860        default,
861        rename = "capturedOutputLimitBytes",
862        skip_serializing_if = "Option::is_none"
863    )]
864    pub captured_output_limit_bytes: Option<u64>,
865    #[serde(
866        default,
867        rename = "stdinBufferLimitBytes",
868        skip_serializing_if = "Option::is_none"
869    )]
870    pub stdin_buffer_limit_bytes: Option<u64>,
871    #[serde(
872        default,
873        rename = "eventPayloadLimitBytes",
874        skip_serializing_if = "Option::is_none"
875    )]
876    pub event_payload_limit_bytes: Option<u64>,
877    #[serde(
878        default,
879        rename = "v8IpcMaxFrameBytes",
880        skip_serializing_if = "Option::is_none"
881    )]
882    pub v8_ipc_max_frame_bytes: Option<u64>,
883}
884
885#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
886#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
887#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct PythonLimits {
890    #[serde(
891        default,
892        rename = "outputBufferMaxBytes",
893        skip_serializing_if = "Option::is_none"
894    )]
895    pub output_buffer_max_bytes: Option<u64>,
896    #[serde(
897        default,
898        rename = "executionTimeoutMs",
899        skip_serializing_if = "Option::is_none"
900    )]
901    pub execution_timeout_ms: Option<u64>,
902    #[serde(
903        default,
904        rename = "maxOldSpaceMb",
905        skip_serializing_if = "Option::is_none"
906    )]
907    pub max_old_space_mb: Option<u64>,
908    #[serde(
909        default,
910        rename = "vfsRpcTimeoutMs",
911        skip_serializing_if = "Option::is_none"
912    )]
913    pub vfs_rpc_timeout_ms: Option<u64>,
914}
915
916#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
917#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
918#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
919#[serde(deny_unknown_fields)]
920pub struct WasmLimits {
921    #[serde(
922        default,
923        rename = "maxModuleFileBytes",
924        skip_serializing_if = "Option::is_none"
925    )]
926    pub max_module_file_bytes: Option<u64>,
927    #[serde(
928        default,
929        rename = "capturedOutputLimitBytes",
930        skip_serializing_if = "Option::is_none"
931    )]
932    pub captured_output_limit_bytes: Option<u64>,
933    #[serde(
934        default,
935        rename = "syncReadLimitBytes",
936        skip_serializing_if = "Option::is_none"
937    )]
938    pub sync_read_limit_bytes: Option<u64>,
939    #[serde(
940        default,
941        rename = "prewarmTimeoutMs",
942        skip_serializing_if = "Option::is_none"
943    )]
944    pub prewarm_timeout_ms: Option<u64>,
945    #[serde(
946        default,
947        rename = "runnerHeapLimitMb",
948        skip_serializing_if = "Option::is_none"
949    )]
950    pub runner_heap_limit_mb: Option<u64>,
951    #[serde(
952        default,
953        rename = "runnerCpuTimeLimitMs",
954        skip_serializing_if = "Option::is_none"
955    )]
956    pub runner_cpu_time_limit_ms: Option<u64>,
957}
958
959#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
960#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
961#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
962#[serde(deny_unknown_fields)]
963pub struct ProcessLimits {
964    #[serde(
965        default,
966        rename = "maxSpawnFileActions",
967        skip_serializing_if = "Option::is_none"
968    )]
969    pub max_spawn_file_actions: Option<u64>,
970    #[serde(
971        default,
972        rename = "maxSpawnFileActionBytes",
973        skip_serializing_if = "Option::is_none"
974    )]
975    pub max_spawn_file_action_bytes: Option<u64>,
976    #[serde(
977        default,
978        rename = "pendingStdinBytes",
979        skip_serializing_if = "Option::is_none"
980    )]
981    pub pending_stdin_bytes: Option<u64>,
982    #[serde(
983        default,
984        rename = "pendingEventCount",
985        skip_serializing_if = "Option::is_none"
986    )]
987    pub pending_event_count: Option<u64>,
988    #[serde(
989        default,
990        rename = "pendingEventBytes",
991        skip_serializing_if = "Option::is_none"
992    )]
993    pub pending_event_bytes: Option<u64>,
994}
995
996// ---------------------------------------------------------------------------
997// Permissions tree (runtime.ts)
998// ---------------------------------------------------------------------------
999
1000/// Top-level permission policy. All domains optional (`allowAll` when omitted).
1001#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1002#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
1003#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(deny_unknown_fields)]
1005pub struct Permissions {
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub fs: Option<FsPermissions>,
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub network: Option<PatternPermissions>,
1010    #[serde(
1011        default,
1012        rename = "childProcess",
1013        skip_serializing_if = "Option::is_none"
1014    )]
1015    pub child_process: Option<PatternPermissions>,
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub process: Option<PatternPermissions>,
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub env: Option<PatternPermissions>,
1020    #[serde(
1021        default,
1022        rename = "hostFunction",
1023        skip_serializing_if = "Option::is_none"
1024    )]
1025    pub host_function: Option<PatternPermissions>,
1026}
1027
1028/// `"allow"` or `"deny"`.
1029#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1031#[serde(rename_all = "lowercase")]
1032pub enum PermissionMode {
1033    Allow,
1034    Deny,
1035}
1036
1037/// `PermissionMode | RulePermissions<FsPermissionRule>`.
1038#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(untagged)]
1041pub enum FsPermissions {
1042    Mode(PermissionMode),
1043    Rules(RulePermissions<FsPermissionRule>),
1044}
1045
1046/// `PermissionMode | RulePermissions<PatternPermissionRule>` (network/childProcess/process/env/binding).
1047#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1048#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1049#[serde(untagged)]
1050pub enum PatternPermissions {
1051    Mode(PermissionMode),
1052    Rules(RulePermissions<PatternPermissionRule>),
1053}
1054
1055/// `{ default?: PermissionMode; rules: T[] }`.
1056#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1057#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1059#[serde(deny_unknown_fields)]
1060pub struct RulePermissions<T> {
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub default: Option<PermissionMode>,
1063    pub rules: Vec<T>,
1064}
1065
1066/// `{ mode; operations?; paths? }`.
1067#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1068#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(deny_unknown_fields)]
1071pub struct FsPermissionRule {
1072    pub mode: PermissionMode,
1073    #[serde(default, skip_serializing_if = "Option::is_none")]
1074    pub operations: Option<Vec<String>>,
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub paths: Option<Vec<String>>,
1077}
1078
1079/// `{ mode; operations?; patterns? }`.
1080#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1081#[cfg_attr(feature = "contract", ts(rename_all = "camelCase"))]
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1083#[serde(deny_unknown_fields)]
1084pub struct PatternPermissionRule {
1085    pub mode: PermissionMode,
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub operations: Option<Vec<String>>,
1088    #[serde(default, skip_serializing_if = "Option::is_none")]
1089    pub patterns: Option<Vec<String>>,
1090}
1091
1092// ---------------------------------------------------------------------------
1093// Root filesystem (layers.ts / overlay-filesystem.ts)
1094// ---------------------------------------------------------------------------
1095
1096/// Root filesystem configuration. Default: overlay + bundled base snapshot.
1097#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1099pub struct RootFilesystemConfig {
1100    #[serde(default, rename = "type")]
1101    pub kind: RootFilesystemKind,
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub mode: Option<RootFilesystemMode>,
1104    #[serde(
1105        default,
1106        rename = "nativePlugin",
1107        skip_serializing_if = "Option::is_none"
1108    )]
1109    pub native_plugin: Option<MountPlugin>,
1110    #[serde(default, rename = "disableDefaultBaseLayer")]
1111    pub disable_default_base_layer: bool,
1112    #[serde(default)]
1113    pub lowers: Vec<RootLowerInput>,
1114}
1115
1116impl Default for RootFilesystemConfig {
1117    fn default() -> Self {
1118        Self {
1119            kind: RootFilesystemKind::Overlay,
1120            mode: None,
1121            native_plugin: None,
1122            disable_default_base_layer: false,
1123            lowers: Vec::new(),
1124        }
1125    }
1126}
1127
1128/// The root filesystem kind.
1129#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1131#[serde(rename_all = "lowercase")]
1132pub enum RootFilesystemKind {
1133    #[default]
1134    Overlay,
1135    Native,
1136}
1137
1138/// Root filesystem mode.
1139#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1141#[serde(rename_all = "kebab-case")]
1142pub enum RootFilesystemMode {
1143    Ephemeral,
1144    ReadOnly,
1145}
1146
1147/// A lower (immutable) snapshot layer input.
1148#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1150#[serde(tag = "kind", rename_all = "kebab-case")]
1151pub enum RootLowerInput {
1152    /// The bundled base filesystem snapshot.
1153    BundledBaseFilesystem,
1154    /// A snapshot export (`{ kind: "snapshot-export", source }`).
1155    #[serde(untagged)]
1156    SnapshotExport(crate::fs::RootSnapshotExport),
1157}
1158
1159// ---------------------------------------------------------------------------
1160// Mounts
1161// ---------------------------------------------------------------------------
1162
1163/// A filesystem mount. `Plain.driver` is an in-process trait object and cannot cross the wire.
1164pub enum MountConfig {
1165    /// Plain mount over an in-process [`VirtualFileSystem`] driver.
1166    Plain {
1167        path: String,
1168        driver: Arc<dyn VirtualFileSystem>,
1169        guest_source: Option<String>,
1170        guest_fstype: Option<String>,
1171        read_only: bool,
1172    },
1173    /// Native plugin mount (`{ id; config? }`).
1174    Native {
1175        path: String,
1176        plugin: MountPlugin,
1177        guest_source: Option<String>,
1178        guest_fstype: Option<String>,
1179        read_only: bool,
1180    },
1181    /// Overlay mount (`{ type: "overlay"; store; mode?; lowers }`).
1182    Overlay {
1183        path: String,
1184        filesystem: OverlayMountConfig,
1185    },
1186}
1187
1188/// A native mount plugin descriptor.
1189#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct MountPlugin {
1192    pub id: String,
1193    #[serde(default, skip_serializing_if = "Option::is_none")]
1194    pub config: Option<serde_json::Value>,
1195}
1196
1197/// Mount a host `node_modules` directory into the VM at `/root/node_modules`.
1198///
1199/// Rust mirror of TS `nodeModulesMount(...)` (`packages/core/src/host-dir-mount.ts`).
1200/// This is the explicit, mount-based replacement for the removed `moduleAccessCwd`
1201/// option: the guest module resolver reads the mounted tree through the kernel VFS,
1202/// so the caller supplies exactly the `node_modules` directory whose packages should
1203/// be resolvable in the guest. The mount is read-only.
1204pub fn node_modules_mount(host_node_modules_dir: impl Into<String>) -> MountConfig {
1205    MountConfig::Native {
1206        path: "/root/node_modules".to_string(),
1207        plugin: MountPlugin {
1208            id: "host_dir".to_string(),
1209            config: Some(serde_json::json!({
1210                "hostPath": host_node_modules_dir.into(),
1211                "readOnly": true,
1212            })),
1213        },
1214        guest_source: Some(String::from("host_dir")),
1215        guest_fstype: Some(String::from("host_dir")),
1216        read_only: true,
1217    }
1218}
1219
1220/// Overlay mount filesystem config.
1221#[cfg_attr(feature = "contract", derive(ts_rs::TS))]
1222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1223pub struct OverlayMountConfig {
1224    #[serde(rename = "type")]
1225    pub kind: String,
1226    pub store: serde_json::Value,
1227    #[serde(default, skip_serializing_if = "Option::is_none")]
1228    pub mode: Option<RootFilesystemMode>,
1229    pub lowers: Vec<RootLowerInput>,
1230}
1231
1232// ---------------------------------------------------------------------------
1233// Sidecar config
1234// ---------------------------------------------------------------------------
1235
1236/// How the client obtains its sidecar handle.
1237pub enum AgentOsSidecarConfig {
1238    /// Use (or create) a shared pooled sidecar (`pool` default `"default"`).
1239    Shared { pool: Option<String> },
1240    /// Use an explicit sidecar handle.
1241    Explicit {
1242        handle: Arc<crate::sidecar::AgentOsSidecar>,
1243    },
1244}
1245
1246// ---------------------------------------------------------------------------
1247// Schedule driver
1248// ---------------------------------------------------------------------------
1249
1250/// The callback fired by a [`ScheduleDriver`] when a schedule entry triggers.
1251///
1252/// Mirrors the TS `ScheduleEntry.callback: () => void | Promise<void>`. The cron manager passes a
1253/// closure that runs one job execution; the driver awaits it (and, for the default driver, reschedules
1254/// the next cron fire afterwards).
1255pub type ScheduleCallback = Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
1256
1257/// A schedule entry handed to a [`ScheduleDriver`]. Mirrors TS `ScheduleEntry`
1258/// (`cron/schedule-driver.ts`).
1259#[derive(Clone)]
1260pub struct ScheduleEntry {
1261    /// Unique ID for this job.
1262    pub id: String,
1263    /// 5/6/7-field cron expression OR an ISO-8601 one-shot timestamp.
1264    pub schedule: String,
1265    /// Called when the schedule fires.
1266    pub callback: ScheduleCallback,
1267}
1268
1269/// Driver-owned scheduling abstraction. Mirrors the TS `ScheduleDriver` interface
1270/// (`cron/schedule-driver.ts`) exactly: the driver parses the schedule, arms the timer, reschedules
1271/// cron entries after each fire, and tears everything down on [`ScheduleDriver::dispose`]. This is the
1272/// documented extension point: a custom driver (deterministic virtual-time test driver, fire-immediately
1273/// driver, etc.) fully controls timing.
1274pub trait ScheduleDriver: Send + Sync {
1275    /// Schedule a callback to fire on a cron expression or at a specific time. Returns a cancellation
1276    /// handle.
1277    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle;
1278
1279    /// Cancel a previously scheduled entry.
1280    fn cancel(&self, handle: &ScheduleHandle);
1281
1282    /// Tear down all scheduled work.
1283    fn dispose(&self);
1284}
1285
1286/// Handle to a scheduled entry. Mirrors TS `ScheduleHandle { id }`. Identifies the entry to cancel via
1287/// [`ScheduleDriver::cancel`].
1288#[derive(Clone)]
1289pub struct ScheduleHandle {
1290    pub id: String,
1291}
1292
1293/// Default schedule driver backed by `tokio` timers and the system clock.
1294///
1295/// Mirrors the TS `TimerScheduleDriver`: for cron expressions it computes the next fire time and arms
1296/// a single timer, rescheduling after each fire; for one-shot timestamps it fires once and removes the
1297/// entry. Driver-held timer tasks are tracked so [`ScheduleDriver::cancel`] / [`ScheduleDriver::dispose`]
1298/// can abort them.
1299#[derive(Default)]
1300pub struct TimerScheduleDriver {
1301    timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
1302}
1303
1304impl TimerScheduleDriver {
1305    pub fn new() -> Self {
1306        Self {
1307            timers: Arc::new(scc::HashMap::new()),
1308        }
1309    }
1310
1311    /// Arm the next fire for `entry`. For a one-shot or an exhausted cron the entry is dropped. For a
1312    /// recurring cron the timer reschedules itself after firing the callback. `cancel` is the per-entry
1313    /// cancellation token shared with the registry slot.
1314    fn schedule_next(
1315        timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
1316        entry: ScheduleEntry,
1317        cancel: tokio_util::sync::CancellationToken,
1318    ) {
1319        let now = chrono::Utc::now();
1320        let parsed = match crate::cron::parse_schedule(&entry.schedule) {
1321            Ok(parsed) => parsed,
1322            Err(_) => {
1323                let _ = timers.remove(&entry.id);
1324                return;
1325            }
1326        };
1327        let is_cron = parsed.is_cron();
1328        let next = match crate::cron::resolve_next_run(&parsed, now) {
1329            Some(next) => next,
1330            None => {
1331                // No upcoming run (one-shot in the past, or exhausted cron).
1332                let _ = timers.remove(&entry.id);
1333                return;
1334            }
1335        };
1336
1337        let delay = (next - now).to_std().unwrap_or(std::time::Duration::ZERO);
1338
1339        tokio::spawn(async move {
1340            tokio::select! {
1341                _ = cancel.cancelled() => {
1342                    return;
1343                }
1344                _ = tokio::time::sleep(delay) => {}
1345            }
1346            if cancel.is_cancelled() {
1347                return;
1348            }
1349            // The driver is fire-and-forget; errors are the caller's responsibility.
1350            (entry.callback)().await;
1351
1352            if is_cron && timers.contains(&entry.id) {
1353                Self::schedule_next(Arc::clone(&timers), entry, cancel);
1354            } else {
1355                let _ = timers.remove(&entry.id);
1356            }
1357        });
1358    }
1359}
1360
1361impl ScheduleDriver for TimerScheduleDriver {
1362    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
1363        let id = entry.id.clone();
1364        let cancel = tokio_util::sync::CancellationToken::new();
1365        // Replace any existing timer for this id, cancelling it first.
1366        if let Some((_, old)) = self.timers.remove(&id) {
1367            old.cancel();
1368        }
1369        let _ = self.timers.insert(id.clone(), cancel.clone());
1370
1371        Self::schedule_next(Arc::clone(&self.timers), entry, cancel);
1372
1373        ScheduleHandle { id }
1374    }
1375
1376    fn cancel(&self, handle: &ScheduleHandle) {
1377        if let Some((_, cancel)) = self.timers.remove(&handle.id) {
1378            cancel.cancel();
1379        }
1380    }
1381
1382    fn dispose(&self) {
1383        self.timers.scan(|_, cancel| cancel.cancel());
1384        self.timers.clear();
1385    }
1386}