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