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