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