|
1
|
+ |
//! The owned Rust PDK for OpenAgents WASM plugins.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! A plugin author writes one function over serde types:
|
|
4
|
+ |
//!
|
|
5
|
+ |
//! ```ignore
|
|
6
|
+ |
//! use openagents_pdk::{plugin_entry, Refusal};
|
|
7
|
+ |
//! use serde::{Deserialize, Serialize};
|
|
8
|
+ |
//!
|
|
9
|
+ |
//! #[derive(Deserialize)]
|
|
10
|
+ |
//! struct Input { text: String }
|
|
11
|
+ |
//!
|
|
12
|
+ |
//! #[derive(Serialize)]
|
|
13
|
+ |
//! struct Output { chars: usize }
|
|
14
|
+ |
//!
|
|
15
|
+ |
//! fn handle(input: Input) -> Result<Output, Refusal> {
|
|
16
|
+ |
//! Ok(Output { chars: input.text.chars().count() })
|
|
17
|
+ |
//! }
|
|
18
|
+ |
//!
|
|
19
|
+ |
//! plugin_entry!(handle);
|
|
20
|
+ |
//! ```
|
|
21
|
+ |
//!
|
|
22
|
+ |
//! and the [`plugin_entry!`] macro generates the whole `packet-v0` ABI:
|
|
23
|
+ |
//! the `packet_alloc` export the host allocates through, the
|
|
24
|
+ |
//! `handle_packet(ptr, len) -> u64` export, the serde decode of the input
|
|
25
|
+ |
//! packet, the `{"ok": ...}` / `{"refusal": ...}` envelope on the way out,
|
|
26
|
+ |
//! and the `(ptr << 32) | len` packing of the return word. Authors never
|
|
27
|
+ |
//! see a pointer.
|
|
28
|
+ |
//!
|
|
29
|
+ |
//! ## The packet-v0 contract, as this crate owns it
|
|
30
|
+ |
//!
|
|
31
|
+ |
//! - The input packet is the UTF-8 JSON encoding of the tool arguments.
|
|
32
|
+ |
//! A packet that does not decode into the handler's input type is
|
|
33
|
+ |
//! answered with a `bad_packet` refusal, not a trap.
|
|
34
|
+ |
//! - The output packet is UTF-8 JSON: `{"ok": <output>}` on success,
|
|
35
|
+ |
//! `{"refusal": {"code": ..., "reason": ...}}` otherwise. Refusals are
|
|
36
|
+ |
//! values on both sides of the boundary; the PDK never panics on bad
|
|
37
|
+ |
//! input and the handler returns `Result`, never throws.
|
|
38
|
+ |
//! - The output buffer is deliberately leaked. The host reads it
|
|
39
|
+ |
//! immediately and drops the instance after one call — one instance per
|
|
40
|
+ |
//! invocation is the host's isolation model — so a free export would be
|
|
41
|
+ |
//! ceremony.
|
|
42
|
+ |
//!
|
|
43
|
+ |
//! ## Host capabilities
|
|
44
|
+ |
//!
|
|
45
|
+ |
//! [`read_mounted_file`] is the first host import: available only when the
|
|
46
|
+ |
//! plugin's manifest declares read-only mounts, and answered by the host
|
|
47
|
+ |
//! with either the file bytes or a typed refusal (`mount_denied`,
|
|
48
|
+ |
//! `file_unreadable`, `file_too_large`). A plugin that never calls it
|
|
49
|
+ |
//! links no imports at all — the compiler strips the unused extern — so a
|
|
50
|
+ |
//! pure-compute plugin still passes the host's empty-import inspection.
|
|
51
|
+ |
|
|
52
|
+ |
use serde::de::DeserializeOwned;
|
|
53
|
+ |
use serde::Serialize;
|
|
54
|
+ |
|
|
55
|
+ |
// Re-exported so plugin crates need only `openagents-pdk` in [dependencies].
|
|
56
|
+ |
pub use serde;
|
|
57
|
+ |
pub use serde_json;
|
|
58
|
+ |
|
|
59
|
+ |
/// Why the plugin would not do what was asked. Returned, never thrown.
|
|
60
|
+ |
///
|
|
61
|
+ |
/// The code set mirrors the host's guest-visible refusal codes, so a
|
|
62
|
+ |
/// refusal born on either side of the boundary reads the same in the
|
|
63
|
+ |
/// output packet.
|
|
64
|
+ |
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
65
|
+ |
pub struct Refusal {
|
|
66
|
+ |
pub code: RefusalCode,
|
|
67
|
+ |
pub reason: String,
|
|
68
|
+ |
}
|
|
69
|
+ |
|
|
70
|
+ |
/// The closed set of guest-side refusal codes for `packet-v0`.
|
|
71
|
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
72
|
+ |
pub enum RefusalCode {
|
|
73
|
+ |
/// The input packet does not decode into the handler's input type,
|
|
74
|
+ |
/// or an output failed to encode.
|
|
75
|
+ |
BadPacket,
|
|
76
|
+ |
/// The plugin was asked for something it does not do.
|
|
77
|
+ |
Unsupported,
|
|
78
|
+ |
/// The host refused a mounted-file read: the path is outside every
|
|
79
|
+ |
/// declared mount, absolute, or reaches through a symlink.
|
|
80
|
+ |
MountDenied,
|
|
81
|
+ |
/// The host could not read the mounted file (missing, a directory,
|
|
82
|
+ |
/// or an I/O failure).
|
|
83
|
+ |
FileUnreadable,
|
|
84
|
+ |
/// The mounted file exceeds the host's per-file size bound.
|
|
85
|
+ |
FileTooLarge,
|
|
86
|
+ |
/// The plugin's own invariant broke. A bug, stated as a value.
|
|
87
|
+ |
Internal,
|
|
88
|
+ |
}
|
|
89
|
+ |
|
|
90
|
+ |
impl RefusalCode {
|
|
91
|
+ |
pub const fn as_str(self) -> &'static str {
|
|
92
|
+ |
match self {
|
|
93
|
+ |
RefusalCode::BadPacket => "bad_packet",
|
|
94
|
+ |
RefusalCode::Unsupported => "unsupported",
|
|
95
|
+ |
RefusalCode::MountDenied => "mount_denied",
|
|
96
|
+ |
RefusalCode::FileUnreadable => "file_unreadable",
|
|
97
|
+ |
RefusalCode::FileTooLarge => "file_too_large",
|
|
98
|
+ |
RefusalCode::Internal => "internal",
|
|
99
|
+ |
}
|
|
100
|
+ |
}
|
|
101
|
+ |
|
|
102
|
+ |
/// The code for a host-authored refusal packet. Unknown codes fold to
|
|
103
|
+ |
/// [`RefusalCode::Internal`]; the caller keeps the raw text in the reason.
|
|
104
|
+ |
fn parse(code: &str) -> Option<Self> {
|
|
105
|
+ |
match code {
|
|
106
|
+ |
"bad_packet" => Some(RefusalCode::BadPacket),
|
|
107
|
+ |
"unsupported" => Some(RefusalCode::Unsupported),
|
|
108
|
+ |
"mount_denied" => Some(RefusalCode::MountDenied),
|
|
109
|
+ |
"file_unreadable" => Some(RefusalCode::FileUnreadable),
|
|
110
|
+ |
"file_too_large" => Some(RefusalCode::FileTooLarge),
|
|
111
|
+ |
"internal" => Some(RefusalCode::Internal),
|
|
112
|
+ |
_ => None,
|
|
113
|
+ |
}
|
|
114
|
+ |
}
|
|
115
|
+ |
}
|
|
116
|
+ |
|
|
117
|
+ |
impl Refusal {
|
|
118
|
+ |
pub fn new(code: RefusalCode, reason: impl Into<String>) -> Self {
|
|
119
|
+ |
Refusal { code, reason: reason.into() }
|
|
120
|
+ |
}
|
|
121
|
+ |
|
|
122
|
+ |
pub fn bad_packet(reason: impl Into<String>) -> Self {
|
|
123
|
+ |
Refusal::new(RefusalCode::BadPacket, reason)
|
|
124
|
+ |
}
|
|
125
|
+ |
|
|
126
|
+ |
pub fn unsupported(reason: impl Into<String>) -> Self {
|
|
127
|
+ |
Refusal::new(RefusalCode::Unsupported, reason)
|
|
128
|
+ |
}
|
|
129
|
+ |
|
|
130
|
+ |
pub fn internal(reason: impl Into<String>) -> Self {
|
|
131
|
+ |
Refusal::new(RefusalCode::Internal, reason)
|
|
132
|
+ |
}
|
|
133
|
+ |
}
|
|
134
|
+ |
|
|
135
|
+ |
/// Encode `{"refusal": {"code": ..., "reason": ...}}` as an output packet.
|
|
136
|
+ |
pub fn refusal_packet(refusal: &Refusal) -> Vec<u8> {
|
|
137
|
+ |
serde_json::to_vec(&serde_json::json!({
|
|
138
|
+ |
"refusal": { "code": refusal.code.as_str(), "reason": refusal.reason }
|
|
139
|
+ |
}))
|
|
140
|
+ |
// The value is two strings; encoding cannot fail.
|
|
141
|
+ |
.expect("a refusal always encodes")
|
|
142
|
+ |
}
|
|
143
|
+ |
|
|
144
|
+ |
/// Decode the input packet, run the handler, encode the output envelope.
|
|
145
|
+ |
///
|
|
146
|
+ |
/// This is the whole guest side of `packet-v0` minus the pointer plumbing,
|
|
147
|
+ |
/// and it is total: every path returns a packet.
|
|
148
|
+ |
pub fn run_handler<I, O, F>(input: &[u8], handler: F) -> Vec<u8>
|
|
149
|
+ |
where
|
|
150
|
+ |
I: DeserializeOwned,
|
|
151
|
+ |
O: Serialize,
|
|
152
|
+ |
F: FnOnce(I) -> Result<O, Refusal>,
|
|
153
|
+ |
{
|
|
154
|
+ |
let parsed: I = match serde_json::from_slice(input) {
|
|
155
|
+ |
Ok(value) => value,
|
|
156
|
+ |
Err(err) => {
|
|
157
|
+ |
return refusal_packet(&Refusal::bad_packet(format!(
|
|
158
|
+ |
"the input packet does not decode: {err}"
|
|
159
|
+ |
)))
|
|
160
|
+ |
}
|
|
161
|
+ |
};
|
|
162
|
+ |
match handler(parsed) {
|
|
163
|
+ |
Ok(output) => match serde_json::to_vec(&output) {
|
|
164
|
+ |
Ok(body) => {
|
|
165
|
+ |
let mut packet = Vec::with_capacity(body.len() + 8);
|
|
166
|
+ |
packet.extend_from_slice(b"{\"ok\":");
|
|
167
|
+ |
packet.extend_from_slice(&body);
|
|
168
|
+ |
packet.push(b'}');
|
|
169
|
+ |
packet
|
|
170
|
+ |
}
|
|
171
|
+ |
Err(err) => refusal_packet(&Refusal::internal(format!(
|
|
172
|
+ |
"the output does not encode: {err}"
|
|
173
|
+ |
))),
|
|
174
|
+ |
},
|
|
175
|
+ |
Err(refusal) => refusal_packet(&refusal),
|
|
176
|
+ |
}
|
|
177
|
+ |
}
|
|
178
|
+ |
|
|
179
|
+ |
/// Read a file from one of the manifest's declared read-only mounts.
|
|
180
|
+ |
///
|
|
181
|
+ |
/// The path is relative to a mount root; the host confines it (no absolute
|
|
182
|
+ |
/// paths, no `..` escapes, no symlinks, a per-file size bound) and answers
|
|
183
|
+ |
/// with the bytes or a typed refusal. On a target other than
|
|
184
|
+ |
/// `wasm32-unknown-unknown` — the PDK's own unit tests, for example — the
|
|
185
|
+ |
/// import does not exist and this returns `unsupported`.
|
|
186
|
+ |
pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {
|
|
187
|
+ |
imp::read_mounted_file(path)
|
|
188
|
+ |
}
|
|
189
|
+ |
|
|
190
|
+ |
/// Parse a host `read_file` answer packet: one status byte, then either
|
|
191
|
+ |
/// the file bytes (0) or a `{"code", "reason"}` refusal (1).
|
|
192
|
+ |
fn parse_host_packet(packet: &[u8]) -> Result<Vec<u8>, Refusal> {
|
|
193
|
+ |
#[derive(serde::Deserialize)]
|
|
194
|
+ |
struct RawRefusal {
|
|
195
|
+ |
code: String,
|
|
196
|
+ |
reason: String,
|
|
197
|
+ |
}
|
|
198
|
+ |
match packet.split_first() {
|
|
199
|
+ |
Some((0, bytes)) => Ok(bytes.to_vec()),
|
|
200
|
+ |
Some((1, body)) => match serde_json::from_slice::<RawRefusal>(body) {
|
|
201
|
+ |
Ok(raw) => match RefusalCode::parse(&raw.code) {
|
|
202
|
+ |
Some(code) => Err(Refusal::new(code, raw.reason)),
|
|
203
|
+ |
None => Err(Refusal::internal(format!("host refusal `{}`: {}", raw.code, raw.reason))),
|
|
204
|
+ |
},
|
|
205
|
+ |
Err(_) => Err(Refusal::internal("the host's refusal packet does not decode")),
|
|
206
|
+ |
},
|
|
207
|
+ |
_ => Err(Refusal::internal("the host answered with an empty packet")),
|
|
208
|
+ |
}
|
|
209
|
+ |
}
|
|
210
|
+ |
|
|
211
|
+ |
#[cfg(target_arch = "wasm32")]
|
|
212
|
+ |
mod imp {
|
|
213
|
+ |
use super::{parse_host_packet, Refusal};
|
|
214
|
+ |
|
|
215
|
+ |
#[link(wasm_import_module = "openagents")]
|
|
216
|
+ |
extern "C" {
|
|
217
|
+ |
/// Host capability import: `(path_ptr, path_len) -> (ptr << 32) | len`
|
|
218
|
+ |
/// of an answer packet the host wrote into guest memory through
|
|
219
|
+ |
/// `packet_alloc`. Present only when the manifest declares mounts.
|
|
220
|
+ |
fn read_file(path_ptr: *const u8, path_len: u32) -> u64;
|
|
221
|
+ |
}
|
|
222
|
+ |
|
|
223
|
+ |
pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {
|
|
224
|
+ |
let packed = unsafe { read_file(path.as_ptr(), path.len() as u32) };
|
|
225
|
+ |
let ptr = (packed >> 32) as u32 as usize as *const u8;
|
|
226
|
+ |
let len = (packed & 0xffff_ffff) as usize;
|
|
227
|
+ |
if ptr.is_null() {
|
|
228
|
+ |
return Err(Refusal::internal("the host answered with a null packet"));
|
|
229
|
+ |
}
|
|
230
|
+ |
let packet = unsafe { core::slice::from_raw_parts(ptr, len) };
|
|
231
|
+ |
parse_host_packet(packet)
|
|
232
|
+ |
}
|
|
233
|
+ |
}
|
|
234
|
+ |
|
|
235
|
+ |
#[cfg(not(target_arch = "wasm32"))]
|
|
236
|
+ |
mod imp {
|
|
237
|
+ |
use super::Refusal;
|
|
238
|
+ |
|
|
239
|
+ |
pub fn read_mounted_file(_path: &str) -> Result<Vec<u8>, Refusal> {
|
|
240
|
+ |
Err(Refusal::unsupported(
|
|
241
|
+ |
"read_mounted_file is a host capability import; it exists only inside the WASM host",
|
|
242
|
+ |
))
|
|
243
|
+ |
}
|
|
244
|
+ |
}
|
|
245
|
+ |
|
|
246
|
+ |
/// The pointer plumbing behind [`plugin_entry!`]. Hidden, not private, so
|
|
247
|
+ |
/// the macro can reach it from the plugin crate.
|
|
248
|
+ |
#[doc(hidden)]
|
|
249
|
+ |
pub mod __abi {
|
|
250
|
+ |
use super::{run_handler, Refusal};
|
|
251
|
+ |
use serde::de::DeserializeOwned;
|
|
252
|
+ |
use serde::Serialize;
|
|
253
|
+ |
|
|
254
|
+ |
pub fn packet_alloc(len: u32) -> *mut u8 {
|
|
255
|
+ |
let layout = core::alloc::Layout::from_size_align(len.max(1) as usize, 1)
|
|
256
|
+ |
.expect("a byte-aligned layout is always valid");
|
|
257
|
+ |
unsafe { std::alloc::alloc(layout) }
|
|
258
|
+ |
}
|
|
259
|
+ |
|
|
260
|
+ |
/// Leak an output packet and pack its location into the return word.
|
|
261
|
+ |
pub fn pack_output(output: Vec<u8>) -> u64 {
|
|
262
|
+ |
let len = output.len() as u64;
|
|
263
|
+ |
let ptr = Box::leak(output.into_boxed_slice()).as_mut_ptr() as u64;
|
|
264
|
+ |
(ptr << 32) | len
|
|
265
|
+ |
}
|
|
266
|
+ |
|
|
267
|
+ |
/// # Safety
|
|
268
|
+ |
/// `ptr..ptr+len` must be the packet the host wrote through `packet_alloc`.
|
|
269
|
+ |
pub unsafe fn handle_packet<I, O, F>(ptr: *const u8, len: u32, handler: F) -> u64
|
|
270
|
+ |
where
|
|
271
|
+ |
I: DeserializeOwned,
|
|
272
|
+ |
O: Serialize,
|
|
273
|
+ |
F: FnOnce(I) -> Result<O, Refusal>,
|
|
274
|
+ |
{
|
|
275
|
+ |
let input = core::slice::from_raw_parts(ptr, len as usize);
|
|
276
|
+ |
pack_output(run_handler(input, handler))
|
|
277
|
+ |
}
|
|
278
|
+ |
}
|
|
279
|
+ |
|
|
280
|
+ |
/// Generate the `packet-v0` exports around one typed handler function
|
|
281
|
+ |
/// `fn(I) -> Result<O, Refusal>` where `I: Deserialize` and `O: Serialize`.
|
|
282
|
+ |
#[macro_export]
|
|
283
|
+ |
macro_rules! plugin_entry {
|
|
284
|
+ |
($handler:path) => {
|
|
285
|
+ |
#[no_mangle]
|
|
286
|
+ |
pub extern "C" fn packet_alloc(len: u32) -> *mut u8 {
|
|
287
|
+ |
$crate::__abi::packet_alloc(len)
|
|
288
|
+ |
}
|
|
289
|
+ |
|
|
290
|
+ |
#[no_mangle]
|
|
291
|
+ |
pub extern "C" fn handle_packet(ptr: *const u8, len: u32) -> u64 {
|
|
292
|
+ |
unsafe { $crate::__abi::handle_packet(ptr, len, $handler) }
|
|
293
|
+ |
}
|
|
294
|
+ |
};
|
|
295
|
+ |
}
|
|
296
|
+ |
|
|
297
|
+ |
#[cfg(test)]
|
|
298
|
+ |
mod tests {
|
|
299
|
+ |
use super::*;
|
|
300
|
+ |
use serde::{Deserialize, Serialize};
|
|
301
|
+ |
|
|
302
|
+ |
#[derive(Deserialize)]
|
|
303
|
+ |
struct In {
|
|
304
|
+ |
n: u32,
|
|
305
|
+ |
}
|
|
306
|
+ |
|
|
307
|
+ |
#[derive(Serialize)]
|
|
308
|
+ |
struct Out {
|
|
309
|
+ |
doubled: u32,
|
|
310
|
+ |
}
|
|
311
|
+ |
|
|
312
|
+ |
fn double(input: In) -> Result<Out, Refusal> {
|
|
313
|
+ |
if input.n > 1000 {
|
|
314
|
+ |
return Err(Refusal::unsupported("n is too large"));
|
|
315
|
+ |
}
|
|
316
|
+ |
Ok(Out { doubled: input.n * 2 })
|
|
317
|
+ |
}
|
|
318
|
+ |
|
|
319
|
+ |
#[test]
|
|
320
|
+ |
fn a_good_packet_comes_back_wrapped_in_ok() {
|
|
321
|
+ |
let packet = run_handler(br#"{"n": 21}"#, double);
|
|
322
|
+ |
assert_eq!(packet, br#"{"ok":{"doubled":42}}"#);
|
|
323
|
+ |
}
|
|
324
|
+ |
|
|
325
|
+ |
#[test]
|
|
326
|
+ |
fn an_undecodable_packet_is_a_bad_packet_refusal_not_a_panic() {
|
|
327
|
+ |
let packet = run_handler(b"not json", double);
|
|
328
|
+ |
let value: serde_json::Value = serde_json::from_slice(&packet).unwrap();
|
|
329
|
+ |
assert_eq!(value["refusal"]["code"], "bad_packet");
|
|
330
|
+ |
}
|
|
331
|
+ |
|
|
332
|
+ |
#[test]
|
|
333
|
+ |
fn a_handler_refusal_becomes_the_output_packet() {
|
|
334
|
+ |
let packet = run_handler(br#"{"n": 2000}"#, double);
|
|
335
|
+ |
let value: serde_json::Value = serde_json::from_slice(&packet).unwrap();
|
|
336
|
+ |
assert_eq!(value["refusal"]["code"], "unsupported");
|
|
337
|
+ |
assert_eq!(value["refusal"]["reason"], "n is too large");
|
|
338
|
+ |
}
|
|
339
|
+ |
|
|
340
|
+ |
#[test]
|
|
341
|
+ |
fn host_ok_packets_carry_the_bytes_after_the_status_byte() {
|
|
342
|
+ |
assert_eq!(parse_host_packet(b"\x00hello"), Ok(b"hello".to_vec()));
|
|
343
|
+ |
}
|
|
344
|
+ |
|
|
345
|
+ |
#[test]
|
|
346
|
+ |
fn host_refusal_packets_decode_into_the_typed_enum() {
|
|
347
|
+ |
let packet = b"\x01{\"code\":\"mount_denied\",\"reason\":\"outside\"}";
|
|
348
|
+ |
let refusal = parse_host_packet(packet).unwrap_err();
|
|
349
|
+ |
assert_eq!(refusal.code, RefusalCode::MountDenied);
|
|
350
|
+ |
assert_eq!(refusal.reason, "outside");
|
|
351
|
+ |
}
|
|
352
|
+ |
|
|
353
|
+ |
#[test]
|
|
354
|
+ |
fn unknown_host_codes_fold_to_internal_and_keep_the_raw_code() {
|
|
355
|
+ |
let packet = b"\x01{\"code\":\"weather\",\"reason\":\"rain\"}";
|
|
356
|
+ |
let refusal = parse_host_packet(packet).unwrap_err();
|
|
357
|
+ |
assert_eq!(refusal.code, RefusalCode::Internal);
|
|
358
|
+ |
assert!(refusal.reason.contains("weather"));
|
|
359
|
+ |
}
|
|
360
|
+ |
|
|
361
|
+ |
#[test]
|
|
362
|
+ |
fn the_return_word_packs_pointer_high_and_length_low() {
|
|
363
|
+ |
let word = __abi::pack_output(vec![1, 2, 3]);
|
|
364
|
+ |
assert_eq!(word & 0xffff_ffff, 3);
|
|
365
|
+ |
assert_ne!(word >> 32, 0);
|
|
366
|
+ |
}
|
|
367
|
+ |
|
|
368
|
+ |
#[test]
|
|
369
|
+ |
fn off_wasm_the_mount_import_is_an_unsupported_refusal() {
|
|
370
|
+ |
let refusal = read_mounted_file("anything.txt").unwrap_err();
|
|
371
|
+ |
assert_eq!(refusal.code, RefusalCode::Unsupported);
|
|
372
|
+ |
}
|
|
373
|
+ |
}
|