Skip to main content

harn_cli/acp/
mod.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use harn_serve::{AcpProfileConfig, AcpRuntimeConfigurator, AcpServerConfig, AuthPolicy};
6use tokio::sync::mpsc;
7
8struct CliAcpRuntimeConfigurator;
9
10#[async_trait(?Send)]
11impl AcpRuntimeConfigurator for CliAcpRuntimeConfigurator {
12    async fn configure(
13        &self,
14        vm: &mut harn_vm::Vm,
15        source_path: Option<&Path>,
16    ) -> Result<(), String> {
17        // Hostlib registration is independent of the package/extension flow:
18        // even a `harn run` invocation that hasn't loaded a manifest should
19        // see the `hostlib_*` builtins so callers can probe the surface.
20        // Behind the `hostlib` cargo feature (default-on); see
21        // `crates/harn-hostlib/README.md` for the boundary contract.
22        #[cfg(feature = "hostlib")]
23        {
24            let _ = harn_hostlib::install_default(vm);
25        }
26
27        // Install the lazy neural injection-classifier loader (Layer 2, guard
28        // backend). Built only under `guard-neural`; the runtime fires it the
29        // first time a `local-ml` policy scores untrusted content. Capturing the
30        // project base dir lets `harn-guard` resolve the installed model store.
31        #[cfg(feature = "guard-neural")]
32        {
33            let base_dir = source_path
34                .and_then(std::path::Path::parent)
35                .unwrap_or_else(|| std::path::Path::new("."))
36                .to_path_buf();
37            harn_vm::security::set_injection_classifier_loader(Box::new(move |selector| {
38                harn_guard::load_classifier(&base_dir, selector)
39            }));
40        }
41
42        let Some(path) = source_path else {
43            return Ok(());
44        };
45
46        // ACP is another execution transport for the same file-backed project,
47        // so its VM must receive the same manifest-declared authority as `run`
48        // and source execution before trigger or hook modules are loaded.
49        crate::compiler_context::enable_trusted_host_dispatch_for_source(vm, path)
50            .map_err(|error| format!("failed to enable trusted host dispatch: {error}"))?;
51
52        let extensions = crate::package::load_runtime_extensions(path);
53        crate::package::install_runtime_extensions(&extensions);
54        crate::package::install_manifest_triggers(vm, &extensions)
55            .await
56            .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
57        crate::package::install_manifest_hooks(vm, &extensions)
58            .await
59            .map_err(|error| format!("failed to install manifest hooks: {error}"))?;
60        Ok(())
61    }
62}
63
64pub(crate) fn server_config(pipeline: Option<String>, auth_policy: AuthPolicy) -> AcpServerConfig {
65    let extensions = pipeline
66        .as_deref()
67        .map(Path::new)
68        .map(crate::package::load_runtime_extensions)
69        .unwrap_or_default();
70    AcpServerConfig::new(pipeline)
71        .with_auth_policy(auth_policy)
72        .with_runtime_configurator(Arc::new(CliAcpRuntimeConfigurator))
73        .with_llm_overrides(extensions.llm, extensions.capabilities)
74}
75
76pub(crate) fn ensure_acp_event_log(pipeline: Option<&str>) {
77    if harn_vm::event_log::active_event_log().is_none() {
78        let base_dir = pipeline
79            .map(Path::new)
80            .and_then(Path::parent)
81            .unwrap_or_else(|| Path::new("."));
82        if let Err(error) = harn_vm::event_log::install_default_for_base_dir(base_dir) {
83            eprintln!(
84                "[harn] ACP session replay disabled: failed to initialize EventLog for {}: {error}",
85                base_dir.display()
86            );
87        }
88    }
89}
90
91pub(crate) async fn run_acp_server(
92    pipeline: Option<&str>,
93    auth_policy: AuthPolicy,
94    trace: bool,
95    profile: AcpProfileConfig,
96) {
97    ensure_acp_event_log(pipeline);
98    if trace {
99        harn_vm::llm::enable_tracing();
100    }
101    harn_serve::run_acp_server(
102        server_config(pipeline.map(str::to_string), auth_policy).with_profile(profile),
103    )
104    .await;
105    if trace {
106        eprint!("{}", crate::commands::run::render_trace_summary());
107    }
108}
109
110pub(crate) async fn run_acp_channel_server(
111    pipeline: Option<String>,
112    request_rx: mpsc::UnboundedReceiver<serde_json::Value>,
113    response_tx: mpsc::UnboundedSender<String>,
114) {
115    harn_serve::run_acp_channel_server(
116        server_config(pipeline, AuthPolicy::allow_all()),
117        request_rx,
118        response_tx,
119    )
120    .await;
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    async fn configure_fixture(declared: bool) -> Result<(), String> {
128        harn_vm::reset_thread_local_state();
129        crate::compiler_context::ensure_builtin_signatures_installed();
130        let project = tempfile::tempdir().expect("temp project");
131        let script =
132            crate::tests::common::host_dispatch_project::write_host_dispatch_trigger_project(
133                project.path(),
134                declared,
135                r#"
136pub fn on_tick(_event) -> nil {
137  const _ = host_call("runtime.pipeline_input", {})
138  return nil
139}
140"#,
141            );
142        let mut vm = harn_vm::Vm::new();
143        harn_vm::register_vm_stdlib(&mut vm);
144        let result = CliAcpRuntimeConfigurator
145            .configure(&mut vm, Some(&script))
146            .await;
147        harn_vm::reset_thread_local_state();
148        result
149    }
150
151    #[tokio::test]
152    async fn acp_honors_manifest_trusted_host_dispatch_before_installing_triggers() {
153        configure_fixture(true)
154            .await
155            .expect("declared ACP project accepts privileged trigger import graph");
156
157        let error = configure_fixture(false)
158            .await
159            .expect_err("undeclared ACP project remains unprivileged");
160        assert!(
161            error.contains("host_call") && error.contains("not callable source API"),
162            "unexpected refusal: {error}"
163        );
164    }
165}