Skip to main content

astrid_sdk/
lib.rs

1//! Safe Rust SDK for building User-Space Capsules on Astrid OS.
2//!
3//! # Design Intent
4//!
5//! This SDK is meant to feel like using `std`. Module names, function
6//! signatures, and type patterns follow Rust standard library conventions so
7//! that a Rust developer's instinct for "where would I find X?" gives the
8//! right answer without reading docs. When Astrid adds a concept that has no
9//! `std` counterpart (IPC, capabilities, interceptors), the API still follows
10//! the same style: typed handles, `Result`-based errors, and `impl AsRef`
11//! parameters.
12//!
13//! See `docs/sdk-ergonomics.md` for the full design rationale.
14//!
15//! # Module Layout (mirrors `std` where applicable)
16//!
17//! | Module          | std equivalent   | Purpose                                |
18//! |-----------------|------------------|----------------------------------------|
19//! | [`fs`]          | `std::fs`        | Virtual filesystem                     |
20//! | [`net`]         | `std::net`       | Unix domain sockets                    |
21//! | [`process`]     | `std::process`   | Host process execution                 |
22//! | [`env`]         | `std::env`       | Capsule configuration / env vars       |
23//! | [`time`]        | `std::time`      | Wall-clock access                      |
24//! | [`log`]         | `log` crate      | Structured logging                     |
25//! | [`runtime`]     | N/A              | OS signaling and caller context        |
26//! | [`ipc`]         | N/A              | Event bus messaging                    |
27//! | [`kv`]          | N/A              | Persistent key-value storage           |
28//! | [`http`]        | N/A              | Outbound HTTP requests                 |
29//! | [`cron`]        | N/A              | Scheduled background tasks             |
30//! | [`uplink`]      | N/A              | Direct frontend messaging              |
31//! | [`hooks`]       | N/A              | User middleware triggers               |
32//! | [`elicit`]      | N/A              | Interactive install/upgrade prompts    |
33//! | [`identity`]    | N/A              | Platform user identity resolution      |
34//! | [`approval`]    | N/A              | Human approval for sensitive actions   |
35//! | [`types`]       | N/A              | IPC payload types and LLM schemas      |
36
37#![allow(unsafe_code)]
38#![allow(missing_docs)]
39#![deny(clippy::all)]
40#![deny(unreachable_pub)]
41#![deny(clippy::unwrap_used)]
42#![cfg_attr(test, allow(clippy::unwrap_used))]
43
44use astrid_sys::*;
45use borsh::{BorshDeserialize, BorshSerialize};
46use serde::{Deserialize, Serialize, de::DeserializeOwned};
47use thiserror::Error;
48
49/// Shared Astrid data types (IPC payloads, LLM schemas, kernel API).
50///
51/// Re-exported from [`astrid_types`]. SDK-specific types like [`CallerContext`]
52/// are also available here.
53pub mod types {
54    use serde::{Deserialize, Serialize};
55
56    // Sub-modules (re-exported for `astrid_sdk::types::ipc::*` access)
57    pub use astrid_types::ipc;
58    pub use astrid_types::kernel;
59    pub use astrid_types::llm;
60
61    // IPC types
62    pub use astrid_types::ipc::{
63        IpcMessage, IpcPayload, OnboardingField, OnboardingFieldType, SelectionOption,
64    };
65
66    // Kernel API types
67    pub use astrid_types::kernel::{
68        CapsuleMetadataEntry, CommandInfo, KernelRequest, KernelResponse, LlmProviderInfo,
69        SYSTEM_SESSION_UUID,
70    };
71
72    // LLM types
73    pub use astrid_types::llm::{
74        ContentPart, LlmResponse, LlmToolDefinition, Message, MessageContent, MessageRole,
75        StopReason, StreamEvent, ToolCall, ToolCallResult, Usage,
76    };
77
78    /// Identifies the user and session that triggered the current capsule execution.
79    #[derive(Debug, Clone, Serialize, Deserialize)]
80    pub struct CallerContext {
81        /// Session ID, if available.
82        pub session_id: Option<String>,
83        /// User ID, if available.
84        pub user_id: Option<String>,
85    }
86}
87pub use borsh;
88pub use serde;
89pub use serde_json;
90
91// Re-exported for the #[capsule] macro's generated code. Not part of the
92// public API - capsule authors should never need to import these directly.
93#[doc(hidden)]
94pub use extism_pdk;
95#[doc(hidden)]
96pub use schemars;
97
98/// Core error type for SDK operations
99#[derive(Error, Debug)]
100pub enum SysError {
101    #[error("Host function call failed: {0}")]
102    HostError(#[from] extism_pdk::Error),
103    #[error("JSON serialization error: {0}")]
104    JsonError(#[from] serde_json::Error),
105    #[error("MessagePack serialization error: {0}")]
106    MsgPackEncodeError(#[from] rmp_serde::encode::Error),
107    #[error("MessagePack deserialization error: {0}")]
108    MsgPackDecodeError(#[from] rmp_serde::decode::Error),
109    #[error("Borsh serialization error: {0}")]
110    BorshError(#[from] std::io::Error),
111    #[error("API logic error: {0}")]
112    ApiError(String),
113}
114
115/// Virtual filesystem (mirrors `std::fs` naming).
116pub mod fs {
117    use super::*;
118
119    /// Check if a path exists. Like `std::fs::exists` (nightly).
120    pub fn exists(path: impl AsRef<[u8]>) -> Result<bool, SysError> {
121        let result = unsafe { astrid_fs_exists(path.as_ref().to_vec())? };
122        Ok(!result.is_empty() && result[0] != 0)
123    }
124
125    /// Read the entire contents of a file as bytes. Like `std::fs::read`.
126    pub fn read(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
127        let result = unsafe { astrid_read_file(path.as_ref().to_vec())? };
128        Ok(result)
129    }
130
131    /// Read the entire contents of a file as a string. Like `std::fs::read_to_string`.
132    pub fn read_to_string(path: impl AsRef<[u8]>) -> Result<String, SysError> {
133        let bytes = read(path)?;
134        String::from_utf8(bytes).map_err(|e| SysError::ApiError(e.to_string()))
135    }
136
137    /// Write bytes to a file. Like `std::fs::write`.
138    pub fn write(path: impl AsRef<[u8]>, contents: impl AsRef<[u8]>) -> Result<(), SysError> {
139        unsafe { astrid_write_file(path.as_ref().to_vec(), contents.as_ref().to_vec())? };
140        Ok(())
141    }
142
143    /// Create a directory. Like `std::fs::create_dir`.
144    pub fn create_dir(path: impl AsRef<[u8]>) -> Result<(), SysError> {
145        unsafe { astrid_fs_mkdir(path.as_ref().to_vec())? };
146        Ok(())
147    }
148
149    /// Read directory entries. Like `std::fs::read_dir`.
150    pub fn read_dir(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
151        let result = unsafe { astrid_fs_readdir(path.as_ref().to_vec())? };
152        Ok(result)
153    }
154
155    /// Get file metadata. Like `std::fs::metadata`.
156    pub fn metadata(path: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
157        let result = unsafe { astrid_fs_stat(path.as_ref().to_vec())? };
158        Ok(result)
159    }
160
161    /// Remove a file. Like `std::fs::remove_file`.
162    pub fn remove_file(path: impl AsRef<[u8]>) -> Result<(), SysError> {
163        unsafe { astrid_fs_unlink(path.as_ref().to_vec())? };
164        Ok(())
165    }
166}
167
168/// Event bus messaging (like `std::sync::mpsc` but topic-based).
169pub mod ipc {
170    use super::*;
171
172    /// An active subscription to an IPC topic. Returned by [`subscribe`].
173    ///
174    /// Follows the typed-handle pattern used by [`crate::net::ListenerHandle`].
175    #[derive(Debug, Clone)]
176    pub struct SubscriptionHandle(pub(crate) Vec<u8>);
177
178    impl SubscriptionHandle {
179        /// Raw handle bytes for interop with lower-level APIs.
180        #[must_use]
181        pub fn as_bytes(&self) -> &[u8] {
182            &self.0
183        }
184    }
185
186    // Allow existing code using `impl AsRef<[u8]>` to pass a SubscriptionHandle.
187    impl AsRef<[u8]> for SubscriptionHandle {
188        fn as_ref(&self) -> &[u8] {
189            &self.0
190        }
191    }
192
193    pub fn publish_bytes(topic: impl AsRef<[u8]>, payload: &[u8]) -> Result<(), SysError> {
194        unsafe { astrid_ipc_publish(topic.as_ref().to_vec(), payload.to_vec())? };
195        Ok(())
196    }
197
198    pub fn publish_json<T: Serialize>(
199        topic: impl AsRef<[u8]>,
200        payload: &T,
201    ) -> Result<(), SysError> {
202        let bytes = serde_json::to_vec(payload)?;
203        publish_bytes(topic, &bytes)
204    }
205
206    pub fn publish_msgpack<T: Serialize>(
207        topic: impl AsRef<[u8]>,
208        payload: &T,
209    ) -> Result<(), SysError> {
210        let bytes = rmp_serde::to_vec_named(payload)?;
211        publish_bytes(topic, &bytes)
212    }
213
214    /// Subscribe to an IPC topic. Returns a typed handle for polling/receiving.
215    pub fn subscribe(topic: impl AsRef<[u8]>) -> Result<SubscriptionHandle, SysError> {
216        let handle_bytes = unsafe { astrid_ipc_subscribe(topic.as_ref().to_vec())? };
217        Ok(SubscriptionHandle(handle_bytes))
218    }
219
220    pub fn unsubscribe(handle: &SubscriptionHandle) -> Result<(), SysError> {
221        unsafe { astrid_ipc_unsubscribe(handle.0.clone())? };
222        Ok(())
223    }
224
225    pub fn poll_bytes(handle: &SubscriptionHandle) -> Result<Vec<u8>, SysError> {
226        let message_bytes = unsafe { astrid_ipc_poll(handle.0.clone())? };
227        Ok(message_bytes)
228    }
229
230    /// Block until a message arrives on a subscription handle, or timeout.
231    ///
232    /// Returns the message envelope (same format as `poll_bytes`), or an
233    /// empty-messages envelope if the timeout expires with no messages.
234    /// Max timeout is capped at 60 000 ms by the host.
235    pub fn recv_bytes(handle: &SubscriptionHandle, timeout_ms: u64) -> Result<Vec<u8>, SysError> {
236        let timeout_str = timeout_ms.to_string();
237        let message_bytes = unsafe { astrid_ipc_recv(handle.0.clone(), timeout_str.into_bytes())? };
238        Ok(message_bytes)
239    }
240}
241
242/// Direct frontend messaging (uplinks to CLI, Telegram, etc.).
243pub mod uplink {
244    use super::*;
245
246    /// An opaque uplink connection identifier. Returned by [`register`].
247    #[derive(Debug, Clone)]
248    pub struct UplinkId(pub(crate) Vec<u8>);
249
250    impl UplinkId {
251        /// Raw ID bytes for interop with lower-level APIs.
252        #[must_use]
253        pub fn as_bytes(&self) -> &[u8] {
254            &self.0
255        }
256    }
257
258    impl AsRef<[u8]> for UplinkId {
259        fn as_ref(&self) -> &[u8] {
260            &self.0
261        }
262    }
263
264    /// Register a new uplink connection. Returns a typed [`UplinkId`].
265    pub fn register(
266        name: impl AsRef<[u8]>,
267        platform: impl AsRef<[u8]>,
268        profile: impl AsRef<[u8]>,
269    ) -> Result<UplinkId, SysError> {
270        let id_bytes = unsafe {
271            astrid_uplink_register(
272                name.as_ref().to_vec(),
273                platform.as_ref().to_vec(),
274                profile.as_ref().to_vec(),
275            )?
276        };
277        Ok(UplinkId(id_bytes))
278    }
279
280    /// Send bytes to a user via an uplink.
281    pub fn send_bytes(
282        uplink_id: &UplinkId,
283        platform_user_id: impl AsRef<[u8]>,
284        content: &[u8],
285    ) -> Result<Vec<u8>, SysError> {
286        let result = unsafe {
287            astrid_uplink_send(
288                uplink_id.0.clone(),
289                platform_user_id.as_ref().to_vec(),
290                content.to_vec(),
291            )?
292        };
293        Ok(result)
294    }
295}
296
297/// The KV Airlock — Persistent Key-Value Storage
298pub mod kv {
299    use super::*;
300
301    pub fn get_bytes(key: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
302        let result = unsafe { astrid_kv_get(key.as_ref().to_vec())? };
303        Ok(result)
304    }
305
306    pub fn set_bytes(key: impl AsRef<[u8]>, value: &[u8]) -> Result<(), SysError> {
307        unsafe { astrid_kv_set(key.as_ref().to_vec(), value.to_vec())? };
308        Ok(())
309    }
310
311    pub fn get_json<T: DeserializeOwned>(key: impl AsRef<[u8]>) -> Result<T, SysError> {
312        let bytes = get_bytes(key)?;
313        let parsed = serde_json::from_slice(&bytes)?;
314        Ok(parsed)
315    }
316
317    pub fn set_json<T: Serialize>(key: impl AsRef<[u8]>, value: &T) -> Result<(), SysError> {
318        let bytes = serde_json::to_vec(value)?;
319        set_bytes(key, &bytes)
320    }
321
322    /// Delete a key from the KV store.
323    ///
324    /// This is idempotent: deleting a non-existent key succeeds silently.
325    /// The underlying store returns whether the key existed, but that
326    /// information is not surfaced through the WASM host boundary.
327    pub fn delete(key: impl AsRef<[u8]>) -> Result<(), SysError> {
328        unsafe { astrid_kv_delete(key.as_ref().to_vec())? };
329        Ok(())
330    }
331
332    /// List all keys matching a prefix.
333    ///
334    /// Returns an empty vec if no keys match. The prefix is matched
335    /// against key names within the capsule's scoped namespace.
336    pub fn list_keys(prefix: impl AsRef<[u8]>) -> Result<Vec<String>, SysError> {
337        let result = unsafe { astrid_kv_list_keys(prefix.as_ref().to_vec())? };
338        let keys: Vec<String> = serde_json::from_slice(&result)?;
339        Ok(keys)
340    }
341
342    /// Delete all keys matching a prefix.
343    ///
344    /// Returns the number of keys deleted. The prefix is matched
345    /// against key names within the capsule's scoped namespace.
346    pub fn clear_prefix(prefix: impl AsRef<[u8]>) -> Result<u64, SysError> {
347        let result = unsafe { astrid_kv_clear_prefix(prefix.as_ref().to_vec())? };
348        let count: u64 = serde_json::from_slice(&result)?;
349        Ok(count)
350    }
351
352    pub fn get_borsh<T: BorshDeserialize>(key: impl AsRef<[u8]>) -> Result<T, SysError> {
353        let bytes = get_bytes(key)?;
354        let parsed = borsh::from_slice(&bytes)?;
355        Ok(parsed)
356    }
357
358    pub fn set_borsh<T: BorshSerialize>(key: impl AsRef<[u8]>, value: &T) -> Result<(), SysError> {
359        let bytes = borsh::to_vec(value)?;
360        set_bytes(key, &bytes)
361    }
362
363    // ---- Versioned KV helpers ----
364
365    /// Internal envelope for versioned KV data.
366    ///
367    /// Wire format: `{"__sv": <version>, "data": <payload>}`.
368    /// The `__sv` prefix is deliberately ugly to avoid collision with
369    /// user struct fields.
370    #[derive(Serialize, Deserialize)]
371    struct VersionedEnvelope<T> {
372        #[serde(rename = "__sv")]
373        schema_version: u32,
374        data: T,
375    }
376
377    /// Result of reading versioned data from KV.
378    #[derive(Debug)]
379    pub enum Versioned<T> {
380        /// Data is at the expected schema version.
381        Current(T),
382        /// Data is at an older version and needs migration.
383        NeedsMigration {
384            /// Raw JSON value of the `data` field.
385            raw: serde_json::Value,
386            /// The schema version that was stored.
387            stored_version: u32,
388        },
389        /// Key exists but data has no version envelope (pre-versioning legacy data).
390        Unversioned(serde_json::Value),
391        /// Key does not exist in KV.
392        NotFound,
393    }
394
395    /// Write versioned data to KV, wrapped in a schema-version envelope.
396    ///
397    /// The stored JSON looks like `{"__sv": 1, "data": { ... }}`.
398    /// Use [`get_versioned`] or [`get_versioned_or_migrate`] to read it back.
399    pub fn set_versioned<T: Serialize>(
400        key: impl AsRef<[u8]>,
401        value: &T,
402        version: u32,
403    ) -> Result<(), SysError> {
404        let envelope = VersionedEnvelope {
405            schema_version: version,
406            data: value,
407        };
408        set_json(key, &envelope)
409    }
410
411    /// Read versioned data from KV.
412    ///
413    /// Returns [`Versioned::Current`] if the stored version matches
414    /// `current_version`. Returns [`Versioned::NeedsMigration`] for older
415    /// versions. Returns an error for versions newer than `current_version`
416    /// (fail secure - don't silently interpret data from a schema you don't
417    /// understand).
418    ///
419    /// Data written by plain [`set_json`] (no envelope) returns
420    /// [`Versioned::Unversioned`].
421    pub fn get_versioned<T: DeserializeOwned>(
422        key: impl AsRef<[u8]>,
423        current_version: u32,
424    ) -> Result<Versioned<T>, SysError> {
425        let bytes = get_bytes(&key)?;
426        parse_versioned(&bytes, current_version)
427    }
428
429    /// Core parsing logic for versioned KV data, separated from FFI for
430    /// testability. Operates on raw bytes as returned by `get_bytes`.
431    fn parse_versioned<T: DeserializeOwned>(
432        bytes: &[u8],
433        current_version: u32,
434    ) -> Result<Versioned<T>, SysError> {
435        // The host function `astrid_kv_get` returns an empty slice when the
436        // key is absent. A present key written via set_json/set_versioned
437        // always has at least the JSON envelope bytes, so empty = not found.
438        if bytes.is_empty() {
439            return Ok(Versioned::NotFound);
440        }
441
442        let mut value: serde_json::Value = serde_json::from_slice(bytes)?;
443
444        // Detect envelope by checking for __sv (u64) + data fields.
445        // If __sv is present but malformed (not a number, or missing data),
446        // return an error rather than silently treating as unversioned.
447        let sv_field = value.get("__sv");
448        let has_sv = sv_field.is_some();
449        let envelope_version = sv_field.and_then(|v| v.as_u64());
450        let has_data = value.get("data").is_some();
451
452        match (has_sv, envelope_version, has_data) {
453            // Valid envelope: __sv is a u64 and data is present.
454            // Take ownership of the data field via remove() to avoid cloning.
455            (_, Some(v), true) => {
456                let v = u32::try_from(v)
457                    .map_err(|_| SysError::ApiError("schema version exceeds u32::MAX".into()))?;
458                // Safety: the match guard confirmed has_data=true, so
459                // value is an object with a "data" key. This is infallible.
460                let data = value
461                    .as_object_mut()
462                    .and_then(|m| m.remove("data"))
463                    .expect("data field guaranteed by match condition");
464                if v == current_version {
465                    let parsed: T = serde_json::from_value(data)?;
466                    Ok(Versioned::Current(parsed))
467                } else if v < current_version {
468                    Ok(Versioned::NeedsMigration {
469                        raw: data,
470                        stored_version: v,
471                    })
472                } else {
473                    Err(SysError::ApiError(format!(
474                        "stored schema version {v} is newer than current \
475                         version {current_version} - cannot safely read"
476                    )))
477                }
478            }
479            // Malformed envelope: __sv present but data missing or __sv not a number.
480            (true, _, _) => Err(SysError::ApiError(
481                "malformed versioned envelope: __sv field present but \
482                 data field missing or __sv is not a number"
483                    .into(),
484            )),
485            // No __sv field at all: plain unversioned data.
486            (false, _, _) => Ok(Versioned::Unversioned(value)),
487        }
488    }
489
490    /// Read versioned data, automatically migrating older versions.
491    ///
492    /// `migrate_fn` receives the raw JSON and the stored version, and must
493    /// return a `T` at `current_version`. The migrated value is automatically
494    /// saved back to KV.
495    ///
496    /// **Warning:** The original data is overwritten after a successful
497    /// migration. If the write-back fails, the original data is preserved
498    /// and the migration will be re-attempted on the next call. Ensure
499    /// `migrate_fn` is idempotent and correct - there is no rollback
500    /// after a successful write.
501    ///
502    /// For [`Versioned::Unversioned`] data, `migrate_fn` is called with
503    /// version 0. For [`Versioned::NotFound`], returns `None`.
504    pub fn get_versioned_or_migrate<T: Serialize + DeserializeOwned>(
505        key: impl AsRef<[u8]>,
506        current_version: u32,
507        migrate_fn: impl FnOnce(serde_json::Value, u32) -> Result<T, SysError>,
508    ) -> Result<Option<T>, SysError> {
509        let key = key.as_ref();
510
511        match get_versioned::<T>(key, current_version)? {
512            Versioned::Current(data) => Ok(Some(data)),
513            Versioned::NeedsMigration {
514                raw,
515                stored_version,
516            } => {
517                let migrated = migrate_fn(raw, stored_version)?;
518                set_versioned(key, &migrated, current_version)?;
519                Ok(Some(migrated))
520            }
521            Versioned::Unversioned(raw) => {
522                let migrated = migrate_fn(raw, 0)?;
523                set_versioned(key, &migrated, current_version)?;
524                Ok(Some(migrated))
525            }
526            Versioned::NotFound => Ok(None),
527        }
528    }
529
530    #[cfg(test)]
531    mod tests {
532        use super::*;
533
534        #[derive(Debug, Serialize, Deserialize, PartialEq)]
535        struct TestData {
536            name: String,
537            count: u32,
538        }
539
540        // ---- Envelope serialization tests ----
541
542        #[test]
543        fn versioned_envelope_roundtrip() {
544            let envelope = VersionedEnvelope {
545                schema_version: 1,
546                data: TestData {
547                    name: "hello".into(),
548                    count: 42,
549                },
550            };
551            let json = serde_json::to_string(&envelope).unwrap();
552            assert!(json.contains("\"__sv\":1"));
553            assert!(json.contains("\"data\":{"));
554
555            let parsed: VersionedEnvelope<TestData> = serde_json::from_str(&json).unwrap();
556            assert_eq!(parsed.schema_version, 1);
557            assert_eq!(
558                parsed.data,
559                TestData {
560                    name: "hello".into(),
561                    count: 42,
562                }
563            );
564        }
565
566        #[test]
567        fn versioned_envelope_wire_format() {
568            let envelope = VersionedEnvelope {
569                schema_version: 3,
570                data: serde_json::json!({"key": "value"}),
571            };
572            let json = serde_json::to_string(&envelope).unwrap();
573            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
574
575            assert_eq!(parsed["__sv"], 3);
576            assert_eq!(parsed["data"]["key"], "value");
577        }
578
579        // ---- parse_versioned logic tests ----
580
581        #[test]
582        fn parse_versioned_empty_bytes_returns_not_found() {
583            let result = parse_versioned::<TestData>(b"", 1).unwrap();
584            assert!(matches!(result, Versioned::NotFound));
585        }
586
587        #[test]
588        fn parse_versioned_current_version_returns_current() {
589            let bytes = br#"{"__sv":2,"data":{"name":"hello","count":42}}"#;
590            let result = parse_versioned::<TestData>(bytes, 2).unwrap();
591            match result {
592                Versioned::Current(data) => {
593                    assert_eq!(data.name, "hello");
594                    assert_eq!(data.count, 42);
595                }
596                other => panic!("expected Current, got {other:?}"),
597            }
598        }
599
600        #[test]
601        fn parse_versioned_older_version_returns_needs_migration() {
602            let bytes = br#"{"__sv":1,"data":{"name":"old","count":1}}"#;
603            let result = parse_versioned::<TestData>(bytes, 3).unwrap();
604            match result {
605                Versioned::NeedsMigration {
606                    raw,
607                    stored_version,
608                } => {
609                    assert_eq!(stored_version, 1);
610                    assert_eq!(raw["name"], "old");
611                    assert_eq!(raw["count"], 1);
612                }
613                other => panic!("expected NeedsMigration, got {other:?}"),
614            }
615        }
616
617        #[test]
618        fn parse_versioned_newer_version_returns_error() {
619            let bytes = br#"{"__sv":5,"data":{"name":"future","count":0}}"#;
620            let result = parse_versioned::<TestData>(bytes, 2);
621            assert!(result.is_err());
622            let err = result.unwrap_err().to_string();
623            assert!(
624                err.contains("newer than current"),
625                "error should mention newer version: {err}"
626            );
627        }
628
629        #[test]
630        fn parse_versioned_plain_json_returns_unversioned() {
631            let bytes = br#"{"name":"legacy","count":99}"#;
632            let result = parse_versioned::<TestData>(bytes, 1).unwrap();
633            match result {
634                Versioned::Unversioned(val) => {
635                    assert_eq!(val["name"], "legacy");
636                    assert_eq!(val["count"], 99);
637                }
638                other => panic!("expected Unversioned, got {other:?}"),
639            }
640        }
641
642        #[test]
643        fn parse_versioned_malformed_sv_without_data_returns_error() {
644            let bytes = br#"{"__sv":1,"payload":"something"}"#;
645            let result = parse_versioned::<TestData>(bytes, 1);
646            assert!(result.is_err());
647            let err = result.unwrap_err().to_string();
648            assert!(
649                err.contains("malformed"),
650                "error should mention malformed envelope: {err}"
651            );
652        }
653
654        #[test]
655        fn parse_versioned_non_numeric_sv_returns_error() {
656            let bytes = br#"{"__sv":"one","data":{}}"#;
657            let result = parse_versioned::<TestData>(bytes, 1);
658            assert!(result.is_err());
659            let err = result.unwrap_err().to_string();
660            assert!(
661                err.contains("malformed"),
662                "error should mention malformed envelope: {err}"
663            );
664        }
665
666        #[test]
667        fn parse_versioned_version_zero_is_valid() {
668            // Version 0 is a legitimate version (initial schema).
669            let bytes = br#"{"__sv":0,"data":{"name":"v0","count":0}}"#;
670            let result = parse_versioned::<TestData>(bytes, 0).unwrap();
671            assert!(matches!(result, Versioned::Current(_)));
672        }
673
674        #[test]
675        fn parse_versioned_invalid_json_returns_error() {
676            let result = parse_versioned::<TestData>(b"not json", 1);
677            assert!(result.is_err());
678        }
679    }
680}
681
682/// The HTTP Airlock — External Network Requests
683pub mod http {
684    use super::*;
685
686    /// Issue a raw HTTP request. The `request_bytes` payload format depends on the Kernel's expectation
687    /// (e.g. JSON or MsgPack representation of the HTTP request).
688    pub fn request_bytes(request_bytes: &[u8]) -> Result<Vec<u8>, SysError> {
689        let result = unsafe { astrid_http_request(request_bytes.to_vec())? };
690        Ok(result)
691    }
692
693    /// Represents an active streaming HTTP response.
694    ///
695    /// Must be explicitly closed via [`stream_close`] when done.
696    /// Not `Clone` — each handle is a unique owner of the host-side resource.
697    #[derive(Debug)]
698    pub struct HttpStreamHandle(String);
699
700    /// Metadata returned when a streaming HTTP request is initiated.
701    pub struct StreamStartResponse {
702        /// The handle to use for subsequent [`stream_read`] / [`stream_close`] calls.
703        pub handle: HttpStreamHandle,
704        /// HTTP status code.
705        pub status: u16,
706        /// Response headers.
707        pub headers: std::collections::HashMap<String, String>,
708    }
709
710    /// Start a streaming HTTP request.
711    ///
712    /// Sends the request and waits for the status/headers to arrive.
713    /// Returns a [`StreamStartResponse`] with the handle, status, and headers.
714    /// Use [`stream_read`] to consume the body in chunks.
715    pub fn stream_start(request_bytes: &[u8]) -> Result<StreamStartResponse, SysError> {
716        let result = unsafe { astrid_http_stream_start(request_bytes.to_vec())? };
717
718        #[derive(serde::Deserialize)]
719        struct Resp {
720            handle: String,
721            status: u16,
722            headers: std::collections::HashMap<String, String>,
723        }
724        let resp: Resp = serde_json::from_slice(&result)?;
725        Ok(StreamStartResponse {
726            handle: HttpStreamHandle(resp.handle),
727            status: resp.status,
728            headers: resp.headers,
729        })
730    }
731
732    /// Read the next chunk from a streaming HTTP response.
733    ///
734    /// Returns `Ok(Some(bytes))` with the next chunk of data, or
735    /// `Ok(None)` when the stream is exhausted (EOF).
736    pub fn stream_read(stream: &HttpStreamHandle) -> Result<Option<Vec<u8>>, SysError> {
737        let result = unsafe { astrid_http_stream_read(stream.0.as_bytes().to_vec())? };
738        if result.is_empty() {
739            Ok(None)
740        } else {
741            Ok(Some(result))
742        }
743    }
744
745    /// Close a streaming HTTP response, releasing host-side resources.
746    ///
747    /// Idempotent — closing an already-closed handle is a no-op.
748    pub fn stream_close(stream: &HttpStreamHandle) -> Result<(), SysError> {
749        unsafe { astrid_http_stream_close(stream.0.as_bytes().to_vec())? };
750        Ok(())
751    }
752}
753
754/// The Cron Airlock — Dynamic Background Scheduling
755pub mod cron {
756    use super::*;
757
758    /// Schedule a dynamic cron job that will wake up this capsule.
759    pub fn schedule(
760        name: impl AsRef<[u8]>,
761        schedule: impl AsRef<[u8]>,
762        payload: &[u8],
763    ) -> Result<(), SysError> {
764        unsafe {
765            astrid_cron_schedule(
766                name.as_ref().to_vec(),
767                schedule.as_ref().to_vec(),
768                payload.to_vec(),
769            )?
770        };
771        Ok(())
772    }
773
774    /// Cancel a previously scheduled dynamic cron job.
775    pub fn cancel(name: impl AsRef<[u8]>) -> Result<(), SysError> {
776        unsafe { astrid_cron_cancel(name.as_ref().to_vec())? };
777        Ok(())
778    }
779}
780
781/// Capsule configuration (like `std::env`).
782///
783/// In the Astrid model, capsule config entries are the equivalent of
784/// environment variables. The kernel injects them at load time.
785pub mod env {
786    use super::*;
787
788    /// Well-known config key for the kernel's Unix domain socket path.
789    pub const CONFIG_SOCKET_PATH: &str = "ASTRID_SOCKET_PATH";
790
791    /// Read a config value as raw bytes. Like `std::env::var_os`.
792    pub fn var_bytes(key: impl AsRef<[u8]>) -> Result<Vec<u8>, SysError> {
793        let result = unsafe { astrid_get_config(key.as_ref().to_vec())? };
794        Ok(result)
795    }
796
797    /// Read a config value as a UTF-8 string. Like `std::env::var`.
798    pub fn var(key: impl AsRef<[u8]>) -> Result<String, SysError> {
799        let bytes = var_bytes(key)?;
800        String::from_utf8(bytes).map_err(|e| SysError::ApiError(e.to_string()))
801    }
802}
803
804/// Wall-clock access (like `std::time`).
805pub mod time {
806    use super::*;
807
808    /// Returns the current wall-clock time as milliseconds since the UNIX epoch.
809    ///
810    /// This is a host call - the WASM guest has no direct access to system time.
811    /// Returns 0 if the host clock is unavailable.
812    pub fn now_ms() -> Result<u64, SysError> {
813        let bytes = unsafe { astrid_clock_ms()? };
814        let s = String::from_utf8_lossy(&bytes);
815        s.trim()
816            .parse::<u64>()
817            .map_err(|e| SysError::ApiError(format!("clock_ms parse error: {e}")))
818    }
819}
820
821/// Structured logging.
822pub mod log {
823    use super::*;
824
825    /// Log a message at the given level.
826    pub fn log(level: impl AsRef<[u8]>, message: impl AsRef<[u8]>) -> Result<(), SysError> {
827        unsafe { astrid_log(level.as_ref().to_vec(), message.as_ref().to_vec())? };
828        Ok(())
829    }
830
831    /// Log at DEBUG level.
832    pub fn debug(message: impl AsRef<[u8]>) -> Result<(), SysError> {
833        log("debug", message)
834    }
835
836    /// Log at INFO level.
837    pub fn info(message: impl AsRef<[u8]>) -> Result<(), SysError> {
838        log("info", message)
839    }
840
841    /// Log at WARN level.
842    pub fn warn(message: impl AsRef<[u8]>) -> Result<(), SysError> {
843        log("warn", message)
844    }
845
846    /// Log at ERROR level.
847    pub fn error(message: impl AsRef<[u8]>) -> Result<(), SysError> {
848        log("error", message)
849    }
850}
851
852/// OS runtime introspection and signaling.
853pub mod runtime {
854    use super::*;
855
856    /// Signal that the capsule's run loop is ready.
857    ///
858    /// Call this after setting up IPC subscriptions in `run()` to let the
859    /// kernel know this capsule is ready to receive events. The kernel waits
860    /// for this signal before loading dependent capsules.
861    pub fn signal_ready() -> Result<(), SysError> {
862        unsafe { astrid_signal_ready()? };
863        Ok(())
864    }
865
866    /// Retrieves the caller context (User ID and Session ID) for the current execution.
867    pub fn caller() -> Result<crate::types::CallerContext, SysError> {
868        let bytes = unsafe { astrid_get_caller()? };
869        serde_json::from_slice(&bytes)
870            .map_err(|e| SysError::ApiError(format!("failed to parse caller context: {e}")))
871    }
872
873    /// Returns the kernel's Unix domain socket path.
874    ///
875    /// Reads from the well-known `ASTRID_SOCKET_PATH` config key that the
876    /// kernel injects into every capsule at load time.
877    pub fn socket_path() -> Result<String, SysError> {
878        let raw = crate::env::var(crate::env::CONFIG_SOCKET_PATH)?;
879        // var() returns JSON-encoded values (quoted strings).
880        // Use proper JSON parsing to handle escape sequences correctly.
881        let path = serde_json::from_str::<String>(raw.trim()).or_else(|_| {
882            // Fallback: if the value isn't valid JSON, use it raw.
883            if raw.is_empty() {
884                Err(SysError::ApiError(
885                    "ASTRID_SOCKET_PATH config key is empty".to_string(),
886                ))
887            } else {
888                Ok(raw)
889            }
890        })?;
891        // Reject paths with null bytes - they would silently truncate at the OS level.
892        if path.contains('\0') {
893            return Err(SysError::ApiError(
894                "ASTRID_SOCKET_PATH contains null byte".to_string(),
895            ));
896        }
897        Ok(path)
898    }
899}
900
901/// The Hooks Airlock — Executing User Middleware
902pub mod hooks {
903    use super::*;
904
905    pub fn trigger(event_bytes: &[u8]) -> Result<Vec<u8>, SysError> {
906        unsafe { Ok(astrid_trigger_hook(event_bytes.to_vec())?) }
907    }
908}
909
910/// Cross-capsule capability queries.
911///
912/// Allows a capsule to check whether another capsule (identified by its
913/// IPC session UUID) has a specific manifest capability. Used by the
914/// prompt builder to enforce `allow_prompt_injection` gating.
915pub mod capabilities {
916    use super::*;
917
918    /// Check whether a capsule has a specific capability.
919    ///
920    /// Returns `true` if the capsule identified by `source_uuid` has the
921    /// given `capability` declared in its manifest. Returns `false` for
922    /// unknown UUIDs, unknown capabilities, or on any error (fail-closed).
923    pub fn check(source_uuid: &str, capability: &str) -> Result<bool, SysError> {
924        let request = serde_json::json!({
925            "source_uuid": source_uuid,
926            "capability": capability,
927        });
928        let request_bytes = serde_json::to_vec(&request)?;
929        let response_bytes = unsafe { astrid_check_capsule_capability(request_bytes)? };
930        let response: serde_json::Value = serde_json::from_slice(&response_bytes)?;
931        Ok(response["allowed"].as_bool().unwrap_or(false))
932    }
933}
934
935pub mod net;
936pub mod process {
937    use super::*;
938    use serde::{Deserialize, Serialize};
939
940    /// Request payload for spawning a host process.
941    #[derive(Debug, Serialize)]
942    pub struct ProcessRequest<'a> {
943        pub cmd: &'a str,
944        pub args: &'a [&'a str],
945    }
946
947    /// Result returned from a spawned host process.
948    #[derive(Debug, Deserialize)]
949    pub struct ProcessResult {
950        pub stdout: String,
951        pub stderr: String,
952        pub exit_code: i32,
953    }
954
955    /// Spawns a native host process (blocks until completion).
956    /// The Capsule must have the `host_process` capability granted for this command.
957    pub fn spawn(cmd: &str, args: &[&str]) -> Result<ProcessResult, SysError> {
958        let req = ProcessRequest { cmd, args };
959        let req_bytes = serde_json::to_vec(&req)?;
960        let result_bytes = unsafe { astrid_spawn_host(req_bytes)? };
961        let result: ProcessResult = serde_json::from_slice(&result_bytes)?;
962        Ok(result)
963    }
964
965    // -------------------------------------------------------------------
966    // Background process management
967    // -------------------------------------------------------------------
968
969    /// Handle returned when a background process is spawned.
970    #[derive(Debug, Deserialize)]
971    pub struct BackgroundProcessHandle {
972        /// Opaque handle ID (not an OS PID).
973        pub id: u64,
974    }
975
976    /// Buffered logs and status from a background process.
977    #[derive(Debug, Deserialize)]
978    pub struct ProcessLogs {
979        /// New stdout output since the last read.
980        pub stdout: String,
981        /// New stderr output since the last read.
982        pub stderr: String,
983        /// Whether the process is still running.
984        pub running: bool,
985        /// Exit code if the process has exited.
986        pub exit_code: Option<i32>,
987    }
988
989    /// Result from killing a background process.
990    #[derive(Debug, Deserialize)]
991    pub struct KillResult {
992        /// Whether the process was successfully killed.
993        pub killed: bool,
994        /// Exit code of the terminated process.
995        pub exit_code: Option<i32>,
996        /// Any remaining buffered stdout.
997        pub stdout: String,
998        /// Any remaining buffered stderr.
999        pub stderr: String,
1000    }
1001
1002    /// Spawn a background host process.
1003    ///
1004    /// Returns an opaque handle that can be used with [`read_logs`] and
1005    /// [`kill`]. The process runs sandboxed with piped stdout/stderr.
1006    pub fn spawn_background(cmd: &str, args: &[&str]) -> Result<BackgroundProcessHandle, SysError> {
1007        let req = ProcessRequest { cmd, args };
1008        let req_bytes = serde_json::to_vec(&req)?;
1009        let result_bytes = unsafe { astrid_spawn_background_host(req_bytes)? };
1010        let result: BackgroundProcessHandle = serde_json::from_slice(&result_bytes)?;
1011        Ok(result)
1012    }
1013
1014    /// Read buffered output from a background process.
1015    ///
1016    /// Each call drains the buffer and returns only NEW output since the
1017    /// last read. Also reports whether the process is still running.
1018    pub fn read_logs(id: u64) -> Result<ProcessLogs, SysError> {
1019        #[derive(Serialize)]
1020        struct Req {
1021            id: u64,
1022        }
1023        let req_bytes = serde_json::to_vec(&Req { id })?;
1024        let result_bytes = unsafe { astrid_read_process_logs_host(req_bytes)? };
1025        let result: ProcessLogs = serde_json::from_slice(&result_bytes)?;
1026        Ok(result)
1027    }
1028
1029    /// Kill a background process and release its resources.
1030    ///
1031    /// Returns any remaining buffered output along with the exit code.
1032    pub fn kill(id: u64) -> Result<KillResult, SysError> {
1033        #[derive(Serialize)]
1034        struct Req {
1035            id: u64,
1036        }
1037        let req_bytes = serde_json::to_vec(&Req { id })?;
1038        let result_bytes = unsafe { astrid_kill_process_host(req_bytes)? };
1039        let result: KillResult = serde_json::from_slice(&result_bytes)?;
1040        Ok(result)
1041    }
1042}
1043
1044/// The Elicit Airlock - User Input During Install/Upgrade Lifecycle
1045///
1046/// These functions are only callable during `#[astrid::install]` and
1047/// `#[astrid::upgrade]` hooks. Calling them from a tool or interceptor
1048/// returns a host error.
1049pub mod elicit {
1050    use super::*;
1051
1052    /// Internal request structure sent to the `astrid_elicit` host function.
1053    #[derive(Serialize)]
1054    struct ElicitRequest<'a> {
1055        #[serde(rename = "type")]
1056        kind: &'a str,
1057        key: &'a str,
1058        #[serde(skip_serializing_if = "Option::is_none")]
1059        description: Option<&'a str>,
1060        #[serde(skip_serializing_if = "Option::is_none")]
1061        options: Option<&'a [&'a str]>,
1062        #[serde(skip_serializing_if = "Option::is_none")]
1063        default: Option<&'a str>,
1064    }
1065
1066    /// Validates that the elicit key is non-empty and not whitespace-only.
1067    fn validate_key(key: &str) -> Result<(), SysError> {
1068        if key.trim().is_empty() {
1069            return Err(SysError::ApiError("elicit key must not be empty".into()));
1070        }
1071        Ok(())
1072    }
1073
1074    /// Store a secret via the kernel's `SecretStore`. The capsule **never**
1075    /// receives the value. Returns `Ok(())` confirming the user provided it.
1076    pub fn secret(key: &str, description: &str) -> Result<(), SysError> {
1077        validate_key(key)?;
1078        let req = ElicitRequest {
1079            kind: "secret",
1080            key,
1081            description: Some(description),
1082            options: None,
1083            default: None,
1084        };
1085        let req_bytes = serde_json::to_vec(&req)?;
1086        // SAFETY: FFI call to Extism host function. The host validates the
1087        // request and returns a well-formed JSON response or an Extism error.
1088        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1089
1090        #[derive(serde::Deserialize)]
1091        struct SecretResp {
1092            ok: bool,
1093        }
1094        let resp: SecretResp = serde_json::from_slice(&resp_bytes)?;
1095        if !resp.ok {
1096            return Err(SysError::ApiError(
1097                "kernel did not confirm secret storage".into(),
1098            ));
1099        }
1100        Ok(())
1101    }
1102
1103    /// Check if a secret has been configured (without reading it).
1104    pub fn has_secret(key: &str) -> Result<bool, SysError> {
1105        validate_key(key)?;
1106        #[derive(Serialize)]
1107        struct HasSecretRequest<'a> {
1108            key: &'a str,
1109        }
1110        let req_bytes = serde_json::to_vec(&HasSecretRequest { key })?;
1111        // SAFETY: FFI call to Extism host function. The host checks the
1112        // SecretStore and returns a JSON response or an Extism error.
1113        let resp_bytes = unsafe { astrid_has_secret(req_bytes)? };
1114
1115        #[derive(serde::Deserialize)]
1116        struct ExistsResp {
1117            exists: bool,
1118        }
1119        let resp: ExistsResp = serde_json::from_slice(&resp_bytes)?;
1120        Ok(resp.exists)
1121    }
1122
1123    /// Shared implementation for text elicitation with optional default.
1124    fn elicit_text(
1125        key: &str,
1126        description: &str,
1127        default: Option<&str>,
1128    ) -> Result<String, SysError> {
1129        validate_key(key)?;
1130        let req = ElicitRequest {
1131            kind: "text",
1132            key,
1133            description: Some(description),
1134            options: None,
1135            default,
1136        };
1137        let req_bytes = serde_json::to_vec(&req)?;
1138        // SAFETY: FFI call to Extism host function. The host validates the
1139        // request and returns a well-formed JSON response or an Extism error.
1140        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1141
1142        #[derive(serde::Deserialize)]
1143        struct TextResp {
1144            value: String,
1145        }
1146        let resp: TextResp = serde_json::from_slice(&resp_bytes)?;
1147        Ok(resp.value)
1148    }
1149
1150    /// Prompt for a text value. Blocks until the user responds.
1151    /// Use [`secret()`] for sensitive data - this returns the value to the capsule.
1152    pub fn text(key: &str, description: &str) -> Result<String, SysError> {
1153        elicit_text(key, description, None)
1154    }
1155
1156    /// Prompt with a default value pre-filled.
1157    pub fn text_with_default(
1158        key: &str,
1159        description: &str,
1160        default: &str,
1161    ) -> Result<String, SysError> {
1162        elicit_text(key, description, Some(default))
1163    }
1164
1165    /// Prompt for a selection from a list. Returns the selected value.
1166    pub fn select(key: &str, description: &str, options: &[&str]) -> Result<String, SysError> {
1167        validate_key(key)?;
1168        if options.is_empty() {
1169            return Err(SysError::ApiError(
1170                "select requires at least one option".into(),
1171            ));
1172        }
1173        let req = ElicitRequest {
1174            kind: "select",
1175            key,
1176            description: Some(description),
1177            options: Some(options),
1178            default: None,
1179        };
1180        let req_bytes = serde_json::to_vec(&req)?;
1181        // SAFETY: FFI call to Extism host function. The host validates the
1182        // request and returns a well-formed JSON response or an Extism error.
1183        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1184
1185        #[derive(serde::Deserialize)]
1186        struct SelectResp {
1187            value: String,
1188        }
1189        let resp: SelectResp = serde_json::from_slice(&resp_bytes)?;
1190        if !options.iter().any(|o| *o == resp.value) {
1191            let truncated: String = resp.value.chars().take(64).collect();
1192            return Err(SysError::ApiError(format!(
1193                "host returned value '{truncated}' not in provided options",
1194            )));
1195        }
1196        Ok(resp.value)
1197    }
1198
1199    /// Prompt for multiple text values (array input).
1200    pub fn array(key: &str, description: &str) -> Result<Vec<String>, SysError> {
1201        validate_key(key)?;
1202        let req = ElicitRequest {
1203            kind: "array",
1204            key,
1205            description: Some(description),
1206            options: None,
1207            default: None,
1208        };
1209        let req_bytes = serde_json::to_vec(&req)?;
1210        // SAFETY: FFI call to Extism host function. The host validates the
1211        // request and returns a well-formed JSON response or an Extism error.
1212        let resp_bytes = unsafe { astrid_elicit(req_bytes)? };
1213
1214        #[derive(serde::Deserialize)]
1215        struct ArrayResp {
1216            values: Vec<String>,
1217        }
1218        let resp: ArrayResp = serde_json::from_slice(&resp_bytes)?;
1219        Ok(resp.values)
1220    }
1221}
1222
1223/// Auto-subscribed interceptor bindings for run-loop capsules.
1224///
1225/// When a capsule declares both `run()` and `[[interceptor]]`, the runtime
1226/// auto-subscribes to each interceptor's topic and delivers events through
1227/// the IPC channel the run loop already reads from. This module provides
1228/// helpers to query the subscription mappings and dispatch events by action.
1229pub mod interceptors {
1230    use super::*;
1231
1232    /// A single interceptor subscription binding.
1233    #[derive(Debug, serde::Deserialize)]
1234    pub struct InterceptorBinding {
1235        /// The IPC subscription handle ID (as bytes for use with `ipc::poll_bytes`/`ipc::recv_bytes`).
1236        pub handle_id: u64,
1237        /// The interceptor action name from the manifest.
1238        pub action: String,
1239        /// The event topic this interceptor subscribes to.
1240        pub topic: String,
1241    }
1242
1243    impl InterceptorBinding {
1244        /// Return a subscription handle for use with `ipc::poll_bytes` / `ipc::recv_bytes`.
1245        #[must_use]
1246        pub fn subscription_handle(&self) -> ipc::SubscriptionHandle {
1247            ipc::SubscriptionHandle(self.handle_id.to_string().into_bytes())
1248        }
1249
1250        /// Return the raw handle ID bytes (for lower-level interop).
1251        #[must_use]
1252        pub fn handle_bytes(&self) -> Vec<u8> {
1253            self.handle_id.to_string().into_bytes()
1254        }
1255    }
1256
1257    /// Query the runtime for auto-subscribed interceptor handles.
1258    ///
1259    /// Returns an empty vec if this capsule has no auto-subscribed interceptors
1260    /// (i.e. it does not have both `run()` and `[[interceptor]]`).
1261    pub fn bindings() -> Result<Vec<InterceptorBinding>, SysError> {
1262        // SAFETY: FFI call to Extism host function. The host serializes
1263        // `HostState.interceptor_handles` to JSON and returns valid UTF-8 bytes.
1264        // Errors are propagated via the `?` operator.
1265        let bytes = unsafe { astrid_get_interceptor_handles()? };
1266        let bindings: Vec<InterceptorBinding> = serde_json::from_slice(&bytes)?;
1267        Ok(bindings)
1268    }
1269
1270    /// Poll all interceptor subscriptions and dispatch pending events.
1271    ///
1272    /// For each binding with pending messages, calls
1273    /// `handler(action, envelope_bytes)` once with the full batch envelope
1274    /// (JSON with `messages` array, `dropped`, and `lagged` fields).
1275    /// Bindings with no pending messages are skipped.
1276    pub fn poll(
1277        bindings: &[InterceptorBinding],
1278        mut handler: impl FnMut(&str, &[u8]),
1279    ) -> Result<(), SysError> {
1280        #[derive(serde::Deserialize)]
1281        struct PollEnvelope {
1282            messages: Vec<serde_json::Value>,
1283        }
1284
1285        for binding in bindings {
1286            let handle = binding.subscription_handle();
1287            let envelope = ipc::poll_bytes(&handle)?;
1288
1289            // poll_bytes always returns a JSON envelope like
1290            // `{"messages":[],"dropped":0,"lagged":0}`. Check the
1291            // messages array before calling the handler.
1292            let parsed: PollEnvelope = serde_json::from_slice(&envelope)?;
1293            if !parsed.messages.is_empty() {
1294                handler(&binding.action, &envelope);
1295            }
1296        }
1297        Ok(())
1298    }
1299}
1300
1301/// Request human approval for sensitive actions from within a capsule.
1302///
1303/// Any capsule can call [`approval::request`] to block until the frontend
1304/// user approves or denies an action. The host function checks the
1305/// `AllowanceStore` for a matching pattern first (instant path), and only
1306/// prompts the user when no allowance exists.
1307///
1308/// # Example
1309///
1310/// ```ignore
1311/// use astrid_sdk::prelude::*;
1312///
1313/// let result = approval::request("git push", "git push origin main", "high")?;
1314/// if !result.approved {
1315///     return Err(SysError::ApiError("Action denied by user".into()));
1316/// }
1317/// ```
1318/// Platform identity resolution and linking.
1319///
1320/// Capsules use this module to resolve platform-specific user identities
1321/// (e.g. Discord user IDs, Twitch usernames) to Astrid-native user IDs,
1322/// and to manage the links between them.
1323///
1324/// Requires the `identity` capability in `Capsule.toml`:
1325/// - `["resolve"]` - resolve platform users
1326/// - `["link"]` - resolve, link, unlink, and list links
1327/// - `["admin"]` - all of the above plus create new users
1328pub mod identity {
1329    use super::*;
1330
1331    /// A resolved Astrid user returned by [`resolve`].
1332    #[derive(Debug)]
1333    pub struct ResolvedUser {
1334        /// The Astrid-native user ID (UUID).
1335        pub user_id: String,
1336        /// Optional display name.
1337        pub display_name: Option<String>,
1338    }
1339
1340    /// A platform-to-Astrid identity link.
1341    #[derive(Debug)]
1342    pub struct Link {
1343        /// Platform name (e.g. "discord", "twitch").
1344        pub platform: String,
1345        /// Platform-specific user identifier.
1346        pub platform_user_id: String,
1347        /// The Astrid user this is linked to.
1348        pub astrid_user_id: String,
1349        /// When the link was created (RFC 3339).
1350        pub linked_at: String,
1351        /// How the link was established (e.g. "system", "chat_command").
1352        pub method: String,
1353    }
1354
1355    /// Resolve a platform user to an Astrid user.
1356    ///
1357    /// Returns `Ok(Some(user))` if the platform identity is linked,
1358    /// `Ok(None)` if not found. Requires `identity = ["resolve"]` or higher.
1359    pub fn resolve(
1360        platform: &str,
1361        platform_user_id: &str,
1362    ) -> Result<Option<ResolvedUser>, SysError> {
1363        #[derive(Serialize)]
1364        struct Req<'a> {
1365            platform: &'a str,
1366            platform_user_id: &'a str,
1367        }
1368
1369        let req_bytes = serde_json::to_vec(&Req {
1370            platform,
1371            platform_user_id,
1372        })?;
1373
1374        // SAFETY: FFI call to Extism host function.
1375        let resp_bytes = unsafe { astrid_identity_resolve(req_bytes)? };
1376
1377        #[derive(Deserialize)]
1378        struct Resp {
1379            found: bool,
1380            user_id: Option<String>,
1381            display_name: Option<String>,
1382            error: Option<String>,
1383        }
1384        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1385        if resp.found {
1386            let user_id = resp.user_id.ok_or_else(|| {
1387                SysError::ApiError("host returned found=true but user_id was missing".into())
1388            })?;
1389            Ok(Some(ResolvedUser {
1390                user_id,
1391                display_name: resp.display_name,
1392            }))
1393        } else if let Some(err) = resp.error {
1394            Err(SysError::ApiError(err))
1395        } else {
1396            Ok(None)
1397        }
1398    }
1399
1400    /// Link a platform identity to an Astrid user.
1401    ///
1402    /// - `method` describes how the link was established (e.g. "chat_command", "system").
1403    ///
1404    /// Returns the created link on success. Requires `identity = ["link"]` or higher.
1405    pub fn link(
1406        platform: &str,
1407        platform_user_id: &str,
1408        astrid_user_id: &str,
1409        method: &str,
1410    ) -> Result<Link, SysError> {
1411        #[derive(Serialize)]
1412        struct Req<'a> {
1413            platform: &'a str,
1414            platform_user_id: &'a str,
1415            astrid_user_id: &'a str,
1416            method: &'a str,
1417        }
1418
1419        let req_bytes = serde_json::to_vec(&Req {
1420            platform,
1421            platform_user_id,
1422            astrid_user_id,
1423            method,
1424        })?;
1425
1426        // SAFETY: FFI call to Extism host function.
1427        let resp_bytes = unsafe { astrid_identity_link(req_bytes)? };
1428
1429        #[derive(Deserialize)]
1430        struct LinkInfo {
1431            platform: String,
1432            platform_user_id: String,
1433            astrid_user_id: String,
1434            linked_at: String,
1435            method: String,
1436        }
1437        #[derive(Deserialize)]
1438        struct Resp {
1439            ok: bool,
1440            error: Option<String>,
1441            link: Option<LinkInfo>,
1442        }
1443        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1444        if !resp.ok {
1445            return Err(SysError::ApiError(
1446                resp.error.unwrap_or_else(|| "identity link failed".into()),
1447            ));
1448        }
1449        let l = resp
1450            .link
1451            .ok_or_else(|| SysError::ApiError("missing link in response".into()))?;
1452        Ok(Link {
1453            platform: l.platform,
1454            platform_user_id: l.platform_user_id,
1455            astrid_user_id: l.astrid_user_id,
1456            linked_at: l.linked_at,
1457            method: l.method,
1458        })
1459    }
1460
1461    /// Unlink a platform identity from its Astrid user.
1462    ///
1463    /// Returns `true` if a link was removed, `false` if none existed.
1464    /// Requires `identity = ["link"]` or higher.
1465    pub fn unlink(platform: &str, platform_user_id: &str) -> Result<bool, SysError> {
1466        #[derive(Serialize)]
1467        struct Req<'a> {
1468            platform: &'a str,
1469            platform_user_id: &'a str,
1470        }
1471
1472        let req_bytes = serde_json::to_vec(&Req {
1473            platform,
1474            platform_user_id,
1475        })?;
1476
1477        // SAFETY: FFI call to Extism host function.
1478        let resp_bytes = unsafe { astrid_identity_unlink(req_bytes)? };
1479
1480        #[derive(Deserialize)]
1481        struct Resp {
1482            ok: bool,
1483            error: Option<String>,
1484            removed: Option<bool>,
1485        }
1486        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1487        if !resp.ok {
1488            return Err(SysError::ApiError(
1489                resp.error
1490                    .unwrap_or_else(|| "identity unlink failed".into()),
1491            ));
1492        }
1493        Ok(resp.removed.unwrap_or(false))
1494    }
1495
1496    /// Create a new Astrid user.
1497    ///
1498    /// Returns the UUID of the newly created user.
1499    /// Requires `identity = ["admin"]`.
1500    pub fn create_user(display_name: Option<&str>) -> Result<String, SysError> {
1501        #[derive(Serialize)]
1502        struct Req<'a> {
1503            display_name: Option<&'a str>,
1504        }
1505
1506        let req_bytes = serde_json::to_vec(&Req { display_name })?;
1507
1508        // SAFETY: FFI call to Extism host function.
1509        let resp_bytes = unsafe { astrid_identity_create_user(req_bytes)? };
1510
1511        #[derive(Deserialize)]
1512        struct Resp {
1513            ok: bool,
1514            error: Option<String>,
1515            user_id: Option<String>,
1516        }
1517        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1518        if !resp.ok {
1519            return Err(SysError::ApiError(
1520                resp.error
1521                    .unwrap_or_else(|| "identity create_user failed".into()),
1522            ));
1523        }
1524        resp.user_id
1525            .ok_or_else(|| SysError::ApiError("missing user_id in response".into()))
1526    }
1527
1528    /// List all platform links for an Astrid user.
1529    ///
1530    /// Returns all linked platform identities for the given user UUID.
1531    /// Requires `identity = ["link"]` or higher.
1532    pub fn list_links(astrid_user_id: &str) -> Result<Vec<Link>, SysError> {
1533        #[derive(Serialize)]
1534        struct Req<'a> {
1535            astrid_user_id: &'a str,
1536        }
1537
1538        let req_bytes = serde_json::to_vec(&Req { astrid_user_id })?;
1539
1540        // SAFETY: FFI call to Extism host function.
1541        let resp_bytes = unsafe { astrid_identity_list_links(req_bytes)? };
1542
1543        #[derive(Deserialize)]
1544        struct LinkInfo {
1545            platform: String,
1546            platform_user_id: String,
1547            astrid_user_id: String,
1548            linked_at: String,
1549            method: String,
1550        }
1551        #[derive(Deserialize)]
1552        struct Resp {
1553            ok: bool,
1554            error: Option<String>,
1555            links: Option<Vec<LinkInfo>>,
1556        }
1557        let resp: Resp = serde_json::from_slice(&resp_bytes)?;
1558        if !resp.ok {
1559            return Err(SysError::ApiError(
1560                resp.error
1561                    .unwrap_or_else(|| "identity list_links failed".into()),
1562            ));
1563        }
1564        Ok(resp
1565            .links
1566            .unwrap_or_default()
1567            .into_iter()
1568            .map(|l| Link {
1569                platform: l.platform,
1570                platform_user_id: l.platform_user_id,
1571                astrid_user_id: l.astrid_user_id,
1572                linked_at: l.linked_at,
1573                method: l.method,
1574            })
1575            .collect())
1576    }
1577}
1578
1579pub mod approval {
1580    use super::*;
1581
1582    /// The result of an approval request.
1583    #[derive(Debug)]
1584    pub struct ApprovalResult {
1585        /// Whether the action was approved.
1586        pub approved: bool,
1587        /// The decision string: "approve", "approve_session",
1588        /// "approve_always", "deny", or "allowance" (auto-approved).
1589        pub decision: String,
1590    }
1591
1592    /// Request human approval for a sensitive action.
1593    ///
1594    /// Blocks the capsule until the frontend user responds or the request
1595    /// times out. If an existing allowance matches, returns immediately
1596    /// without prompting.
1597    ///
1598    /// - `action` - short description of the action (e.g. "git push")
1599    /// - `resource` - full resource identifier (e.g. "git push origin main")
1600    /// - `risk_level` - one of "low", "medium", "high", "critical"
1601    pub fn request(
1602        action: &str,
1603        resource: &str,
1604        risk_level: &str,
1605    ) -> Result<ApprovalResult, SysError> {
1606        #[derive(Serialize)]
1607        struct ApprovalRequest<'a> {
1608            action: &'a str,
1609            resource: &'a str,
1610            risk_level: &'a str,
1611        }
1612
1613        let req = ApprovalRequest {
1614            action,
1615            resource,
1616            risk_level,
1617        };
1618        let req_bytes = serde_json::to_vec(&req)?;
1619
1620        // SAFETY: FFI call to Extism host function. The host checks the
1621        // AllowanceStore, publishes ApprovalRequired if needed, blocks
1622        // until a response arrives, and returns a JSON result.
1623        let resp_bytes = unsafe { astrid_request_approval(req_bytes)? };
1624
1625        #[derive(Deserialize)]
1626        struct ApprovalResp {
1627            approved: bool,
1628            decision: String,
1629        }
1630        let resp: ApprovalResp = serde_json::from_slice(&resp_bytes)?;
1631        Ok(ApprovalResult {
1632            approved: resp.approved,
1633            decision: resp.decision,
1634        })
1635    }
1636}
1637
1638pub mod prelude {
1639    pub use crate::{
1640        SysError,
1641        // Astrid-specific modules
1642        approval,
1643        capabilities,
1644        cron,
1645        elicit,
1646        // std-mirrored modules
1647        env,
1648        fs,
1649        hooks,
1650        http,
1651        identity,
1652        interceptors,
1653        ipc,
1654        kv,
1655        log,
1656        net,
1657        process,
1658        runtime,
1659        time,
1660        uplink,
1661    };
1662
1663    #[cfg(feature = "derive")]
1664    pub use astrid_sdk_macros::capsule;
1665}