Skip to main content

harn_hostlib/
registry.rs

1//! Registration plumbing.
2//!
3//! Each module exposes a [`HostlibCapability`] implementation that pushes
4//! its builtins into a [`BuiltinRegistry`]. The registry can then either
5//! be wired into a real [`harn_vm::Vm`] (production path) or introspected
6//! by tests to assert the exposed surface without touching the VM.
7
8use std::collections::BTreeSet;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use harn_vm::{Vm, VmError, VmValue};
14
15use crate::error::HostlibError;
16
17/// Sync builtin handler signature. Mirrors the closure type accepted by
18/// [`harn_vm::Vm::register_builtin`]; we keep it `Send + Sync` so capability
19/// instances can be shared across threads if an embedder ever wants that.
20pub type SyncHandler = Arc<dyn Fn(&[VmValue]) -> Result<VmValue, HostlibError> + Send + Sync>;
21/// Async hostlib handler used by event-driven operations.
22pub type AsyncHandler = Arc<
23    dyn Fn(Vec<VmValue>) -> Pin<Box<dyn Future<Output = Result<VmValue, HostlibError>> + Send>>
24        + Send
25        + Sync,
26>;
27
28#[derive(Clone)]
29/// One registered async builtin and its schema coordinates.
30pub struct RegisteredAsyncBuiltin {
31    /// Harn-visible builtin name.
32    pub name: &'static str,
33    /// Hostlib schema module.
34    pub module: &'static str,
35    /// Hostlib schema method.
36    pub method: &'static str,
37    /// Async implementation.
38    pub handler: AsyncHandler,
39}
40
41/// One registered builtin. The name is what Harn scripts call (e.g.
42/// `hostlib_ast_parse_file`); `module` and `method` are the canonical
43/// schema-directory coordinates (`schemas/<module>/<method>.request.json`).
44#[derive(Clone)]
45pub struct RegisteredBuiltin {
46    /// Builtin name as Harn scripts see it.
47    pub name: &'static str,
48    /// Module bucket (e.g. `"ast"`, `"tools"`).
49    pub module: &'static str,
50    /// Method name within the module (e.g. `"parse_file"`, `"search"`).
51    pub method: &'static str,
52    /// Handler invoked when Harn calls the builtin.
53    pub handler: SyncHandler,
54}
55
56impl std::fmt::Debug for RegisteredBuiltin {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("RegisteredBuiltin")
59            .field("name", &self.name)
60            .field("module", &self.module)
61            .field("method", &self.method)
62            .finish()
63    }
64}
65
66/// Mutable collector each capability writes into during `register`.
67#[derive(Default)]
68pub struct BuiltinRegistry {
69    builtins: Vec<RegisteredBuiltin>,
70    async_builtins: Vec<RegisteredAsyncBuiltin>,
71    command_policy_builtins: BTreeSet<&'static str>,
72}
73
74impl BuiltinRegistry {
75    /// Construct an empty registry.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Push one builtin. Capabilities call this from `register_builtins`.
81    pub fn register(&mut self, builtin: RegisteredBuiltin) {
82        self.builtins.push(builtin);
83    }
84
85    /// Convenience: register a builtin whose body is the `unimplemented`
86    /// scaffold error.
87    pub fn register_unimplemented(
88        &mut self,
89        name: &'static str,
90        module: &'static str,
91        method: &'static str,
92    ) {
93        let handler: SyncHandler =
94            Arc::new(move |_args| Err(HostlibError::Unimplemented { builtin: name }));
95        self.register(RegisteredBuiltin {
96            name,
97            module,
98            method,
99            handler,
100        });
101    }
102
103    /// Convenience: register a stateless builtin backed by a plain fn
104    /// pointer. This is the shape almost every capability module uses;
105    /// keeping it here avoids each module hand-rolling its own copy.
106    pub(crate) fn register_fn(
107        &mut self,
108        module: &'static str,
109        name: &'static str,
110        method: &'static str,
111        runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
112    ) {
113        let handler: SyncHandler = Arc::new(runner);
114        self.register(RegisteredBuiltin {
115            name,
116            module,
117            method,
118            handler,
119        });
120    }
121
122    /// Like [`Self::register_fn`], but wraps the handler in the shared
123    /// deterministic-tools permission gate
124    /// ([`crate::tools::permissions::gated_handler`]).
125    pub(crate) fn register_gated_fn(
126        &mut self,
127        module: &'static str,
128        name: &'static str,
129        method: &'static str,
130        runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
131    ) {
132        self.register(RegisteredBuiltin {
133            name,
134            module,
135            method,
136            handler: crate::tools::permissions::gated_handler(name, runner),
137        });
138    }
139
140    /// Register a deterministic command-execution builtin whose request must
141    /// cross the VM command-policy boundary before the hostlib handler runs.
142    pub(crate) fn register_gated_command_fn(
143        &mut self,
144        module: &'static str,
145        name: &'static str,
146        method: &'static str,
147        runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
148    ) {
149        self.register_gated_fn(module, name, method, runner);
150        self.command_policy_builtins.insert(name);
151    }
152
153    pub(crate) fn register_gated_async_fn<F, Fut>(
154        &mut self,
155        module: &'static str,
156        name: &'static str,
157        method: &'static str,
158        runner: F,
159    ) where
160        F: Fn(Vec<VmValue>) -> Fut + Send + Sync + 'static,
161        Fut: Future<Output = Result<VmValue, HostlibError>> + Send + 'static,
162    {
163        let runner = Arc::new(runner);
164        let handler: AsyncHandler = Arc::new(move |args| {
165            if !crate::tools::permissions::is_enabled(
166                crate::tools::permissions::FEATURE_TOOLS_DETERMINISTIC,
167            ) {
168                return Box::pin(async move {
169                    Err(HostlibError::Backend {
170                        builtin: name,
171                        message: format!(
172                            "feature `{}` is not enabled in this session — call \
173                             `hostlib_enable(\"{}\")` before invoking this capability",
174                            crate::tools::permissions::FEATURE_TOOLS_DETERMINISTIC,
175                            crate::tools::permissions::FEATURE_TOOLS_DETERMINISTIC,
176                        ),
177                    })
178                });
179            }
180            Box::pin(runner(args))
181        });
182        self.async_builtins.push(RegisteredAsyncBuiltin {
183            name,
184            module,
185            method,
186            handler,
187        });
188    }
189
190    fn uses_command_policy(&self, name: &str) -> bool {
191        self.command_policy_builtins.contains(name)
192    }
193
194    /// Iterate over every registered builtin.
195    pub fn iter(&self) -> impl Iterator<Item = &RegisteredBuiltin> {
196        self.builtins.iter()
197    }
198
199    /// Iterate over every registered async builtin.
200    pub fn iter_async(&self) -> impl Iterator<Item = &RegisteredAsyncBuiltin> {
201        self.async_builtins.iter()
202    }
203
204    /// Total count.
205    pub fn len(&self) -> usize {
206        self.builtins.len() + self.async_builtins.len()
207    }
208
209    /// True when nothing has been registered yet.
210    pub fn is_empty(&self) -> bool {
211        self.builtins.is_empty() && self.async_builtins.is_empty()
212    }
213
214    /// Look up a builtin by its Harn-visible name.
215    pub fn find(&self, name: &str) -> Option<&RegisteredBuiltin> {
216        self.builtins.iter().find(|b| b.name == name)
217    }
218
219    /// Look up one async builtin by its Harn-visible name.
220    pub fn find_async(&self, name: &str) -> Option<&RegisteredAsyncBuiltin> {
221        self.async_builtins.iter().find(|b| b.name == name)
222    }
223}
224
225/// One module's worth of builtins. Kept tiny on purpose: capabilities exist
226/// purely so tests can reason about the surface without booting a VM, and
227/// so embedders can opt into individual modules.
228pub trait HostlibCapability: 'static {
229    /// Module name (matches the `schemas/<module>/` directory).
230    fn module_name(&self) -> &'static str;
231
232    /// Push every builtin this module exposes into `registry`.
233    fn register_builtins(&self, registry: &mut BuiltinRegistry);
234}
235
236/// Composes capabilities and emits VM registrations.
237///
238/// `HostlibRegistry` is the type embedders interact with. It owns the
239/// capability instances and the populated [`BuiltinRegistry`] together so
240/// the same surface can be inspected by tests *and* wired into a VM.
241pub struct HostlibRegistry {
242    builtins: BuiltinRegistry,
243    modules: Vec<&'static str>,
244}
245
246impl Default for HostlibRegistry {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl HostlibRegistry {
253    /// Construct an empty registry. Most callers want [`crate::install_default`]
254    /// instead, which pre-populates every shipped capability.
255    pub fn new() -> Self {
256        Self {
257            builtins: BuiltinRegistry::new(),
258            modules: Vec::new(),
259        }
260    }
261
262    /// Add one capability to the registry. Returns `self` for chaining.
263    #[must_use]
264    pub fn with<C: HostlibCapability>(mut self, capability: C) -> Self {
265        let module = capability.module_name();
266        capability.register_builtins(&mut self.builtins);
267        self.modules.push(module);
268        self
269    }
270
271    /// Wire every registered builtin into the supplied VM.
272    pub fn register_into_vm(&mut self, vm: &mut Vm) {
273        for builtin in self.builtins.iter().cloned() {
274            let module = builtin.module;
275            let method = builtin.method;
276            harn_vm::stdlib::host::register_callable_host_operation(
277                module,
278                method,
279                "Hostlib schema-backed operation registered at runtime.",
280            );
281            let handler = builtin.handler.clone();
282            if self.builtins.uses_command_policy(builtin.name) {
283                vm.register_async_builtin(builtin.name, move |ctx, args| {
284                    let handler = handler.clone();
285                    async move {
286                        let request = crate::schemas::validate_request_args(
287                            builtin.name,
288                            module,
289                            method,
290                            &args,
291                        )
292                        .map_err(VmError::from)?;
293                        let params = request.as_dict().ok_or_else(|| {
294                            VmError::Runtime(format!(
295                                "{}: validated request must be a dict",
296                                builtin.name
297                            ))
298                        })?;
299                        if let Some(mocked) = harn_vm::stdlib::host::dispatch_mock_hostlib_call(
300                            module, method, params,
301                        ) {
302                            return mocked;
303                        }
304                        let caller = serde_json::json!({
305                            "surface": "hostlib",
306                            "builtin": builtin.name,
307                            "module": module,
308                            "method": method,
309                            "session_id": harn_vm::current_agent_session_id(),
310                        });
311                        match harn_vm::orchestration::run_command_policy_preflight_with_ctx(
312                            Some(&ctx),
313                            params,
314                            caller,
315                        )
316                        .await?
317                        {
318                            harn_vm::orchestration::CommandPolicyPreflight::Blocked {
319                                status,
320                                message,
321                                context,
322                                decisions,
323                            } => {
324                                let response = harn_vm::orchestration::blocked_command_response(
325                                    params, status, &message, context, decisions,
326                                );
327                                crate::schemas::validate_response(
328                                    builtin.name,
329                                    module,
330                                    method,
331                                    crate::tools::policy_blocked_run_command_response(response),
332                                )
333                                .map_err(VmError::from)
334                            }
335                            harn_vm::orchestration::CommandPolicyPreflight::Proceed {
336                                params,
337                                context,
338                                decisions,
339                            } => {
340                                // Hooks may rewrite command fields. Revalidate
341                                // the rewritten request at the owning schema
342                                // boundary before the hostlib parser sees it.
343                                let rewritten = VmValue::dict(params.clone());
344                                let validated = crate::schemas::validate_request_args(
345                                    builtin.name,
346                                    module,
347                                    method,
348                                    &[rewritten],
349                                )
350                                .map_err(VmError::from)?;
351                                let result = handler(&[validated]).map_err(VmError::from)?;
352                                if crate::tools::run_command_request_is_background(&params) {
353                                    return crate::schemas::validate_response(
354                                        builtin.name,
355                                        module,
356                                        method,
357                                        result,
358                                    )
359                                    .map_err(VmError::from);
360                                }
361                                let result =
362                                    harn_vm::orchestration::run_command_policy_postflight_with_ctx(
363                                        Some(&ctx),
364                                        &params,
365                                        result,
366                                        context,
367                                        decisions,
368                                    )
369                                    .await?;
370                                crate::schemas::validate_response(
371                                    builtin.name,
372                                    module,
373                                    method,
374                                    result,
375                                )
376                                .map_err(VmError::from)
377                            }
378                        }
379                    }
380                });
381            } else {
382                vm.register_builtin(
383                    builtin.name,
384                    move |args, _out| -> Result<VmValue, VmError> {
385                        let request = crate::schemas::validate_request_args(
386                            builtin.name,
387                            module,
388                            method,
389                            args,
390                        )
391                        .map_err(VmError::from)?;
392                        let validated_args = [request.clone()];
393                        if let Some(params) = request.as_dict() {
394                            if let Some(mocked) = harn_vm::stdlib::host::dispatch_mock_hostlib_call(
395                                module, method, params,
396                            ) {
397                                return mocked;
398                            }
399                        }
400                        handler(&validated_args).map_err(VmError::from)
401                    },
402                );
403            }
404        }
405        for builtin in self.builtins.async_builtins.iter().cloned() {
406            let module = builtin.module;
407            let method = builtin.method;
408            harn_vm::stdlib::host::register_callable_host_operation(
409                module,
410                method,
411                "Hostlib schema-backed operation registered at runtime.",
412            );
413            let handler = builtin.handler.clone();
414            vm.register_async_builtin(builtin.name, move |_ctx, args| {
415                let handler = handler.clone();
416                async move {
417                    let request =
418                        crate::schemas::validate_request_args(builtin.name, module, method, &args)
419                            .map_err(VmError::from)?;
420                    if let Some(params) = request.as_dict() {
421                        if let Some(mocked) = harn_vm::stdlib::host::dispatch_mock_hostlib_call(
422                            module, method, params,
423                        ) {
424                            return mocked;
425                        }
426                    }
427                    let result = handler(vec![request]).await.map_err(VmError::from)?;
428                    crate::schemas::validate_response(builtin.name, module, method, result)
429                        .map_err(VmError::from)
430                }
431            });
432        }
433    }
434
435    /// Borrow the underlying [`BuiltinRegistry`] for introspection (e.g.
436    /// schema-drift tests).
437    pub fn builtins(&self) -> &BuiltinRegistry {
438        &self.builtins
439    }
440
441    /// List the module names that have been registered, in insertion order.
442    pub fn modules(&self) -> &[&'static str] {
443        &self.modules
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn unimplemented_builtins_route_through_error() {
453        let mut registry = BuiltinRegistry::new();
454        registry.register_unimplemented("hostlib_demo", "demo", "ping");
455        let entry = registry.find("hostlib_demo").expect("registered");
456        let err = (entry.handler)(&[]).expect_err("should be unimplemented");
457        assert!(
458            matches!(err, HostlibError::Unimplemented { builtin } if builtin == "hostlib_demo")
459        );
460    }
461
462    #[test]
463    fn registry_records_modules_in_order() {
464        struct First;
465        impl HostlibCapability for First {
466            fn module_name(&self) -> &'static str {
467                "first"
468            }
469            fn register_builtins(&self, _registry: &mut BuiltinRegistry) {}
470        }
471        struct Second;
472        impl HostlibCapability for Second {
473            fn module_name(&self) -> &'static str {
474                "second"
475            }
476            fn register_builtins(&self, _registry: &mut BuiltinRegistry) {}
477        }
478
479        let registry = HostlibRegistry::new().with(First).with(Second);
480        assert_eq!(registry.modules(), &["first", "second"]);
481    }
482}