agent-os-client 0.2.0-rc.3

High-level Rust client SDK for the Agent OS native sidecar (1:1 port of the TypeScript AgentOs client)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
//! Configuration types: `AgentOsConfig` (= TS `AgentOsOptions`), the permissions tree, root
//! filesystem config, mount config, and the schedule-driver abstraction.
//!
//! Ported from `packages/core/src/agent-os.ts` (`AgentOsOptions`), `runtime.ts` (`Permissions`),
//! `layers.ts` / `overlay-filesystem.ts` (root/overlay), and `cron/` (schedule driver).
//!
//! Non-serializable parameters (`MountConfig::Plain.driver`, `CronAction::Callback`) are in-process
//! only and become `Arc<dyn ...>` trait objects; they cannot cross the wire and are gated exactly as
//! the actor layer gates them.

use std::collections::BTreeMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::fs::VirtualFileSystem;

/// Resolved client options (= TS `AgentOsOptions`). All fields optional with documented defaults.
#[derive(Default)]
pub struct AgentOsConfig {
    /// Software packages to install (flattened). Default `[]`.
    pub software: Vec<SoftwareInput>,
    /// Loopback ports exempt from the default outbound-to-host block.
    pub loopback_exempt_ports: Vec<u16>,
    /// Allowed Node.js builtins. Default: the hardened native-bridge set.
    pub allowed_node_builtins: Option<Vec<String>>,
    /// Working directory used for guest module resolution. Default: host cwd.
    pub module_access_cwd: Option<String>,
    /// Root filesystem configuration. Default: overlay + bundled base snapshot.
    pub root_filesystem: RootFilesystemConfig,
    /// Additional mounts.
    pub mounts: Vec<MountConfig>,
    /// Extra OS instructions appended to agent sessions.
    pub additional_instructions: Option<String>,
    /// Schedule driver used by the cron manager. Default: [`TimerScheduleDriver`].
    pub schedule_driver: Option<Arc<dyn ScheduleDriver>>,
    /// Tool kits to register.
    pub tool_kits: Vec<ToolKit>,
    /// Permission policy. Default: allow-all.
    pub permissions: Option<Permissions>,
    /// Sidecar placement/config. Default: shared `default` pool.
    pub sidecar: Option<AgentOsSidecarConfig>,
}

/// Builder for [`AgentOsConfig`].
#[derive(Default)]
pub struct AgentOsConfigBuilder {
    config: AgentOsConfig,
}

impl AgentOsConfigBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn software(mut self, software: Vec<SoftwareInput>) -> Self {
        self.config.software = software;
        self
    }

    pub fn loopback_exempt_ports(mut self, ports: Vec<u16>) -> Self {
        self.config.loopback_exempt_ports = ports;
        self
    }

    pub fn allowed_node_builtins(mut self, builtins: Vec<String>) -> Self {
        self.config.allowed_node_builtins = Some(builtins);
        self
    }

    pub fn module_access_cwd(mut self, cwd: impl Into<String>) -> Self {
        self.config.module_access_cwd = Some(cwd.into());
        self
    }

    pub fn root_filesystem(mut self, root: RootFilesystemConfig) -> Self {
        self.config.root_filesystem = root;
        self
    }

    pub fn mounts(mut self, mounts: Vec<MountConfig>) -> Self {
        self.config.mounts = mounts;
        self
    }

    pub fn additional_instructions(mut self, instructions: impl Into<String>) -> Self {
        self.config.additional_instructions = Some(instructions.into());
        self
    }

    pub fn schedule_driver(mut self, driver: Arc<dyn ScheduleDriver>) -> Self {
        self.config.schedule_driver = Some(driver);
        self
    }

    pub fn tool_kits(mut self, tool_kits: Vec<ToolKit>) -> Self {
        self.config.tool_kits = tool_kits;
        self
    }

    pub fn permissions(mut self, permissions: Permissions) -> Self {
        self.config.permissions = Some(permissions);
        self
    }

    pub fn sidecar(mut self, sidecar: AgentOsSidecarConfig) -> Self {
        self.config.sidecar = Some(sidecar);
        self
    }

    pub fn build(self) -> AgentOsConfig {
        self.config
    }
}

