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::sync::Arc;
12
13use serde::{Deserialize, Serialize};
14
15use crate::fs::VirtualFileSystem;
16
17/// Resolved client options (= TS `AgentOsOptions`). All fields optional with documented defaults.
18///
19/// Keep this Rust mirror in sync with `packages/core/src/agent-os.ts::AgentOsOptions`
20/// and `packages/core/src/options-schema.ts::agentOsOptionsSchema`.
21#[derive(Default)]
22pub struct AgentOsConfig {
23    /// Software packages to install (flattened). Default `[]`.
24    pub software: Vec<SoftwareInput>,
25    /// Package directories to project into the VM's `/opt/agentos` tree (the
26    /// secure-exec package projection). Each entry is a host dir containing an
27    /// `agentos-package.json` manifest + the package payload. Default `[]`.
28    pub packages: Vec<PackageRef>,
29    /// Guest mount point for the package projection. Default `/opt/agentos`
30    /// (secure-exec's `OPT_AGENTOS_ROOT`) when `None`.
31    pub packages_mount_at: Option<String>,
32    /// Loopback ports exempt from the default outbound-to-host block.
33    pub loopback_exempt_ports: Vec<u16>,
34    /// Allowed Node.js builtins. Default: the hardened native-bridge set.
35    pub allowed_node_builtins: Option<Vec<String>>,
36    /// Working directory used for guest module resolution. Default: host cwd.
37    pub module_access_cwd: Option<String>,
38    /// Root filesystem configuration. Default: overlay + bundled base snapshot.
39    pub root_filesystem: RootFilesystemConfig,
40    /// Additional mounts.
41    pub mounts: Vec<MountConfig>,
42    /// Extra OS instructions appended to agent sessions.
43    pub additional_instructions: Option<String>,
44    /// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`].
45    pub schedule_driver: Option<Arc<dyn ScheduleDriver>>,
46    /// Tool kits to register.
47    pub tool_kits: Vec<ToolKit>,
48    /// Rust-only sidecar callback handler for `js_bridge`-style plugin requests.
49    pub sidecar_js_bridge_callback: Option<SidecarJsBridgeCallback>,
50    /// Permission policy. Default: allow-all.
51    pub permissions: Option<Permissions>,
52    /// Operator-tunable VM limits. Default: sidecar/kernel built-ins.
53    pub limits: Option<AgentOsLimits>,
54    /// Sidecar placement/config. Default: shared `default` pool.
55    pub sidecar: Option<AgentOsSidecarConfig>,
56    /// Absolute path to the `agentos-sidecar` binary, resolved from the npm
57    /// package on the TypeScript side. Threaded to `SidecarProcess::spawn`
58    /// (mirroring rivetkit's `engine_binary_path`) instead of relying on the
59    /// `AGENTOS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`.
60    pub sidecar_binary_path: Option<String>,
61}
62
63/// Builder for [`AgentOsConfig`].
64#[derive(Default)]
65pub struct AgentOsConfigBuilder {
66    config: AgentOsConfig,
67}
68
69impl AgentOsConfigBuilder {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    pub fn packages(mut self, packages: Vec<PackageRef>) -> Self {
75        self.config.packages = packages;
76        self
77    }
78
79    pub fn packages_mount_at(mut self, mount_at: impl Into<String>) -> Self {
80        self.config.packages_mount_at = Some(mount_at.into());
81        self
82    }
83
84    pub fn loopback_exempt_ports(mut self, ports: Vec<u16>) -> Self {
85        self.config.loopback_exempt_ports = ports;
86        self
87    }
88
89    pub fn allowed_node_builtins(mut self, builtins: Vec<String>) -> Self {
90        self.config.allowed_node_builtins = Some(builtins);
91        self
92    }
93
94    pub fn module_access_cwd(mut self, cwd: impl Into<String>) -> Self {
95        self.config.module_access_cwd = Some(cwd.into());
96        self
97    }
98
99    pub fn root_filesystem(mut self, root: RootFilesystemConfig) -> Self {
100        self.config.root_filesystem = root;
101        self
102    }
103
104    pub fn mounts(mut self, mounts: Vec<MountConfig>) -> Self {
105        self.config.mounts = mounts;
106        self
107    }
108
109    pub fn additional_instructions(mut self, instructions: impl Into<String>) -> Self {
110        self.config.additional_instructions = Some(instructions.into());
111        self
112    }
113
114    pub fn schedule_driver(mut self, driver: Arc<dyn ScheduleDriver>) -> Self {
115        self.config.schedule_driver = Some(driver);
116        self
117    }
118
119    pub fn tool_kits(mut self, tool_kits: Vec<ToolKit>) -> Self {
120        self.config.tool_kits = tool_kits;
121        self
122    }
123
124    pub fn sidecar_js_bridge_callback(mut self, callback: SidecarJsBridgeCallback) -> Self {
125        self.config.sidecar_js_bridge_callback = Some(callback);
126        self
127    }
128
129    pub fn permissions(mut self, permissions: Permissions) -> Self {
130        self.config.permissions = Some(permissions);
131        self
132    }
133
134    pub fn limits(mut self, limits: AgentOsLimits) -> Self {
135        self.config.limits = Some(limits);
136        self
137    }
138
139    pub fn sidecar(mut self, sidecar: AgentOsSidecarConfig) -> Self {
140        self.config.sidecar = Some(sidecar);
141        self
142    }
143
144    pub fn sidecar_binary_path(mut self, path: impl Into<String>) -> Self {
145        self.config.sidecar_binary_path = Some(path.into());
146        self
147    }
148
149    pub fn build(self) -> AgentOsConfig {
150        self.config
151    }
152}
153
154/// The kind of a software package, which decides how it is mounted into the VM. Mirrors the TS
155/// descriptor `type` discriminator (`packages/core/src/packages.ts`).
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub enum SoftwareKind {
159    /// A directory of wasm command binaries. Mounted at `/__secure_exec/commands/{index}/` so the
160    /// sidecar's command discovery can resolve guest commands (`echo`, `sh`, `grep`, ...).
161    #[default]
162    WasmCommands,
163    /// An agent SDK/adapter package. Not mounted as a command directory.
164    Agent,
165    /// A host-tool package. Not mounted as a command directory.
166    Tool,
167}
168
169/// A flattened software package input.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct SoftwareInput {
172    pub package: String,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub version: Option<String>,
175    /// How the package is mounted into the VM. Defaults to [`SoftwareKind::WasmCommands`].
176    #[serde(default)]
177    pub kind: SoftwareKind,
178}
179
180/// A reference to a package directory for the `/opt/agentos` projection. The dir
181/// holds an `agentos-package.json` manifest (name + optional agent block) and the
182/// package payload; the sidecar reads commands/version from it at projection time.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct PackageRef {
185    pub dir: String,
186}
187
188/// A host-side tool execute callback. Receives the validated JSON input, returns a JSON result or an
189/// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the
190/// sidecar host-callback channel.
191pub type ToolCallback = Arc<
192    dyn Fn(
193            serde_json::Value,
194        ) -> futures::future::BoxFuture<'static, Result<serde_json::Value, String>>
195        + Send
196        + Sync,
197>;
198
199/// A sidecar-initiated `js_bridge`-style filesystem callback.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SidecarJsBridgeCall {
202    pub call_id: String,
203    pub mount_id: String,
204    pub operation: String,
205    pub args: serde_json::Value,
206}
207
208/// Host-side handler for sidecar `JsBridgeCallRequest` payloads.
209///
210/// This is Rust-only and intentionally not JSON-serializable. RivetKit uses it to bind a native
211/// sidecar root filesystem to actor-owned SQLite (`ctx.db_*`) without teaching secure-exec about
212/// Rivet actors.
213pub type SidecarJsBridgeCallback = Arc<
214    dyn Fn(
215            SidecarJsBridgeCall,
216        )
217            -> futures::future::BoxFuture<'static, Result<Option<serde_json::Value>, String>>
218        + Send
219        + Sync,
220>;
221
222/// A single host tool within a [`ToolKit`].
223#[derive(Clone)]
224pub struct HostTool {
225    pub name: String,
226    pub description: String,
227    /// JSON Schema for the tool input (forwarded to the sidecar `register_host_callbacks` definition).
228    pub input_schema: serde_json::Value,
229    pub timeout_ms: Option<u64>,
230    /// Host-side implementation, invoked when the guest calls `<toolkit>:<tool>`.
231    pub execute: ToolCallback,
232}
233
234/// A registered tool kit (in-process; tool implementations stay host-side). Tools are exposed to the
235/// guest as `<toolkit>:<tool>` and dispatched back to [`HostTool::execute`] via the sidecar
236/// host-callback channel.
237#[derive(Clone)]
238pub struct ToolKit {
239    pub name: String,
240    pub description: String,
241    pub tools: Vec<HostTool>,
242}
243
244// ---------------------------------------------------------------------------
245// VM limits (agent-os.ts AgentOsLimits / sidecar/limits.ts)
246// ---------------------------------------------------------------------------
247
248/// Operator-tunable runtime limits for a VM. Every field is optional; unset fields fall back to the
249/// sidecar defaults.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
251pub struct AgentOsLimits {
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub resources: Option<ResourceLimits>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub http: Option<HttpLimits>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub tools: Option<ToolLimits>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub plugins: Option<PluginLimits>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub acp: Option<AcpLimits>,
262    #[serde(default, rename = "jsRuntime", skip_serializing_if = "Option::is_none")]
263    pub js_runtime: Option<JsRuntimeLimits>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub python: Option<PythonLimits>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub wasm: Option<WasmLimits>,
268}
269
270#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
271pub struct ResourceLimits {
272    #[serde(default, rename = "cpuCount", skip_serializing_if = "Option::is_none")]
273    pub cpu_count: Option<u64>,
274    #[serde(
275        default,
276        rename = "maxProcesses",
277        skip_serializing_if = "Option::is_none"
278    )]
279    pub max_processes: Option<u64>,
280    #[serde(
281        default,
282        rename = "maxOpenFds",
283        skip_serializing_if = "Option::is_none"
284    )]
285    pub max_open_fds: Option<u64>,
286    #[serde(default, rename = "maxPipes", skip_serializing_if = "Option::is_none")]
287    pub max_pipes: Option<u64>,
288    #[serde(default, rename = "maxPtys", skip_serializing_if = "Option::is_none")]
289    pub max_ptys: Option<u64>,
290    #[serde(
291        default,
292        rename = "maxSockets",
293        skip_serializing_if = "Option::is_none"
294    )]
295    pub max_sockets: Option<u64>,
296    #[serde(
297        default,
298        rename = "maxConnections",
299        skip_serializing_if = "Option::is_none"
300    )]
301    pub max_connections: Option<u64>,
302    #[serde(
303        default,
304        rename = "maxSocketBufferedBytes",
305        skip_serializing_if = "Option::is_none"
306    )]
307    pub max_socket_buffered_bytes: Option<u64>,
308    #[serde(
309        default,
310        rename = "maxSocketDatagramQueueLen",
311        skip_serializing_if = "Option::is_none"
312    )]
313    pub max_socket_datagram_queue_len: Option<u64>,
314    #[serde(
315        default,
316        rename = "maxFilesystemBytes",
317        skip_serializing_if = "Option::is_none"
318    )]
319    pub max_filesystem_bytes: Option<u64>,
320    #[serde(
321        default,
322        rename = "maxInodeCount",
323        skip_serializing_if = "Option::is_none"
324    )]
325    pub max_inode_count: Option<u64>,
326    #[serde(
327        default,
328        rename = "maxBlockingReadMs",
329        skip_serializing_if = "Option::is_none"
330    )]
331    pub max_blocking_read_ms: Option<u64>,
332    #[serde(
333        default,
334        rename = "maxPreadBytes",
335        skip_serializing_if = "Option::is_none"
336    )]
337    pub max_pread_bytes: Option<u64>,
338    #[serde(
339        default,
340        rename = "maxFdWriteBytes",
341        skip_serializing_if = "Option::is_none"
342    )]
343    pub max_fd_write_bytes: Option<u64>,
344    #[serde(
345        default,
346        rename = "maxProcessArgvBytes",
347        skip_serializing_if = "Option::is_none"
348    )]
349    pub max_process_argv_bytes: Option<u64>,
350    #[serde(
351        default,
352        rename = "maxProcessEnvBytes",
353        skip_serializing_if = "Option::is_none"
354    )]
355    pub max_process_env_bytes: Option<u64>,
356    #[serde(
357        default,
358        rename = "maxReaddirEntries",
359        skip_serializing_if = "Option::is_none"
360    )]
361    pub max_readdir_entries: Option<u64>,
362    #[serde(
363        default,
364        rename = "maxWasmFuel",
365        skip_serializing_if = "Option::is_none"
366    )]
367    pub max_wasm_fuel: Option<u64>,
368    #[serde(
369        default,
370        rename = "maxWasmMemoryBytes",
371        skip_serializing_if = "Option::is_none"
372    )]
373    pub max_wasm_memory_bytes: Option<u64>,
374    #[serde(
375        default,
376        rename = "maxWasmStackBytes",
377        skip_serializing_if = "Option::is_none"
378    )]
379    pub max_wasm_stack_bytes: Option<u64>,
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub struct HttpLimits {
384    #[serde(
385        default,
386        rename = "maxFetchResponseBytes",
387        skip_serializing_if = "Option::is_none"
388    )]
389    pub max_fetch_response_bytes: Option<u64>,
390}
391
392#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
393pub struct ToolLimits {
394    #[serde(
395        default,
396        rename = "defaultToolTimeoutMs",
397        skip_serializing_if = "Option::is_none"
398    )]
399    pub default_tool_timeout_ms: Option<u64>,
400    #[serde(
401        default,
402        rename = "maxToolTimeoutMs",
403        skip_serializing_if = "Option::is_none"
404    )]
405    pub max_tool_timeout_ms: Option<u64>,
406    #[serde(
407        default,
408        rename = "maxRegisteredToolkits",
409        skip_serializing_if = "Option::is_none"
410    )]
411    pub max_registered_toolkits: Option<u64>,
412    #[serde(
413        default,
414        rename = "maxRegisteredToolsPerVm",
415        skip_serializing_if = "Option::is_none"
416    )]
417    pub max_registered_tools_per_vm: Option<u64>,
418    #[serde(
419        default,
420        rename = "maxToolsPerToolkit",
421        skip_serializing_if = "Option::is_none"
422    )]
423    pub max_tools_per_toolkit: Option<u64>,
424    #[serde(
425        default,
426        rename = "maxToolSchemaBytes",
427        skip_serializing_if = "Option::is_none"
428    )]
429    pub max_tool_schema_bytes: Option<u64>,
430    #[serde(
431        default,
432        rename = "maxToolExamplesPerTool",
433        skip_serializing_if = "Option::is_none"
434    )]
435    pub max_tool_examples_per_tool: Option<u64>,
436    #[serde(
437        default,
438        rename = "maxToolExampleInputBytes",
439        skip_serializing_if = "Option::is_none"
440    )]
441    pub max_tool_example_input_bytes: Option<u64>,
442}
443
444#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
445pub struct PluginLimits {
446    #[serde(
447        default,
448        rename = "maxPersistedManifestBytes",
449        skip_serializing_if = "Option::is_none"
450    )]
451    pub max_persisted_manifest_bytes: Option<u64>,
452    #[serde(
453        default,
454        rename = "maxPersistedManifestFileBytes",
455        skip_serializing_if = "Option::is_none"
456    )]
457    pub max_persisted_manifest_file_bytes: Option<u64>,
458}
459
460#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
461pub struct AcpLimits {
462    #[serde(
463        default,
464        rename = "maxReadLineBytes",
465        skip_serializing_if = "Option::is_none"
466    )]
467    pub max_read_line_bytes: Option<u64>,
468    #[serde(
469        default,
470        rename = "stdoutBufferByteLimit",
471        skip_serializing_if = "Option::is_none"
472    )]
473    pub stdout_buffer_byte_limit: Option<u64>,
474}
475
476#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
477pub struct JsRuntimeLimits {
478    #[serde(
479        default,
480        rename = "v8HeapLimitMb",
481        skip_serializing_if = "Option::is_none"
482    )]
483    pub v8_heap_limit_mb: Option<u64>,
484    #[serde(
485        default,
486        rename = "capturedOutputLimitBytes",
487        skip_serializing_if = "Option::is_none"
488    )]
489    pub captured_output_limit_bytes: Option<u64>,
490    #[serde(
491        default,
492        rename = "stdinBufferLimitBytes",
493        skip_serializing_if = "Option::is_none"
494    )]
495    pub stdin_buffer_limit_bytes: Option<u64>,
496    #[serde(
497        default,
498        rename = "eventPayloadLimitBytes",
499        skip_serializing_if = "Option::is_none"
500    )]
501    pub event_payload_limit_bytes: Option<u64>,
502    #[serde(
503        default,
504        rename = "v8IpcMaxFrameBytes",
505        skip_serializing_if = "Option::is_none"
506    )]
507    pub v8_ipc_max_frame_bytes: Option<u64>,
508}
509
510#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
511pub struct PythonLimits {
512    #[serde(
513        default,
514        rename = "outputBufferMaxBytes",
515        skip_serializing_if = "Option::is_none"
516    )]
517    pub output_buffer_max_bytes: Option<u64>,
518    #[serde(
519        default,
520        rename = "executionTimeoutMs",
521        skip_serializing_if = "Option::is_none"
522    )]
523    pub execution_timeout_ms: Option<u64>,
524    #[serde(
525        default,
526        rename = "vfsRpcTimeoutMs",
527        skip_serializing_if = "Option::is_none"
528    )]
529    pub vfs_rpc_timeout_ms: Option<u64>,
530}
531
532#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
533pub struct WasmLimits {
534    #[serde(
535        default,
536        rename = "maxModuleFileBytes",
537        skip_serializing_if = "Option::is_none"
538    )]
539    pub max_module_file_bytes: Option<u64>,
540    #[serde(
541        default,
542        rename = "capturedOutputLimitBytes",
543        skip_serializing_if = "Option::is_none"
544    )]
545    pub captured_output_limit_bytes: Option<u64>,
546    #[serde(
547        default,
548        rename = "syncReadLimitBytes",
549        skip_serializing_if = "Option::is_none"
550    )]
551    pub sync_read_limit_bytes: Option<u64>,
552}
553
554// ---------------------------------------------------------------------------
555// Permissions tree (runtime.ts)
556// ---------------------------------------------------------------------------
557
558/// Top-level permission policy. All domains optional (`allowAll` when omitted).
559#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
560pub struct Permissions {
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub fs: Option<FsPermissions>,
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub network: Option<PatternPermissions>,
565    #[serde(
566        default,
567        rename = "childProcess",
568        skip_serializing_if = "Option::is_none"
569    )]
570    pub child_process: Option<PatternPermissions>,
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub process: Option<PatternPermissions>,
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub env: Option<PatternPermissions>,
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub binding: Option<PatternPermissions>,
577}
578
579/// `"allow"` or `"deny"`.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "lowercase")]
582pub enum PermissionMode {
583    Allow,
584    Deny,
585}
586
587/// `PermissionMode | RulePermissions<FsPermissionRule>`.
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(untagged)]
590pub enum FsPermissions {
591    Mode(PermissionMode),
592    Rules(RulePermissions<FsPermissionRule>),
593}
594
595/// `PermissionMode | RulePermissions<PatternPermissionRule>` (network/childProcess/process/env/binding).
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(untagged)]
598pub enum PatternPermissions {
599    Mode(PermissionMode),
600    Rules(RulePermissions<PatternPermissionRule>),
601}
602
603/// `{ default?: PermissionMode; rules: T[] }`.
604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
605pub struct RulePermissions<T> {
606    #[serde(default, skip_serializing_if = "Option::is_none")]
607    pub default: Option<PermissionMode>,
608    pub rules: Vec<T>,
609}
610
611/// `{ mode; operations?; paths? }`.
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct FsPermissionRule {
614    pub mode: PermissionMode,
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub operations: Option<Vec<String>>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub paths: Option<Vec<String>>,
619}
620
621/// `{ mode; operations?; patterns? }`.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct PatternPermissionRule {
624    pub mode: PermissionMode,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub operations: Option<Vec<String>>,
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub patterns: Option<Vec<String>>,
629}
630
631// ---------------------------------------------------------------------------
632// Root filesystem (layers.ts / overlay-filesystem.ts)
633// ---------------------------------------------------------------------------
634
635/// Root filesystem configuration. Default: overlay + bundled base snapshot.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637pub struct RootFilesystemConfig {
638    #[serde(default, rename = "type")]
639    pub kind: RootFilesystemKind,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub mode: Option<RootFilesystemMode>,
642    #[serde(
643        default,
644        rename = "nativePlugin",
645        skip_serializing_if = "Option::is_none"
646    )]
647    pub native_plugin: Option<MountPlugin>,
648    #[serde(default, rename = "disableDefaultBaseLayer")]
649    pub disable_default_base_layer: bool,
650    #[serde(default)]
651    pub lowers: Vec<RootLowerInput>,
652}
653
654impl Default for RootFilesystemConfig {
655    fn default() -> Self {
656        Self {
657            kind: RootFilesystemKind::Overlay,
658            mode: None,
659            native_plugin: None,
660            disable_default_base_layer: false,
661            lowers: Vec::new(),
662        }
663    }
664}
665
666/// The root filesystem kind.
667#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
668#[serde(rename_all = "lowercase")]
669pub enum RootFilesystemKind {
670    #[default]
671    Overlay,
672    Native,
673}
674
675/// Root filesystem mode.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "kebab-case")]
678pub enum RootFilesystemMode {
679    Ephemeral,
680    ReadOnly,
681}
682
683/// A lower (immutable) snapshot layer input.
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(tag = "kind", rename_all = "kebab-case")]
686pub enum RootLowerInput {
687    /// The bundled base filesystem snapshot.
688    BundledBaseFilesystem,
689    /// A snapshot export (`{ kind: "snapshot-export", source }`).
690    #[serde(untagged)]
691    SnapshotExport(crate::fs::RootSnapshotExport),
692}
693
694// ---------------------------------------------------------------------------
695// Mounts
696// ---------------------------------------------------------------------------
697
698/// A filesystem mount. `Plain.driver` is an in-process trait object and cannot cross the wire.
699pub enum MountConfig {
700    /// Plain mount over an in-process [`VirtualFileSystem`] driver.
701    Plain {
702        path: String,
703        driver: Arc<dyn VirtualFileSystem>,
704        read_only: bool,
705    },
706    /// Native plugin mount (`{ id; config? }`).
707    Native {
708        path: String,
709        plugin: MountPlugin,
710        read_only: bool,
711    },
712    /// Overlay mount (`{ type: "overlay"; store; mode?; lowers }`).
713    Overlay {
714        path: String,
715        filesystem: OverlayMountConfig,
716    },
717}
718
719/// A native mount plugin descriptor.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct MountPlugin {
722    pub id: String,
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub config: Option<serde_json::Value>,
725}
726
727/// Overlay mount filesystem config.
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729pub struct OverlayMountConfig {
730    #[serde(rename = "type")]
731    pub kind: String,
732    pub store: serde_json::Value,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub mode: Option<RootFilesystemMode>,
735    pub lowers: Vec<RootLowerInput>,
736}
737
738// ---------------------------------------------------------------------------
739// Sidecar config
740// ---------------------------------------------------------------------------
741
742/// How the client obtains its sidecar handle.
743pub enum AgentOsSidecarConfig {
744    /// Use (or create) a shared pooled sidecar (`pool` default `"default"`).
745    Shared { pool: Option<String> },
746    /// Use an explicit sidecar handle.
747    Explicit {
748        handle: Arc<crate::sidecar::AgentOsSidecar>,
749    },
750}
751
752// ---------------------------------------------------------------------------
753// Schedule driver
754// ---------------------------------------------------------------------------
755
756/// The callback fired by a [`ScheduleDriver`] when a schedule entry triggers.
757///
758/// Mirrors the TS `ScheduleEntry.callback: () => void | Promise<void>`. The cron manager passes a
759/// closure that runs one job execution; the driver awaits it (and, for the default driver, reschedules
760/// the next cron fire afterwards).
761pub type ScheduleCallback = Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
762
763/// A schedule entry handed to a [`ScheduleDriver`]. Mirrors TS `ScheduleEntry`
764/// (`cron/schedule-driver.ts`).
765#[derive(Clone)]
766pub struct ScheduleEntry {
767    /// Unique ID for this job.
768    pub id: String,
769    /// 5/6/7-field cron expression OR an ISO-8601 one-shot timestamp.
770    pub schedule: String,
771    /// Called when the schedule fires.
772    pub callback: ScheduleCallback,
773}
774
775/// Driver-owned scheduling abstraction. Mirrors the TS `ScheduleDriver` interface
776/// (`cron/schedule-driver.ts`) exactly: the driver parses the schedule, arms the timer, reschedules
777/// cron entries after each fire, and tears everything down on [`ScheduleDriver::dispose`]. This is the
778/// documented extension point: a custom driver (deterministic virtual-time test driver, fire-immediately
779/// driver, etc.) fully controls timing.
780pub trait ScheduleDriver: Send + Sync {
781    /// Schedule a callback to fire on a cron expression or at a specific time. Returns a cancellation
782    /// handle.
783    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle;
784
785    /// Cancel a previously scheduled entry.
786    fn cancel(&self, handle: &ScheduleHandle);
787
788    /// Tear down all scheduled work.
789    fn dispose(&self);
790}
791
792/// Handle to a scheduled entry. Mirrors TS `ScheduleHandle { id }`. Identifies the entry to cancel via
793/// [`ScheduleDriver::cancel`].
794#[derive(Clone)]
795pub struct ScheduleHandle {
796    pub id: String,
797}
798
799/// Default schedule driver backed by `tokio` timers and the system clock.
800///
801/// Mirrors the TS `TimerScheduleDriver`: for cron expressions it computes the next fire time and arms
802/// a single timer, rescheduling after each fire; for one-shot timestamps it fires once and removes the
803/// entry. Driver-held timer tasks are tracked so [`ScheduleDriver::cancel`] / [`ScheduleDriver::dispose`]
804/// can abort them.
805#[derive(Default)]
806pub struct TimerScheduleDriver {
807    timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
808}
809
810impl TimerScheduleDriver {
811    pub fn new() -> Self {
812        Self {
813            timers: Arc::new(scc::HashMap::new()),
814        }
815    }
816
817    /// Arm the next fire for `entry`. For a one-shot or an exhausted cron the entry is dropped. For a
818    /// recurring cron the timer reschedules itself after firing the callback. `cancel` is the per-entry
819    /// cancellation token shared with the registry slot.
820    fn schedule_next(
821        timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
822        entry: ScheduleEntry,
823        cancel: tokio_util::sync::CancellationToken,
824    ) {
825        let now = chrono::Utc::now();
826        let parsed = match crate::cron::parse_schedule(&entry.schedule) {
827            Ok(parsed) => parsed,
828            Err(_) => {
829                let _ = timers.remove(&entry.id);
830                return;
831            }
832        };
833        let is_cron = parsed.is_cron();
834        let next = match crate::cron::resolve_next_run(&parsed, now) {
835            Some(next) => next,
836            None => {
837                // No upcoming run (one-shot in the past, or exhausted cron).
838                let _ = timers.remove(&entry.id);
839                return;
840            }
841        };
842
843        let delay = (next - now).to_std().unwrap_or(std::time::Duration::ZERO);
844
845        tokio::spawn(async move {
846            tokio::select! {
847                _ = cancel.cancelled() => {
848                    return;
849                }
850                _ = tokio::time::sleep(delay) => {}
851            }
852            if cancel.is_cancelled() {
853                return;
854            }
855            // The driver is fire-and-forget; errors are the caller's responsibility.
856            (entry.callback)().await;
857
858            if is_cron && timers.contains(&entry.id) {
859                Self::schedule_next(Arc::clone(&timers), entry, cancel);
860            } else {
861                let _ = timers.remove(&entry.id);
862            }
863        });
864    }
865}
866
867impl ScheduleDriver for TimerScheduleDriver {
868    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
869        let id = entry.id.clone();
870        let cancel = tokio_util::sync::CancellationToken::new();
871        // Replace any existing timer for this id, cancelling it first.
872        if let Some((_, old)) = self.timers.remove(&id) {
873            old.cancel();
874        }
875        let _ = self.timers.insert(id.clone(), cancel.clone());
876
877        Self::schedule_next(Arc::clone(&self.timers), entry, cancel);
878
879        ScheduleHandle { id }
880    }
881
882    fn cancel(&self, handle: &ScheduleHandle) {
883        if let Some((_, cancel)) = self.timers.remove(&handle.id) {
884            cancel.cancel();
885        }
886    }
887
888    fn dispose(&self) {
889        self.timers.scan(|_, cancel| cancel.cancel());
890        self.timers.clear();
891    }
892}