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