/// The kind of a software package, which decides how it is mounted into the VM. Mirrors the TS
/// descriptor `type` discriminator (`packages/core/src/packages.ts`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SoftwareKind {
    /// A directory of wasm command binaries. Mounted at `/__agentos/commands/{index}/` so the
    /// sidecar's command discovery can resolve guest commands (`echo`, `sh`, `grep`, ...).
    #[default]
    WasmCommands,
    /// An agent SDK/adapter package. Not mounted as a command directory.
    Agent,
    /// A host-tool package. Not mounted as a command directory.
    Tool,
}

/// A flattened software package input.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoftwareInput {
    pub package: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// How the package is mounted into the VM. Defaults to [`SoftwareKind::WasmCommands`].
    #[serde(default)]
    pub kind: SoftwareKind,
}

/// A host-side tool execute callback. Receives the validated JSON input, returns a JSON result or an
/// error string. Stays host-side (never crosses to the guest); the guest invokes it by name via the
/// sidecar tool-invocation callback channel.
pub type ToolCallback = Arc<
    dyn Fn(serde_json::Value) -> futures::future::BoxFuture<'static, Result<serde_json::Value, String>>
        + Send
        + Sync,
>;

/// A single host tool within a [`ToolKit`].
#[derive(Clone)]
pub struct HostTool {
    pub name: String,
    pub description: String,
    /// JSON Schema for the tool input (forwarded to the sidecar `register_toolkit` definition).
    pub input_schema: serde_json::Value,
    pub timeout_ms: Option<u64>,
    /// Host-side implementation, invoked when the guest calls `<toolkit>:<tool>`.
    pub execute: ToolCallback,
}

/// A registered tool kit (in-process; tool implementations stay host-side). Tools are exposed to the
/// guest as `<toolkit>:<tool>` and dispatched back to [`HostTool::execute`] via the sidecar
/// tool-invocation callback channel.
#[derive(Clone)]
pub struct ToolKit {
    pub name: String,
    pub description: String,
    pub tools: Vec<HostTool>,
}

// ---------------------------------------------------------------------------
// Permissions tree (runtime.ts)
// ---------------------------------------------------------------------------

/// Top-level permission policy. All domains optional (`allowAll` when omitted).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Permissions {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fs: Option<FsPermissions>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network: Option<PatternPermissions>,
    #[serde(default, rename = "childProcess", skip_serializing_if = "Option::is_none")]
    pub child_process: Option<PatternPermissions>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub process: Option<PatternPermissions>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub env: Option<PatternPermissions>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool: Option<PatternPermissions>,
}

/// `"allow"` or `"deny"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionMode {
    Allow,
    Deny,
}

/// `PermissionMode | RulePermissions<FsPermissionRule>`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FsPermissions {
    Mode(PermissionMode),
    Rules(RulePermissions<FsPermissionRule>),
}

/// `PermissionMode | RulePermissions<PatternPermissionRule>` (network/childProcess/process/env/tool).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PatternPermissions {
    Mode(PermissionMode),
    Rules(RulePermissions<PatternPermissionRule>),
}

/// `{ default?: PermissionMode; rules: T[] }`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulePermissions<T> {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<PermissionMode>,
    pub rules: Vec<T>,
}

/// `{ mode; operations?; paths? }`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsPermissionRule {
    pub mode: PermissionMode,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub operations: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub paths: Option<Vec<String>>,
}

/// `{ mode; operations?; patterns? }`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatternPermissionRule {
    pub mode: PermissionMode,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub operations: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub patterns: Option<Vec<String>>,
}

// ---------------------------------------------------------------------------
// Root filesystem (layers.ts / overlay-filesystem.ts)
// ---------------------------------------------------------------------------

