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