Skip to main content

harn_cli/commands/
run.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use harn_parser::DiagnosticSeverity;
11use harn_vm::event_log::EventLog;
12
13use crate::commands::mcp::{self, AuthResolution};
14use crate::commands::time::RunTiming;
15use crate::package;
16use crate::parse_source_file;
17use crate::skill_loader::{
18    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
19    SkillLoaderInputs,
20};
21
22mod explain_cost;
23pub mod harnpack;
24pub mod json_events;
25
26use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
27use self::json_events::NdjsonEmitter;
28
29/// JSON event-stream configuration for `--json` runs.
30#[derive(Clone, Default)]
31pub struct RunJsonOptions {
32    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
33    /// persona, and the terminal result/error events still flow.
34    pub quiet: bool,
35}
36
37pub(crate) enum RunFileMcpServeMode {
38    Stdio,
39    Http {
40        options: harn_serve::McpHttpServeOptions,
41        auth_policy: harn_serve::AuthPolicy,
42    },
43}
44
45/// Core builtins that are never denied, even when using `--allow`.
46const CORE_BUILTINS: &[&str] = &[
47    "println",
48    "print",
49    "log",
50    "type_of",
51    "to_string",
52    "to_int",
53    "to_float",
54    "len",
55    "assert",
56    "assert_eq",
57    "assert_ne",
58    "json_parse",
59    "json_stringify",
60    "runtime_context",
61    "task_current",
62    "runtime_context_values",
63    "runtime_context_get",
64    "runtime_context_set",
65    "runtime_context_clear",
66];
67
68/// Build the set of denied builtin names from `--deny` or `--allow` flags.
69///
70/// - `--deny a,b,c` denies exactly those names.
71/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
72pub(crate) fn build_denied_builtins(
73    deny_csv: Option<&str>,
74    allow_csv: Option<&str>,
75) -> HashSet<String> {
76    if let Some(csv) = deny_csv {
77        csv.split(',')
78            .map(|s| s.trim().to_string())
79            .filter(|s| !s.is_empty())
80            .collect()
81    } else if let Some(csv) = allow_csv {
82        // With --allow, we mark every registered stdlib builtin as denied
83        // *except* those in the allow list and the core builtins.
84        let allowed: HashSet<String> = csv
85            .split(',')
86            .map(|s| s.trim().to_string())
87            .filter(|s| !s.is_empty())
88            .collect();
89        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
90
91        // Create a temporary VM with stdlib registered to enumerate all builtin names.
92        let mut tmp = harn_vm::Vm::new();
93        harn_vm::register_vm_stdlib(&mut tmp);
94        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
95        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
96
97        tmp.builtin_names()
98            .into_iter()
99            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
100            .collect()
101    } else {
102        HashSet::new()
103    }
104}
105
106/// Result of [`compile_or_load_chunk_for_run`]. Failures propagate as
107/// diagnostic text on the run path so callers map them straight to a
108/// non-zero exit code without bespoke error types.
109pub(crate) struct LoadedChunk {
110    pub(crate) source: String,
111    pub(crate) chunk: harn_vm::Chunk,
112}
113
114/// Load the entry pipeline as a runnable [`harn_vm::Chunk`], using the
115/// content-addressed bytecode cache when its key matches. On a cache miss
116/// we read, parse, type-check, and compile, then persist the chunk.
117/// On a hit we skip parse/typecheck/compile entirely — the cache invariant
118/// is that a stored chunk passed those phases on the writer's harn build,
119/// and the key includes every transitively-imported user file so any
120/// change re-runs the full path.
121///
122/// `stderr` receives any diagnostic output. Returns `None` when a fatal
123/// type or compile error blocks execution; the caller maps that to
124/// exit-code 1.
125pub(crate) fn compile_or_load_chunk_for_run(
126    path: &str,
127    stderr: &mut String,
128) -> Option<LoadedChunk> {
129    compile_or_load_chunk_with_timing(path, stderr, None)
130}
131
132/// Like [`compile_or_load_chunk_for_run`] but lets the caller observe
133/// per-phase wall-clock timings (parse, typecheck, bytecode compile +
134/// cache hit/miss). Used by `harn time run` to drive the same code
135/// path as `harn run` while reporting phase-level timing.
136//
137// The `as_deref_mut` calls reborrow the inner `&mut RunTiming` so each
138// phase can mutate it independently. Clippy's `needless_option_as_deref`
139// is correct that the surface types match — that's exactly the
140// reborrow we want.
141#[allow(clippy::needless_option_as_deref)]
142pub(crate) fn compile_or_load_chunk_with_timing(
143    path: &str,
144    stderr: &mut String,
145    mut timing: Option<&mut RunTiming>,
146) -> Option<LoadedChunk> {
147    let source = match fs::read_to_string(path) {
148        Ok(s) => s,
149        Err(e) => {
150            stderr.push_str(&format!("Error reading {path}: {e}\n"));
151            return None;
152        }
153    };
154    if let Some(t) = timing.as_deref_mut() {
155        t.input_bytes = source.len() as u64;
156    }
157
158    let compile_phase_start = Instant::now();
159    let lookup = harn_vm::bytecode_cache::load(Path::new(path), &source);
160    if let Some(chunk) = lookup.chunk {
161        if let Some(t) = timing.as_deref_mut() {
162            t.cache_hit = true;
163            t.bytecode_compile = compile_phase_start.elapsed();
164        }
165        return Some(LoadedChunk { source, chunk });
166    }
167    if let Some(t) = timing.as_deref_mut() {
168        t.cache_hit = false;
169    }
170
171    let parse_start = Instant::now();
172    let (parsed_source, program) = parse_source_file(path);
173    debug_assert_eq!(parsed_source, source, "parse_source_file re-read drifted");
174    if let Some(t) = timing.as_deref_mut() {
175        t.parse = parse_start.elapsed();
176    }
177
178    let typecheck_start = Instant::now();
179    let mut had_type_error = false;
180    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
181    for diag in &type_diagnostics {
182        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
183        if matches!(diag.severity, DiagnosticSeverity::Error) {
184            had_type_error = true;
185        }
186        stderr.push_str(&rendered);
187    }
188    if let Some(t) = timing.as_deref_mut() {
189        t.typecheck = typecheck_start.elapsed();
190    }
191    if had_type_error {
192        return None;
193    }
194
195    let compile_step_start = Instant::now();
196    let chunk = match harn_vm::Compiler::new().compile(&program) {
197        Ok(c) => c,
198        Err(e) => {
199            stderr.push_str(&format!("error: compile error: {e}\n"));
200            return None;
201        }
202    };
203
204    // Cache misses are best-effort — read-only homedirs, full disks, and
205    // sandboxes are common in CI environments. Surface the failure as a
206    // single-line warning when explicitly requested via the audit hook;
207    // otherwise stay quiet to avoid bloating happy-path output.
208    if let Err(err) = harn_vm::bytecode_cache::store(&lookup.key, &chunk) {
209        if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
210            eprintln!("[harn] bytecode cache write skipped: {err}");
211        }
212    }
213    if let Some(t) = timing.as_deref_mut() {
214        t.bytecode_compile = compile_step_start.elapsed();
215    }
216
217    Some(LoadedChunk { source, chunk })
218}
219
220/// Run the static type checker against `program` with cross-module
221/// import-aware call resolution when the file's imports all resolve. Used
222/// by `run_file` and the MCP server entry so `harn run` catches undefined
223/// cross-module calls before the VM starts.
224fn typecheck_with_imports(
225    program: &[harn_parser::SNode],
226    path: &Path,
227    source: &str,
228) -> Vec<harn_parser::TypeDiagnostic> {
229    if let Err(error) = package::ensure_dependencies_materialized(path) {
230        eprintln!("error: {error}");
231        process::exit(1);
232    }
233    let graph = harn_modules::build(&[path.to_path_buf()]);
234    let mut checker = harn_parser::TypeChecker::new();
235    if let Some(imported) = graph.imported_names_for_file(path) {
236        checker = checker.with_imported_names(imported);
237    }
238    if let Some(imported) = graph.imported_type_declarations_for_file(path) {
239        checker = checker.with_imported_type_decls(imported);
240    }
241    if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
242        checker = checker.with_imported_callable_decls(imported);
243    }
244    checker.check_with_source(program, source)
245}
246
247/// Build the wrapped source and temp file backing a `harn run -e` invocation.
248///
249/// `import` is a top-level declaration in Harn, so the leading prefix of
250/// import lines (with surrounding blanks/comments) is hoisted out of the
251/// `pipeline main(task) { ... }` wrapper. The temp file is created in the
252/// current working directory so relative imports (`import "./lib"`) and
253/// `harn.toml` discovery resolve against the user's project, not the
254/// system temp dir. If the CWD is unwritable we fall back to the system
255/// temp dir with a stderr warning — pure-expression `-e` still works,
256/// but relative imports will fail to resolve.
257pub(crate) fn prepare_eval_temp_file(
258    code: &str,
259) -> Result<(String, tempfile::NamedTempFile), String> {
260    let (header, body) = split_eval_header(code);
261    let wrapped = if header.is_empty() {
262        format!("pipeline main(task) {{\n{body}\n}}")
263    } else {
264        format!("{header}\npipeline main(task) {{\n{body}\n}}")
265    };
266
267    let tmp = create_eval_temp_file()?;
268    Ok((wrapped, tmp))
269}
270
271/// Try to place the `-e` temp file in the current working directory so
272/// relative imports and `harn.toml` discovery resolve against the user's
273/// project. Fall back to the system temp dir on failure (with a warning),
274/// so pure-expression `-e` keeps working in read-only contexts.
275fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
276    if let Some(dir) = std::env::current_dir().ok().as_deref() {
277        // Hidden prefix on Unix so editors / tree-walkers are less likely
278        // to pick the file up during its short lifetime.
279        match tempfile::Builder::new()
280            .prefix(".harn-eval-")
281            .suffix(".harn")
282            .tempfile_in(dir)
283        {
284            Ok(tmp) => return Ok(tmp),
285            Err(error) => eprintln!(
286                "warning: harn run -e: could not create temp file in {}: {error}; \
287                 relative imports will not resolve",
288                dir.display()
289            ),
290        }
291    }
292    tempfile::Builder::new()
293        .prefix("harn-eval-")
294        .suffix(".harn")
295        .tempfile()
296        .map_err(|e| format!("failed to create temp file for -e: {e}"))
297}
298
299/// Split the `-e` input into a header (top-level imports + leading
300/// blanks/comments) and a body (everything else, to be wrapped in
301/// `pipeline main(task)`). The header may be empty.
302///
303/// Lines whose first non-whitespace token is `import` or `pub import`
304/// are treated as imports. Scanning stops at the first non-blank,
305/// non-comment, non-import line.
306fn split_eval_header(code: &str) -> (String, String) {
307    let mut header_end = 0usize;
308    let mut last_kept = 0usize;
309    for (idx, line) in code.lines().enumerate() {
310        let trimmed = line.trim_start();
311        if trimmed.is_empty() || trimmed.starts_with("//") {
312            header_end = idx + 1;
313            continue;
314        }
315        let is_import = trimmed.starts_with("import ")
316            || trimmed.starts_with("import\t")
317            || trimmed.starts_with("import\"")
318            || trimmed.starts_with("pub import ")
319            || trimmed.starts_with("pub import\t");
320        if is_import {
321            header_end = idx + 1;
322            last_kept = idx + 1;
323        } else {
324            break;
325        }
326    }
327    if last_kept == 0 {
328        return (String::new(), code.to_string());
329    }
330    let mut header_lines: Vec<&str> = Vec::new();
331    let mut body_lines: Vec<&str> = Vec::new();
332    for (idx, line) in code.lines().enumerate() {
333        if idx < header_end {
334            header_lines.push(line);
335        } else {
336            body_lines.push(line);
337        }
338    }
339    (header_lines.join("\n"), body_lines.join("\n"))
340}
341
342#[derive(Clone, Debug, Default, PartialEq, Eq)]
343pub enum CliLlmMockMode {
344    #[default]
345    Off,
346    Replay {
347        fixture_path: PathBuf,
348    },
349    Record {
350        fixture_path: PathBuf,
351    },
352}
353
354#[derive(Clone, Debug, Default, PartialEq, Eq)]
355pub struct RunAttestationOptions {
356    pub receipt_out: Option<PathBuf>,
357    pub agent_id: Option<String>,
358}
359
360/// Opt-in profiling. When `text` is true the run prints a categorical
361/// breakdown to stderr after execution; when `json_path` is set the same
362/// rollup is serialized to that path. Either flag enables span tracing
363/// (i.e. `harn_vm::tracing::set_tracing_enabled(true)`).
364#[derive(Clone, Debug, Default, PartialEq, Eq)]
365pub struct RunProfileOptions {
366    pub text: bool,
367    pub json_path: Option<PathBuf>,
368}
369
370impl RunProfileOptions {
371    pub fn is_enabled(&self) -> bool {
372        self.text || self.json_path.is_some()
373    }
374}
375
376#[derive(Clone)]
377pub struct RunInterruptTokens {
378    pub cancel_token: Arc<AtomicBool>,
379    pub signal_token: Arc<Mutex<Option<String>>>,
380}
381
382struct ExecuteRunInputs<'a> {
383    path: &'a str,
384    trace: bool,
385    denied_builtins: HashSet<String>,
386    script_argv: Vec<String>,
387    skill_dirs_raw: Vec<String>,
388    llm_mock_mode: CliLlmMockMode,
389    attestation: Option<RunAttestationOptions>,
390    profile: RunProfileOptions,
391    interrupt_tokens: Option<RunInterruptTokens>,
392    json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
393    timing: Option<&'a mut RunTiming>,
394    harnpack: HarnpackRunOptions,
395}
396
397/// Captured outcome of an in-process `execute_run` invocation. Tests use this
398/// instead of spawning the `harn` binary; the binary entry point translates
399/// it into real stdout/stderr writes + `process::exit`.
400#[derive(Clone, Debug, Default)]
401pub struct RunOutcome {
402    pub stdout: String,
403    pub stderr: String,
404    pub exit_code: i32,
405}
406
407pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
408    harn_vm::llm::clear_cli_llm_mock_mode();
409    match mode {
410        CliLlmMockMode::Off => Ok(()),
411        CliLlmMockMode::Replay { fixture_path } => {
412            let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
413            harn_vm::llm::install_cli_llm_mocks(mocks);
414            Ok(())
415        }
416        CliLlmMockMode::Record { .. } => {
417            harn_vm::llm::enable_cli_llm_mock_recording();
418            Ok(())
419        }
420    }
421}
422
423pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
424    let CliLlmMockMode::Record { fixture_path } = mode else {
425        return Ok(());
426    };
427    if let Some(parent) = fixture_path.parent() {
428        if !parent.as_os_str().is_empty() {
429            fs::create_dir_all(parent).map_err(|error| {
430                format!(
431                    "failed to create fixture directory {}: {error}",
432                    parent.display()
433                )
434            })?;
435        }
436    }
437
438    let lines = harn_vm::llm::take_cli_llm_recordings()
439        .into_iter()
440        .map(harn_vm::llm::serialize_llm_mock)
441        .collect::<Result<Vec<_>, _>>()?;
442    let body = if lines.is_empty() {
443        String::new()
444    } else {
445        format!("{}\n", lines.join("\n"))
446    };
447    fs::write(fixture_path, body)
448        .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
449}
450
451pub(crate) async fn run_file(
452    path: &str,
453    trace: bool,
454    denied_builtins: HashSet<String>,
455    script_argv: Vec<String>,
456    llm_mock_mode: CliLlmMockMode,
457    attestation: Option<RunAttestationOptions>,
458    profile: RunProfileOptions,
459) {
460    run_file_with_skill_dirs(
461        path,
462        trace,
463        denied_builtins,
464        script_argv,
465        Vec::new(),
466        llm_mock_mode,
467        attestation,
468        profile,
469        None,
470        HarnpackRunOptions::default(),
471    )
472    .await;
473}
474
475pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
476    let outcome = execute_explain_cost(path);
477    if !outcome.stderr.is_empty() {
478        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
479    }
480    if !outcome.stdout.is_empty() {
481        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
482    }
483    if outcome.exit_code != 0 {
484        process::exit(outcome.exit_code);
485    }
486}
487
488#[allow(clippy::too_many_arguments)]
489pub(crate) async fn run_file_with_skill_dirs(
490    path: &str,
491    trace: bool,
492    denied_builtins: HashSet<String>,
493    script_argv: Vec<String>,
494    skill_dirs_raw: Vec<String>,
495    llm_mock_mode: CliLlmMockMode,
496    attestation: Option<RunAttestationOptions>,
497    profile: RunProfileOptions,
498    json: Option<RunJsonOptions>,
499    harnpack: HarnpackRunOptions,
500) {
501    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
502    let interrupt_tokens = install_signal_shutdown_handler();
503
504    let _stdout_passthrough = StdoutPassthroughGuard::enable();
505    let json_with_stdout =
506        json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
507    let outcome = execute_run_inner(ExecuteRunInputs {
508        path,
509        trace,
510        denied_builtins,
511        script_argv,
512        skill_dirs_raw,
513        llm_mock_mode,
514        attestation,
515        profile,
516        interrupt_tokens: Some(interrupt_tokens.clone()),
517        json: json_with_stdout,
518        timing: None,
519        harnpack,
520    })
521    .await;
522
523    // `harn run` streams normal program stdout during execution. Any stdout
524    // left here came from older capture paths, so flush it after diagnostics.
525    if !outcome.stderr.is_empty() {
526        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
527    }
528    if !outcome.stdout.is_empty() {
529        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
530    }
531
532    let mut exit_code = outcome.exit_code;
533    if exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst) {
534        exit_code = 124;
535    }
536    if exit_code != 0 {
537        process::exit(exit_code);
538    }
539}
540
541#[allow(clippy::too_many_arguments)]
542pub(crate) async fn run_resume_with_skill_dirs(
543    target: &str,
544    trace: bool,
545    denied_builtins: HashSet<String>,
546    resume_argv: Vec<String>,
547    skill_dirs_raw: Vec<String>,
548    llm_mock_mode: CliLlmMockMode,
549    attestation: Option<RunAttestationOptions>,
550    profile: RunProfileOptions,
551    json: Option<RunJsonOptions>,
552) {
553    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
554
555pipeline main(task) {
556  let input = if len(argv) > 1 {
557    argv[1]
558  } else {
559    nil
560  }
561  let handle = resume_agent(argv[0], input, true)
562  return wait_agent(handle)
563}
564"#;
565    let tmp = create_eval_temp_file().unwrap_or_else(|e| {
566        eprintln!("error: {e}");
567        process::exit(1);
568    });
569    let tmp_path = tmp.path().to_path_buf();
570    if let Err(error) = fs::write(&tmp_path, source) {
571        eprintln!("error: failed to write temp file for --resume: {error}");
572        process::exit(1);
573    }
574    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
575    argv.push(target.to_string());
576    argv.extend(resume_argv);
577    let tmp_str = tmp_path.to_string_lossy().into_owned();
578    run_file_with_skill_dirs(
579        &tmp_str,
580        trace,
581        denied_builtins,
582        argv,
583        skill_dirs_raw,
584        llm_mock_mode,
585        attestation,
586        profile,
587        json,
588        HarnpackRunOptions::default(),
589    )
590    .await;
591}
592
593pub fn execute_explain_cost(path: &str) -> RunOutcome {
594    let stdout = String::new();
595    let mut stderr = String::new();
596
597    let (source, program) = parse_source_file(path);
598
599    let mut had_type_error = false;
600    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
601    for diag in &type_diagnostics {
602        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
603        if matches!(diag.severity, DiagnosticSeverity::Error) {
604            had_type_error = true;
605        }
606        stderr.push_str(&rendered);
607    }
608    if had_type_error {
609        return RunOutcome {
610            stdout,
611            stderr,
612            exit_code: 1,
613        };
614    }
615
616    let extensions = package::load_runtime_extensions(Path::new(path));
617    package::install_runtime_extensions(&extensions);
618    RunOutcome {
619        stdout: explain_cost::render_explain_cost(path, &program),
620        stderr,
621        exit_code: 0,
622    }
623}
624
625pub(crate) struct StdoutPassthroughGuard {
626    previous: bool,
627}
628
629impl StdoutPassthroughGuard {
630    pub(crate) fn enable() -> Self {
631        Self {
632            previous: harn_vm::set_stdout_passthrough(true),
633        }
634    }
635}
636
637impl Drop for StdoutPassthroughGuard {
638    fn drop(&mut self) {
639        harn_vm::set_stdout_passthrough(self.previous);
640    }
641}
642
643// User-facing copy on Ctrl-C. We want the operator to know that a brief
644// pause after the first signal is expected (the VM rewinds the active
645// instruction, drops in-flight async ops like a hanging Ollama request,
646// and unwinds frames before the runtime exits) so they don't reflexively
647// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
648// again to force-exit" hint is load-bearing — earlier runs of harn
649// released to the fleet showed operators routinely double-tapping the
650// shortcut and losing the chance to inspect the error trace.
651const FIRST_SIGNAL_MESSAGE: &str =
652    "[harn] signal received, interrupting VM (give it a moment to unwind in-flight async ops; Ctrl-C again to force-exit)...";
653
654fn install_signal_shutdown_handler() -> RunInterruptTokens {
655    let tokens = RunInterruptTokens {
656        cancel_token: Arc::new(AtomicBool::new(false)),
657        signal_token: Arc::new(Mutex::new(None)),
658    };
659    let tokens_clone = tokens.clone();
660    tokio::spawn(async move {
661        #[cfg(unix)]
662        {
663            use tokio::signal::unix::{signal, SignalKind};
664            let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
665            let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
666            let mut sighup = signal(SignalKind::hangup()).expect("SIGHUP handler");
667            let mut seen_signal = false;
668            loop {
669                let signal_name = tokio::select! {
670                    _ = sigterm.recv() => "SIGTERM",
671                    _ = sigint.recv() => "SIGINT",
672                    _ = sighup.recv() => "SIGHUP",
673                };
674                if seen_signal {
675                    eprintln!("[harn] second signal received, terminating");
676                    process::exit(124);
677                }
678                seen_signal = true;
679                request_vm_interrupt(&tokens_clone, signal_name);
680                eprintln!("{FIRST_SIGNAL_MESSAGE}");
681            }
682        }
683        #[cfg(not(unix))]
684        {
685            let mut seen_signal = false;
686            loop {
687                let _ = tokio::signal::ctrl_c().await;
688                if seen_signal {
689                    eprintln!("[harn] second signal received, terminating");
690                    process::exit(124);
691                }
692                seen_signal = true;
693                request_vm_interrupt(&tokens_clone, "SIGINT");
694                eprintln!("{FIRST_SIGNAL_MESSAGE}");
695            }
696        }
697    });
698    tokens
699}
700
701fn request_vm_interrupt(tokens: &RunInterruptTokens, signal_name: &str) {
702    if let Ok(mut signal) = tokens.signal_token.lock() {
703        *signal = Some(signal_name.to_string());
704    }
705    tokens.cancel_token.store(true, Ordering::SeqCst);
706}
707
708/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
709/// stdout, stderr, and what exit code the binary entry would have used,
710/// instead of writing to real stdout/stderr or calling `process::exit`.
711///
712/// Tests should call this directly. The `harn run` binary path wraps it.
713pub async fn execute_run(
714    path: &str,
715    trace: bool,
716    denied_builtins: HashSet<String>,
717    script_argv: Vec<String>,
718    skill_dirs_raw: Vec<String>,
719    llm_mock_mode: CliLlmMockMode,
720    attestation: Option<RunAttestationOptions>,
721    profile: RunProfileOptions,
722) -> RunOutcome {
723    execute_run_with_harnpack_options(
724        path,
725        trace,
726        denied_builtins,
727        script_argv,
728        skill_dirs_raw,
729        llm_mock_mode,
730        attestation,
731        profile,
732        HarnpackRunOptions::default(),
733    )
734    .await
735}
736
737/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
738/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
739/// integration tests and by the binary entry once it has parsed the
740/// `--allow-unsigned` / `--dry-run-verify` flags.
741#[allow(clippy::too_many_arguments)]
742pub async fn execute_run_with_harnpack_options(
743    path: &str,
744    trace: bool,
745    denied_builtins: HashSet<String>,
746    script_argv: Vec<String>,
747    skill_dirs_raw: Vec<String>,
748    llm_mock_mode: CliLlmMockMode,
749    attestation: Option<RunAttestationOptions>,
750    profile: RunProfileOptions,
751    harnpack: HarnpackRunOptions,
752) -> RunOutcome {
753    execute_run_inner(ExecuteRunInputs {
754        path,
755        trace,
756        denied_builtins,
757        script_argv,
758        skill_dirs_raw,
759        llm_mock_mode,
760        attestation,
761        profile,
762        interrupt_tokens: None,
763        json: None,
764        timing: None,
765        harnpack,
766    })
767    .await
768}
769
770/// `execute_run` variant for `--json` mode. Returns once the run is
771/// complete; the NDJSON event stream — including the terminal `result`
772/// or `error` event — has already been written to `out` and flushed.
773/// `out` must be `Send` because the run-event sink may be called from
774/// any worker thread the VM spawns.
775#[allow(clippy::too_many_arguments)]
776pub async fn execute_run_json(
777    path: &str,
778    trace: bool,
779    denied_builtins: HashSet<String>,
780    script_argv: Vec<String>,
781    skill_dirs_raw: Vec<String>,
782    llm_mock_mode: CliLlmMockMode,
783    attestation: Option<RunAttestationOptions>,
784    profile: RunProfileOptions,
785    out: Box<dyn io::Write + Send>,
786    options: RunJsonOptions,
787) -> RunOutcome {
788    execute_run_inner(ExecuteRunInputs {
789        path,
790        trace,
791        denied_builtins,
792        script_argv,
793        skill_dirs_raw,
794        llm_mock_mode,
795        attestation,
796        profile,
797        interrupt_tokens: None,
798        json: Some((options, out)),
799        timing: None,
800        harnpack: HarnpackRunOptions::default(),
801    })
802    .await
803}
804
805/// Run a `.harn` file with the default builtin/argv set and record
806/// phase timings into `timing`. Used by `harn time run` so the
807/// instrumented run shares the exact code path as plain `harn run`.
808pub(crate) async fn execute_run_with_timing(
809    path: &str,
810    script_argv: Vec<String>,
811    timing: Option<&mut RunTiming>,
812) -> RunOutcome {
813    execute_run_inner(ExecuteRunInputs {
814        path,
815        trace: false,
816        denied_builtins: HashSet::new(),
817        script_argv,
818        skill_dirs_raw: Vec::new(),
819        llm_mock_mode: CliLlmMockMode::Off,
820        attestation: None,
821        profile: RunProfileOptions::default(),
822        interrupt_tokens: None,
823        json: None,
824        timing,
825        harnpack: HarnpackRunOptions::default(),
826    })
827    .await
828}
829
830// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
831// the intentional reborrow pattern here.
832#[allow(clippy::needless_option_as_deref)]
833async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
834    let ExecuteRunInputs {
835        path,
836        trace,
837        denied_builtins,
838        script_argv,
839        skill_dirs_raw,
840        llm_mock_mode,
841        attestation,
842        profile,
843        interrupt_tokens,
844        json,
845        mut timing,
846        harnpack,
847    } = inputs;
848
849    // `--json` installs an in-process sink that diverts every
850    // observable VM event (stdout, stderr, transcript, tool, hook,
851    // persona) into a single NDJSON stream on `out`. The sink stays
852    // active until we drop the guard below — fatal errors emit a
853    // terminal `error` event on the same stream before bailing.
854    let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
855
856    let mut stderr = String::new();
857    let mut stdout = String::new();
858
859    // `.harnpack` preflight: verify signature + replay archive into the
860    // content-addressed cache before we touch the chunk loader. The
861    // outcome path (entrypoint inside the unpacked tree) replaces the
862    // CLI-supplied `path` for everything below.
863    let owned_run_path: String;
864    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
865        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
866            Ok(prepared) => prepared,
867            Err(err) => return finalize_harnpack_error(stderr, json_session, err),
868        };
869        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
870            bundle_hash: outcome.bundle_hash.clone(),
871            signature_verified: outcome.signature_verified,
872            key_id: outcome.key_id.clone(),
873            cache_hit: outcome.cache_hit,
874            dry_run_verify: harnpack.dry_run_verify,
875        });
876        if harnpack.dry_run_verify {
877            return finalize_harnpack_dry_run(stderr, json_session, &outcome);
878        }
879        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
880        owned_run_path.as_str()
881    } else {
882        path
883    };
884
885    let Some(LoadedChunk { source, chunk }) =
886        compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
887    else {
888        if let Some(session) = json_session {
889            return session.finalize_error("compile_error", stderr, 1);
890        }
891        return RunOutcome {
892            stdout,
893            stderr,
894            exit_code: 1,
895        };
896    };
897    let path = resolved_path;
898
899    // Bracket the VM-setup phase explicitly. `run_setup` covers
900    // everything between the bytecode compile and the first VM
901    // instruction; `run_main` covers `vm.execute` proper.
902    let setup_start = Instant::now();
903
904    if trace {
905        harn_vm::llm::enable_tracing();
906    }
907    if profile.is_enabled() {
908        harn_vm::tracing::set_tracing_enabled(true);
909    }
910    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
911        stderr.push_str(&format!("error: {error}\n"));
912        if let Some(session) = json_session {
913            return session.finalize_error("llm_mock_install", error, 1);
914        }
915        return RunOutcome {
916            stdout,
917            stderr,
918            exit_code: 1,
919        };
920    }
921
922    let mut vm = harn_vm::Vm::new();
923    if let Some(interrupt_tokens) = interrupt_tokens {
924        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
925        vm.install_cancel_token(interrupt_tokens.cancel_token);
926    }
927    harn_vm::register_vm_stdlib_with_deferred_llm(&mut vm);
928    crate::install_default_hostlib(&mut vm);
929    let source_parent = std::path::Path::new(path)
930        .parent()
931        .unwrap_or(std::path::Path::new("."));
932    // Metadata/store rooted at harn.toml when present; source dir otherwise.
933    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
934    let store_base = project_root.as_deref().unwrap_or(source_parent);
935    let attestation_started_at_ms = now_ms();
936    let attestation_log = if attestation.is_some() {
937        Some(harn_vm::event_log::install_memory_for_current_thread(256))
938    } else {
939        None
940    };
941    if let Some(log) = attestation_log.as_ref() {
942        append_run_provenance_event(
943            log,
944            "started",
945            serde_json::json!({
946                "pipeline": path,
947                "argv": &script_argv,
948                "project_root": store_base.display().to_string(),
949            }),
950        )
951        .await;
952    }
953    harn_vm::register_store_builtins(&mut vm, store_base);
954    harn_vm::register_metadata_builtins(&mut vm, store_base);
955    let pipeline_name = std::path::Path::new(path)
956        .file_stem()
957        .and_then(|s| s.to_str())
958        .unwrap_or("default");
959    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
960    vm.set_source_info(path, &source);
961    if !denied_builtins.is_empty() {
962        vm.set_denied_builtins(denied_builtins);
963    }
964    if let Some(ref root) = project_root {
965        vm.set_project_root(root);
966    }
967
968    if let Some(p) = std::path::Path::new(path).parent() {
969        if !p.as_os_str().is_empty() {
970            vm.set_source_dir(p);
971        }
972    }
973
974    // Load filesystem + manifest skills before the pipeline runs so
975    // `skills` is populated with a pre-discovered registry (see #73).
976    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
977    let loaded = load_skills(&SkillLoaderInputs {
978        cli_dirs,
979        source_path: Some(std::path::PathBuf::from(path)),
980    });
981    emit_loader_warnings(&loaded.loader_warnings);
982    install_skills_global(&mut vm, &loaded);
983
984    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
985    // Always set so scripts can rely on `len(argv)`.
986    let argv_values: Vec<harn_vm::VmValue> = script_argv
987        .iter()
988        .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
989        .collect();
990    vm.set_global(
991        "argv",
992        harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
993    );
994
995    // Install the script's `Harness` capability handle so the auto-call
996    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
997    // entrypoints can read it.
998    vm.set_harness(harn_vm::Harness::real());
999
1000    let extensions = package::load_runtime_extensions(Path::new(path));
1001    package::install_runtime_extensions(&extensions);
1002    if let Some(manifest) = extensions.root_manifest.as_ref() {
1003        if !manifest.mcp.is_empty() {
1004            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1005        }
1006    }
1007    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1008        stderr.push_str(&format!(
1009            "error: failed to install manifest triggers: {error}\n"
1010        ));
1011        if let Some(session) = json_session {
1012            return session.finalize_error("manifest_triggers", error.to_string(), 1);
1013        }
1014        return RunOutcome {
1015            stdout,
1016            stderr,
1017            exit_code: 1,
1018        };
1019    }
1020    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1021        stderr.push_str(&format!(
1022            "error: failed to install manifest hooks: {error}\n"
1023        ));
1024        if let Some(session) = json_session {
1025            return session.finalize_error("manifest_hooks", error.to_string(), 1);
1026        }
1027        return RunOutcome {
1028            stdout,
1029            stderr,
1030            exit_code: 1,
1031        };
1032    }
1033
1034    // Run inside a LocalSet so spawn_local works for concurrency builtins.
1035    let local = tokio::task::LocalSet::new();
1036    if let Some(t) = timing.as_deref_mut() {
1037        t.run_setup = setup_start.elapsed();
1038    }
1039    let main_start = Instant::now();
1040    let execution = local
1041        .run_until(async {
1042            match vm.execute(&chunk).await {
1043                Ok(value) => Ok((vm.output(), value)),
1044                Err(e) => Err(vm.format_runtime_error(&e)),
1045            }
1046        })
1047        .await;
1048    if let Some(t) = timing.as_deref_mut() {
1049        t.run_main = main_start.elapsed();
1050    }
1051    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1052        stderr.push_str(&format!("error: {error}\n"));
1053        if let Some(session) = json_session {
1054            return session.finalize_error("llm_mock_record", error, 1);
1055        }
1056        return RunOutcome {
1057            stdout,
1058            stderr,
1059            exit_code: 1,
1060        };
1061    }
1062
1063    // Always drain any captured stderr accumulated during execution.
1064    let buffered_stderr = harn_vm::take_stderr_buffer();
1065    stderr.push_str(&buffered_stderr);
1066
1067    let exit_code = match &execution {
1068        Ok((_, return_value)) => exit_code_from_return_value(return_value),
1069        Err(_) => 1,
1070    };
1071
1072    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1073        if let Err(error) = emit_run_attestation(
1074            log,
1075            path,
1076            store_base,
1077            attestation_started_at_ms,
1078            exit_code,
1079            options,
1080            &mut stderr,
1081        )
1082        .await
1083        {
1084            stderr.push_str(&format!(
1085                "error: failed to emit provenance receipt: {error}\n"
1086            ));
1087            if let Some(session) = json_session {
1088                return session.finalize_error("attestation", error.to_string(), 1);
1089            }
1090            return RunOutcome {
1091                stdout,
1092                stderr,
1093                exit_code: 1,
1094            };
1095        }
1096        harn_vm::event_log::reset_active_event_log();
1097    }
1098
1099    match execution {
1100        Ok((output, return_value)) => {
1101            stdout.push_str(output);
1102            if trace {
1103                stderr.push_str(&render_trace_summary());
1104            }
1105            if profile.is_enabled() {
1106                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1107                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1108                }
1109            }
1110            if exit_code != 0 {
1111                stderr.push_str(&render_return_value_error(&return_value));
1112            }
1113            if let Some(session) = json_session {
1114                let value = harn_vm::llm::vm_value_to_json(&return_value);
1115                return session.finalize_result(value, exit_code);
1116            }
1117            RunOutcome {
1118                stdout,
1119                stderr,
1120                exit_code,
1121            }
1122        }
1123        Err(rendered_error) => {
1124            stderr.push_str(&rendered_error);
1125            if profile.is_enabled() {
1126                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1127                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1128                }
1129            }
1130            if let Some(session) = json_session {
1131                return session.finalize_error("runtime", rendered_error, 1);
1132            }
1133            RunOutcome {
1134                stdout,
1135                stderr,
1136                exit_code: 1,
1137            }
1138        }
1139    }
1140}
1141
1142fn render_and_persist_profile(
1143    options: &RunProfileOptions,
1144    stderr: &mut String,
1145) -> Result<(), String> {
1146    let spans = harn_vm::tracing::peek_spans();
1147    let profile = harn_vm::profile::build(&spans);
1148    if options.text {
1149        stderr.push_str(&harn_vm::profile::render(&profile));
1150    }
1151    if let Some(path) = options.json_path.as_ref() {
1152        if let Some(parent) = path.parent() {
1153            if !parent.as_os_str().is_empty() {
1154                fs::create_dir_all(parent)
1155                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
1156            }
1157        }
1158        let json = serde_json::to_string_pretty(&profile)
1159            .map_err(|error| format!("serialize profile: {error}"))?;
1160        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1161    }
1162    Ok(())
1163}
1164
1165async fn append_run_provenance_event(
1166    log: &Arc<harn_vm::event_log::AnyEventLog>,
1167    kind: &str,
1168    payload: serde_json::Value,
1169) {
1170    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1171        return;
1172    };
1173    let _ = log
1174        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1175        .await;
1176}
1177
1178async fn emit_run_attestation(
1179    log: &Arc<harn_vm::event_log::AnyEventLog>,
1180    path: &str,
1181    store_base: &Path,
1182    started_at_ms: i64,
1183    exit_code: i32,
1184    options: &RunAttestationOptions,
1185    stderr: &mut String,
1186) -> Result<(), String> {
1187    let finished_at_ms = now_ms();
1188    let status = if exit_code == 0 { "success" } else { "failure" };
1189    append_run_provenance_event(
1190        log,
1191        "finished",
1192        serde_json::json!({
1193            "pipeline": path,
1194            "status": status,
1195            "exit_code": exit_code,
1196        }),
1197    )
1198    .await;
1199    log.flush()
1200        .await
1201        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1202    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1203        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1204    let (signing_key, key_id) =
1205        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1206            .await
1207            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1208    let receipt = harn_vm::build_signed_receipt(
1209        log,
1210        harn_vm::ReceiptBuildOptions {
1211            pipeline: path.to_string(),
1212            status: status.to_string(),
1213            started_at_ms,
1214            finished_at_ms,
1215            exit_code,
1216            producer_name: "harn-cli".to_string(),
1217            producer_version: env!("CARGO_PKG_VERSION").to_string(),
1218        },
1219        &signing_key,
1220        key_id,
1221    )
1222    .await
1223    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1224    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1225    if let Some(parent) = receipt_path.parent() {
1226        fs::create_dir_all(parent)
1227            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1228    }
1229    let encoded = serde_json::to_vec_pretty(&receipt)
1230        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1231    fs::write(&receipt_path, encoded)
1232        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1233    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1234    Ok(())
1235}
1236
1237fn receipt_output_path(
1238    store_base: &Path,
1239    options: &RunAttestationOptions,
1240    receipt_id: &str,
1241) -> PathBuf {
1242    if let Some(path) = options.receipt_out.as_ref() {
1243        return path.clone();
1244    }
1245    harn_vm::runtime_paths::state_root(store_base)
1246        .join("receipts")
1247        .join(format!("{receipt_id}.json"))
1248}
1249
1250fn now_ms() -> i64 {
1251    std::time::SystemTime::now()
1252        .duration_since(std::time::UNIX_EPOCH)
1253        .map(|duration| duration.as_millis() as i64)
1254        .unwrap_or(0)
1255}
1256
1257/// Map a script's top-level return value to a process exit code.
1258///
1259/// - `int n`             → exit n (clamped to 0..=255)
1260/// - `Result::Ok(_)`     → exit 0
1261/// - `Result::Err(_)`    → exit 1
1262/// - anything else       → exit 0
1263fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1264    use harn_vm::VmValue;
1265    match value {
1266        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1267        VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
1268        _ => 0,
1269    }
1270}
1271
1272/// State for a single `harn run --json` invocation. Installs the
1273/// run-event sink in [`Self::install`] and removes it in [`Drop`], so
1274/// every exit path through `execute_run_inner` cleans up correctly
1275/// even if a panic unwinds out of the VM. Save-and-restore of any
1276/// previously installed sink keeps the helper safe to nest (rare, but
1277/// in-process embeddings can call into `harn run` from a host that
1278/// already had a sink wired).
1279///
1280/// `finalize_result` / `finalize_error` emit the terminal event and
1281/// build a [`RunOutcome`] whose stdout/stderr captured-buffer fields
1282/// stay **empty** — the canonical stream is on `out`.
1283/// `outcome.exit_code` still carries the process exit code so the
1284/// binary entry can `process::exit(...)`.
1285struct JsonRunSession {
1286    emitter: self::json_events::NdjsonEmitter,
1287    prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
1288}
1289
1290impl JsonRunSession {
1291    fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1292        let emitter = NdjsonEmitter::new(out, options.quiet);
1293        let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
1294        Self {
1295            emitter,
1296            prior_sink,
1297        }
1298    }
1299
1300    fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1301        self.emitter.emit_result(value, exit_code);
1302        RunOutcome {
1303            stdout: String::new(),
1304            stderr: String::new(),
1305            exit_code,
1306        }
1307    }
1308
1309    fn finalize_error(
1310        self,
1311        code: impl Into<String>,
1312        message: impl Into<String>,
1313        exit_code: i32,
1314    ) -> RunOutcome {
1315        self.emitter.emit_error(code, message);
1316        RunOutcome {
1317            stdout: String::new(),
1318            stderr: String::new(),
1319            exit_code,
1320        }
1321    }
1322}
1323
1324impl Drop for JsonRunSession {
1325    fn drop(&mut self) {
1326        match self.prior_sink.take() {
1327            Some(prior) => {
1328                harn_vm::run_events::install_sink(prior);
1329            }
1330            None => harn_vm::run_events::clear_sink(),
1331        }
1332    }
1333}
1334
1335/// Translate a preflight failure into either the `--json` error event
1336/// stream or a plain stderr message plus exit-code 1. Keeps the
1337/// `.harnpack` verify path's error reporting consistent with the rest
1338/// of `harn run`.
1339fn finalize_harnpack_error(
1340    mut stderr: String,
1341    json_session: Option<JsonRunSession>,
1342    err: HarnpackError,
1343) -> RunOutcome {
1344    stderr.push_str(&format!("error: {}\n", err.message));
1345    if let Some(session) = json_session {
1346        return session.finalize_error(err.code, err.message, 1);
1347    }
1348    RunOutcome {
1349        stdout: String::new(),
1350        stderr,
1351        exit_code: 1,
1352    }
1353}
1354
1355/// Successful `--dry-run-verify` path. Reports the bundle hash and
1356/// signature outcome on stderr (since stdout belongs to the script) and
1357/// emits a terminal `result` event when `--json` is active so consumers
1358/// see the run complete.
1359fn finalize_harnpack_dry_run(
1360    mut stderr: String,
1361    json_session: Option<JsonRunSession>,
1362    prepared: &PreparedHarnpack,
1363) -> RunOutcome {
1364    let summary = format!(
1365        "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1366        prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1367    );
1368    stderr.push_str(&summary);
1369    if let Some(session) = json_session {
1370        let value = serde_json::json!({
1371            "bundle_hash": prepared.bundle_hash,
1372            "signature_verified": prepared.signature_verified,
1373            "key_id": prepared.key_id,
1374            "cache_hit": prepared.cache_hit,
1375            "dry_run_verify": true,
1376        });
1377        return session.finalize_result(value, 0);
1378    }
1379    RunOutcome {
1380        stdout: String::new(),
1381        stderr,
1382        exit_code: 0,
1383    }
1384}
1385
1386fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1387    let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
1388        return String::new();
1389    };
1390    if !enum_variant.is_variant("Result", "Err") {
1391        return String::new();
1392    }
1393    let rendered = enum_variant
1394        .fields
1395        .first()
1396        .map(|p| p.display())
1397        .unwrap_or_default();
1398    if rendered.is_empty() {
1399        "error\n".to_string()
1400    } else if rendered.ends_with('\n') {
1401        rendered
1402    } else {
1403        format!("{rendered}\n")
1404    }
1405}
1406
1407/// Connect to MCP servers declared in `harn.toml` and register them as
1408/// `mcp.<name>` globals on the VM. Connection failures are warned but do
1409/// not abort execution.
1410///
1411/// Servers with `lazy = true` are registered with the VM-side MCP
1412/// registry but NOT booted — their processes start the first time a
1413/// skill's `requires_mcp` list names them or user code calls
1414/// `mcp_ensure_active("name")` / `mcp_call(mcp.<name>, ...)`.
1415pub(crate) async fn connect_mcp_servers(
1416    servers: &[package::McpServerConfig],
1417    vm: &mut harn_vm::Vm,
1418) {
1419    use std::collections::BTreeMap;
1420    use std::rc::Rc;
1421    use std::time::Duration;
1422
1423    let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1424    let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1425
1426    for server in servers {
1427        let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1428            Ok(resolution) => resolution,
1429            Err(error) => {
1430                eprintln!(
1431                    "warning: mcp: failed to load auth for '{}': {}",
1432                    server.name, error
1433                );
1434                AuthResolution::None
1435            }
1436        };
1437        let spec = serde_json::json!({
1438            "name": server.name,
1439            "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1440            "command": server.command,
1441            "args": server.args,
1442            "env": server.env,
1443            "url": server.url,
1444            "auth_token": match resolved_auth {
1445                AuthResolution::Bearer(token) => Some(token),
1446                AuthResolution::None => server.auth_token.clone(),
1447            },
1448            "protocol_version": server.protocol_version,
1449            "proxy_server_name": server.proxy_server_name,
1450        });
1451
1452        // Register with the VM-side registry regardless of lazy flag —
1453        // skill activation and `mcp_ensure_active` look up specs there.
1454        registrations.push(harn_vm::RegisteredMcpServer {
1455            name: server.name.clone(),
1456            spec: spec.clone(),
1457            lazy: server.lazy,
1458            card: server.card.clone(),
1459            keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1460        });
1461
1462        if server.lazy {
1463            eprintln!(
1464                "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1465                server.name
1466            );
1467            continue;
1468        }
1469
1470        match harn_vm::connect_mcp_server_from_json(&spec).await {
1471            Ok(handle) => {
1472                eprintln!("[harn] mcp: connected to '{}'", server.name);
1473                harn_vm::mcp_install_active(&server.name, handle.clone());
1474                mcp_dict.insert(server.name.clone(), harn_vm::VmValue::mcp_client(handle));
1475            }
1476            Err(e) => {
1477                eprintln!(
1478                    "warning: mcp: failed to connect to '{}': {}",
1479                    server.name, e
1480                );
1481            }
1482        }
1483    }
1484
1485    // Install registrations AFTER eager connects so `install_active`
1486    // above doesn't get overwritten.
1487    harn_vm::mcp_register_servers(registrations);
1488
1489    if !mcp_dict.is_empty() {
1490        vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1491    }
1492}
1493
1494pub(crate) fn render_trace_summary() -> String {
1495    use std::fmt::Write;
1496    let entries = harn_vm::llm::take_trace();
1497    if entries.is_empty() {
1498        return String::new();
1499    }
1500    let mut out = String::new();
1501    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1502    let mut total_input = 0i64;
1503    let mut total_output = 0i64;
1504    let mut total_ms = 0u64;
1505    for (i, entry) in entries.iter().enumerate() {
1506        let _ = writeln!(
1507            out,
1508            "  #{}: {} | {} in + {} out tokens | {} ms",
1509            i + 1,
1510            entry.model,
1511            entry.input_tokens,
1512            entry.output_tokens,
1513            entry.duration_ms,
1514        );
1515        total_input += entry.input_tokens;
1516        total_output += entry.output_tokens;
1517        total_ms += entry.duration_ms;
1518    }
1519    let total_tokens = total_input + total_output;
1520    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
1521    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1522    let _ = writeln!(
1523        out,
1524        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1525        entries.len(),
1526        if entries.len() == 1 { "" } else { "s" },
1527        total_tokens,
1528        total_input,
1529        total_output,
1530        total_ms,
1531        cost,
1532    );
1533    out
1534}
1535
1536/// Run a .harn file as an MCP server using the script-driven surface.
1537/// The pipeline must call `mcp_tools(registry)` (or the alias
1538/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
1539/// register additional resources/prompts via `mcp_resource(...)` /
1540/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
1541///
1542/// Dispatched into by `harn serve mcp <file>` when the script does not
1543/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
1544///
1545/// `card_source` — optional `--card` argument. Accepts either a path to
1546/// a JSON file or an inline JSON string. When present, the card is
1547/// embedded in the `initialize` response and exposed as the
1548/// `well-known://mcp-card` resource.
1549pub(crate) async fn run_file_mcp_serve(
1550    path: &str,
1551    card_source: Option<&str>,
1552    mode: RunFileMcpServeMode,
1553) {
1554    let mut diagnostics = String::new();
1555    let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1556    else {
1557        eprint!("{diagnostics}");
1558        process::exit(1);
1559    };
1560    if !diagnostics.is_empty() {
1561        eprint!("{diagnostics}");
1562    }
1563
1564    let mut vm = harn_vm::Vm::new();
1565    harn_vm::register_vm_stdlib(&mut vm);
1566    crate::install_default_hostlib(&mut vm);
1567    let source_parent = std::path::Path::new(path)
1568        .parent()
1569        .unwrap_or(std::path::Path::new("."));
1570    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1571    let store_base = project_root.as_deref().unwrap_or(source_parent);
1572    harn_vm::register_store_builtins(&mut vm, store_base);
1573    harn_vm::register_metadata_builtins(&mut vm, store_base);
1574    let pipeline_name = std::path::Path::new(path)
1575        .file_stem()
1576        .and_then(|s| s.to_str())
1577        .unwrap_or("default");
1578    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1579    vm.set_source_info(path, &source);
1580    if let Some(ref root) = project_root {
1581        vm.set_project_root(root);
1582    }
1583    if let Some(p) = std::path::Path::new(path).parent() {
1584        if !p.as_os_str().is_empty() {
1585            vm.set_source_dir(p);
1586        }
1587    }
1588
1589    // Same skill discovery as `harn run` — see comment there.
1590    let loaded = load_skills(&SkillLoaderInputs {
1591        cli_dirs: Vec::new(),
1592        source_path: Some(std::path::PathBuf::from(path)),
1593    });
1594    emit_loader_warnings(&loaded.loader_warnings);
1595    install_skills_global(&mut vm, &loaded);
1596
1597    let extensions = package::load_runtime_extensions(Path::new(path));
1598    package::install_runtime_extensions(&extensions);
1599    if let Some(manifest) = extensions.root_manifest.as_ref() {
1600        if !manifest.mcp.is_empty() {
1601            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1602        }
1603    }
1604    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1605        eprintln!("error: failed to install manifest triggers: {error}");
1606        process::exit(1);
1607    }
1608    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1609        eprintln!("error: failed to install manifest hooks: {error}");
1610        process::exit(1);
1611    }
1612
1613    let local = tokio::task::LocalSet::new();
1614    local
1615        .run_until(async {
1616            match vm.execute(&chunk).await {
1617                Ok(_) => {}
1618                Err(e) => {
1619                    eprint!("{}", vm.format_runtime_error(&e));
1620                    process::exit(1);
1621                }
1622            }
1623
1624            // Pipeline output goes to stderr — stdout is the MCP transport.
1625            let output = vm.output();
1626            if !output.is_empty() {
1627                eprint!("{output}");
1628            }
1629
1630            let registry = match harn_vm::take_mcp_serve_registry() {
1631                Some(r) => r,
1632                None => {
1633                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1634                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1635                    process::exit(1);
1636                }
1637            };
1638
1639            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1640                Ok(t) => t,
1641                Err(e) => {
1642                    eprintln!("error: {e}");
1643                    process::exit(1);
1644                }
1645            };
1646
1647            let resources = harn_vm::take_mcp_serve_resources();
1648            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1649            let prompts = harn_vm::take_mcp_serve_prompts();
1650
1651            let server_name = std::path::Path::new(path)
1652                .file_stem()
1653                .and_then(|s| s.to_str())
1654                .unwrap_or("harn")
1655                .to_string();
1656
1657            let mut caps = Vec::new();
1658            if !tools.is_empty() {
1659                caps.push(format!(
1660                    "{} tool{}",
1661                    tools.len(),
1662                    if tools.len() == 1 { "" } else { "s" }
1663                ));
1664            }
1665            let total_resources = resources.len() + resource_templates.len();
1666            if total_resources > 0 {
1667                caps.push(format!(
1668                    "{total_resources} resource{}",
1669                    if total_resources == 1 { "" } else { "s" }
1670                ));
1671            }
1672            if !prompts.is_empty() {
1673                caps.push(format!(
1674                    "{} prompt{}",
1675                    prompts.len(),
1676                    if prompts.len() == 1 { "" } else { "s" }
1677                ));
1678            }
1679            eprintln!(
1680                "[harn] serve mcp: serving {} as '{server_name}'",
1681                caps.join(", ")
1682            );
1683
1684            let mut server =
1685                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1686            if let Some(source) = card_source {
1687                match resolve_card_source(source) {
1688                    Ok(card) => server = server.with_server_card(card),
1689                    Err(e) => {
1690                        eprintln!("error: --card: {e}");
1691                        process::exit(1);
1692                    }
1693                }
1694            }
1695            match mode {
1696                RunFileMcpServeMode::Stdio => {
1697                    if let Err(e) = server.run(&mut vm).await {
1698                        eprintln!("error: MCP server error: {e}");
1699                        process::exit(1);
1700                    }
1701                }
1702                RunFileMcpServeMode::Http {
1703                    options,
1704                    auth_policy,
1705                } => {
1706                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1707                        server,
1708                        vm,
1709                        options,
1710                        auth_policy,
1711                    )
1712                    .await
1713                    {
1714                        eprintln!("error: MCP server error: {e}");
1715                        process::exit(1);
1716                    }
1717                }
1718            }
1719        })
1720        .await;
1721}
1722
1723/// Accept either a path to a JSON file or an inline JSON blob and
1724/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
1725/// by peeking at the first non-whitespace character: `{` → inline JSON,
1726/// anything else → path.
1727pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1728    let trimmed = source.trim_start();
1729    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1730        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1731    }
1732    let path = std::path::Path::new(source);
1733    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1734}
1735
1736pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1737    use notify::{Event, EventKind, RecursiveMode, Watcher};
1738
1739    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1740        eprintln!("Error: {e}");
1741        process::exit(1);
1742    });
1743    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1744
1745    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1746    run_file(
1747        path,
1748        false,
1749        denied_builtins.clone(),
1750        Vec::new(),
1751        CliLlmMockMode::Off,
1752        None,
1753        RunProfileOptions::default(),
1754    )
1755    .await;
1756
1757    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1758    let _watcher = {
1759        let tx = tx.clone();
1760        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1761            if let Ok(event) = res {
1762                if matches!(
1763                    event.kind,
1764                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1765                ) {
1766                    let has_harn = event
1767                        .paths
1768                        .iter()
1769                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1770                    if has_harn {
1771                        let _ = tx.blocking_send(());
1772                    }
1773                }
1774            }
1775        })
1776        .unwrap_or_else(|e| {
1777            eprintln!("Error setting up file watcher: {e}");
1778            process::exit(1);
1779        });
1780        watcher
1781            .watch(watch_dir, RecursiveMode::Recursive)
1782            .unwrap_or_else(|e| {
1783                eprintln!("Error watching directory: {e}");
1784                process::exit(1);
1785            });
1786        watcher // keep alive
1787    };
1788
1789    eprintln!(
1790        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1791        watch_dir.display()
1792    );
1793
1794    loop {
1795        rx.recv().await;
1796        // Debounce: let bursts of events settle for 200ms before re-running.
1797        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1798        while rx.try_recv().is_ok() {}
1799
1800        eprintln!();
1801        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1802        run_file(
1803            path,
1804            false,
1805            denied_builtins.clone(),
1806            Vec::new(),
1807            CliLlmMockMode::Off,
1808            None,
1809            RunProfileOptions::default(),
1810        )
1811        .await;
1812    }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    use super::{
1818        execute_explain_cost, execute_run, split_eval_header, CliLlmMockMode, RunProfileOptions,
1819        StdoutPassthroughGuard,
1820    };
1821    use std::collections::HashSet;
1822
1823    #[test]
1824    fn split_eval_header_no_imports_returns_full_body() {
1825        let (header, body) = split_eval_header("log(1 + 2)");
1826        assert_eq!(header, "");
1827        assert_eq!(body, "log(1 + 2)");
1828    }
1829
1830    #[test]
1831    fn split_eval_header_lifts_leading_imports() {
1832        let code = "import \"./lib\"\nimport { x } from \"std/math\"\nlog(x)";
1833        let (header, body) = split_eval_header(code);
1834        assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1835        assert_eq!(body, "log(x)");
1836    }
1837
1838    #[test]
1839    fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1840        let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1841        let (header, body) = split_eval_header(code);
1842        assert_eq!(
1843            header,
1844            "// header comment\npub import { y } from \"./lib\"\n"
1845        );
1846        assert_eq!(body, "foo()");
1847    }
1848
1849    #[test]
1850    fn split_eval_header_does_not_lift_imports_after_other_statements() {
1851        let code = "let a = 1\nimport \"./lib\"";
1852        let (header, body) = split_eval_header(code);
1853        assert_eq!(header, "");
1854        assert_eq!(body, "let a = 1\nimport \"./lib\"");
1855    }
1856
1857    #[test]
1858    fn cli_llm_mock_roundtrips_logprobs() {
1859        let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
1860            "text": "visible",
1861            "logprobs": [{"token": "visible", "logprob": 0.0}]
1862        }))
1863        .expect("parse mock");
1864        assert_eq!(mock.logprobs.len(), 1);
1865
1866        let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
1867        let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1868        assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1869
1870        let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
1871        assert_eq!(reparsed.logprobs.len(), 1);
1872        assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1873    }
1874
1875    #[test]
1876    fn stdout_passthrough_guard_restores_previous_state() {
1877        let original = harn_vm::set_stdout_passthrough(false);
1878        {
1879            let _guard = StdoutPassthroughGuard::enable();
1880            assert!(harn_vm::set_stdout_passthrough(true));
1881        }
1882        assert!(!harn_vm::set_stdout_passthrough(original));
1883    }
1884
1885    #[test]
1886    fn execute_explain_cost_does_not_execute_script() {
1887        let temp = tempfile::TempDir::new().expect("temp dir");
1888        let script = temp.path().join("main.harn");
1889        std::fs::write(
1890            &script,
1891            r#"
1892pipeline main() {
1893  write_file("executed.txt", "bad")
1894  llm_call("hello", nil, {provider: "mock", model: "mock"})
1895}
1896"#,
1897        )
1898        .expect("write script");
1899
1900        let outcome = execute_explain_cost(&script.to_string_lossy());
1901
1902        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1903        assert!(outcome.stdout.contains("LLM cost estimate"));
1904        assert!(
1905            !temp.path().join("executed.txt").exists(),
1906            "--explain-cost must not execute pipeline side effects"
1907        );
1908    }
1909
1910    #[cfg(feature = "hostlib")]
1911    #[tokio::test]
1912    async fn execute_run_installs_hostlib_gate() {
1913        let temp = tempfile::NamedTempFile::new().expect("temp file");
1914        std::fs::write(
1915            temp.path(),
1916            r#"
1917pipeline main() {
1918  let _ = hostlib_enable("tools:deterministic")
1919  __io_println("enabled")
1920}
1921"#,
1922        )
1923        .expect("write script");
1924
1925        let outcome = execute_run(
1926            &temp.path().to_string_lossy(),
1927            false,
1928            HashSet::new(),
1929            Vec::new(),
1930            Vec::new(),
1931            CliLlmMockMode::Off,
1932            None,
1933            RunProfileOptions::default(),
1934        )
1935        .await;
1936
1937        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1938        assert_eq!(outcome.stdout.trim(), "enabled");
1939    }
1940
1941    #[cfg(all(feature = "hostlib", unix))]
1942    #[tokio::test]
1943    async fn execute_run_can_read_hostlib_command_artifacts() {
1944        let temp = tempfile::NamedTempFile::new().expect("temp file");
1945        std::fs::write(
1946            temp.path(),
1947            r#"
1948pipeline main() {
1949  let _ = hostlib_enable("tools:deterministic")
1950  let result = hostlib_tools_run_command({
1951    argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1952    capture: {max_inline_bytes: 8},
1953    timeout_ms: 5000,
1954  })
1955  __io_println(starts_with(result.command_id, "cmd_"))
1956  __io_println(len(result.stdout))
1957  __io_println(result.byte_count)
1958  let window = hostlib_tools_read_command_output({
1959    command_id: result.command_id,
1960    offset: 1990,
1961    length: 20,
1962  })
1963  __io_println(len(window.content))
1964  __io_println(window.eof)
1965}
1966"#,
1967        )
1968        .expect("write script");
1969
1970        let outcome = execute_run(
1971            &temp.path().to_string_lossy(),
1972            false,
1973            HashSet::new(),
1974            Vec::new(),
1975            Vec::new(),
1976            CliLlmMockMode::Off,
1977            None,
1978            RunProfileOptions::default(),
1979        )
1980        .await;
1981
1982        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1983        assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1984    }
1985}