/// Root filesystem configuration. Default: overlay + bundled base snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RootFilesystemConfig {
    #[serde(default, rename = "type")]
    pub kind: RootFilesystemKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<RootFilesystemMode>,
    #[serde(default, rename = "disableDefaultBaseLayer")]
    pub disable_default_base_layer: bool,
    #[serde(default)]
    pub lowers: Vec<RootLowerInput>,
}

impl Default for RootFilesystemConfig {
    fn default() -> Self {
        Self {
            kind: RootFilesystemKind::Overlay,
            mode: None,
            disable_default_base_layer: false,
            lowers: Vec::new(),
        }
    }
}

/// The root filesystem kind. Currently only `overlay`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RootFilesystemKind {
    #[default]
    Overlay,
}

/// Root filesystem mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RootFilesystemMode {
    Ephemeral,
    ReadOnly,
}

/// A lower (immutable) snapshot layer input.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum RootLowerInput {
    /// The bundled base filesystem snapshot.
    BundledBaseFilesystem,
    /// A snapshot export (`{ kind: "snapshot-export", source }`).
    #[serde(untagged)]
    SnapshotExport(crate::fs::RootSnapshotExport),
}

// ---------------------------------------------------------------------------
// Mounts
// ---------------------------------------------------------------------------

/// A filesystem mount. `Plain.driver` is an in-process trait object and cannot cross the wire.
pub enum MountConfig {
    /// Plain mount over an in-process [`VirtualFileSystem`] driver.
    Plain {
        path: String,
        driver: Arc<dyn VirtualFileSystem>,
        read_only: bool,
    },
    /// Native plugin mount (`{ id; config? }`).
    Native {
        path: String,
        plugin: MountPlugin,
        read_only: bool,
    },
    /// Overlay mount (`{ type: "overlay"; store; mode?; lowers }`).
    Overlay {
        path: String,
        filesystem: OverlayMountConfig,
    },
}

/// A native mount plugin descriptor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MountPlugin {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config: Option<serde_json::Value>,
}

/// Overlay mount filesystem config.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayMountConfig {
    #[serde(rename = "type")]
    pub kind: String,
    pub store: serde_json::Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode: Option<RootFilesystemMode>,
    pub lowers: Vec<RootLowerInput>,
}

// ---------------------------------------------------------------------------
// Sidecar config
// ---------------------------------------------------------------------------

/// How the client obtains its sidecar handle.
pub enum AgentOsSidecarConfig {
    /// Use (or create) a shared pooled sidecar (`pool` default `"default"`).
    Shared { pool: Option<String> },
    /// Use an explicit sidecar handle.
    Explicit {
        handle: Arc<crate::sidecar::AgentOsSidecar>,
    },
}

// ---------------------------------------------------------------------------
// Schedule driver
// ---------------------------------------------------------------------------

/// The callback fired by a [`ScheduleDriver`] when a schedule entry triggers.
///
/// Mirrors the TS `ScheduleEntry.callback: () => void | Promise<void>`. The cron manager passes a
/// closure that runs one job execution; the driver awaits it (and, for the default driver, reschedules
/// the next cron fire afterwards).
pub type ScheduleCallback =
    Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>;

/// A schedule entry handed to a [`ScheduleDriver`]. Mirrors TS `ScheduleEntry`
/// (`cron/schedule-driver.ts`).
#[derive(Clone)]
pub struct ScheduleEntry {
    /// Unique ID for this job.
    pub id: String,
    /// 5/6/7-field cron expression OR an ISO-8601 one-shot timestamp.
    pub schedule: String,
    /// Called when the schedule fires.
    pub callback: ScheduleCallback,
}

/// Driver-owned scheduling abstraction. Mirrors the TS `ScheduleDriver` interface
/// (`cron/schedule-driver.ts`) exactly: the driver parses the schedule, arms the timer, reschedules
/// cron entries after each fire, and tears everything down on [`ScheduleDriver::dispose`]. This is the
/// documented extension point: a custom driver (deterministic virtual-time test driver, fire-immediately
/// driver, etc.) fully controls timing.
pub trait ScheduleDriver: Send + Sync {
    /// Schedule a callback to fire on a cron expression or at a specific time. Returns a cancellation
    /// handle.
    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle;

    /// Cancel a previously scheduled entry.
    fn cancel(&self, handle: &ScheduleHandle);

    /// Tear down all scheduled work.
    fn dispose(&self);
}

/// Handle to a scheduled entry. Mirrors TS `ScheduleHandle { id }`. Identifies the entry to cancel via
/// [`ScheduleDriver::cancel`].
#[derive(Clone)]
pub struct ScheduleHandle {
    pub id: String,
}

/// Default schedule driver backed by `tokio` timers and the system clock.
///
/// Mirrors the TS `TimerScheduleDriver`: for cron expressions it computes the next fire time and arms
/// a single timer, rescheduling after each fire; for one-shot timestamps it fires once and removes the
/// entry. Driver-held timer tasks are tracked so [`ScheduleDriver::cancel`] / [`ScheduleDriver::dispose`]
/// can abort them.
#[derive(Default)]
pub struct TimerScheduleDriver {
    timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
}

impl TimerScheduleDriver {
    pub fn new() -> Self {
        Self {
            timers: Arc::new(scc::HashMap::new()),
        }
    }

    /// Arm the next fire for `entry`. For a one-shot or an exhausted cron the entry is dropped. For a
    /// recurring cron the timer reschedules itself after firing the callback. `cancel` is the per-entry
    /// cancellation token shared with the registry slot.
    fn schedule_next(
        timers: Arc<scc::HashMap<String, tokio_util::sync::CancellationToken>>,
        entry: ScheduleEntry,
        cancel: tokio_util::sync::CancellationToken,
    ) {
        let now = chrono::Utc::now();
        let parsed = match crate::cron::parse_schedule(&entry.schedule) {
            Ok(parsed) => parsed,
            Err(_) => {
                let _ = timers.remove(&entry.id);
                return;
            }
        };
        let is_cron = parsed.is_cron();
        let next = match crate::cron::resolve_next_run(&parsed, now) {
            Some(next) => next,
            None => {
                // No upcoming run (one-shot in the past, or exhausted cron).
                let _ = timers.remove(&entry.id);
                return;
            }
        };

        let delay = (next - now)
            .to_std()
            .unwrap_or(std::time::Duration::ZERO);

        tokio::spawn(async move {
            tokio::select! {
                _ = cancel.cancelled() => {
                    return;
                }
                _ = tokio::time::sleep(delay) => {}
            }
            if cancel.is_cancelled() {
                return;
            }
            // The driver is fire-and-forget; errors are the caller's responsibility.
            (entry.callback)().await;

            if is_cron && timers.contains(&entry.id) {
                Self::schedule_next(Arc::clone(&timers), entry, cancel);
            } else {
                let _ = timers.remove(&entry.id);
            }
        });
    }
}

impl ScheduleDriver for TimerScheduleDriver {
    fn schedule(&self, entry: ScheduleEntry) -> ScheduleHandle {
        let id = entry.id.clone();
        let cancel = tokio_util::sync::CancellationToken::new();
        // Replace any existing timer for this id, cancelling it first.
        if let Some((_, old)) = self.timers.remove(&id) {
            old.cancel();
        }
        let _ = self.timers.insert(id.clone(), cancel.clone());

        Self::schedule_next(Arc::clone(&self.timers), entry, cancel);

        ScheduleHandle { id }
    }

    fn cancel(&self, handle: &ScheduleHandle) {
        if let Some((_, cancel)) = self.timers.remove(&handle.id) {
            cancel.cancel();
        }
    }

    fn dispose(&self) {
        self.timers.scan(|_, cancel| cancel.cancel());
        self.timers.clear();
    }
}

/// Metadata helpers reused when building sidecar requests.
pub(crate) fn empty_metadata() -> BTreeMap<String, String> {
    BTreeMap::new()
}