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#[derive(Clone, Default)]
31pub struct RunJsonOptions {
32 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
45const 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
68pub(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 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 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
106pub(crate) struct LoadedChunk {
110 pub(crate) source: String,
111 pub(crate) chunk: harn_vm::Chunk,
112}
113
114pub(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#[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 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
220fn 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
247pub(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
271fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
276 if let Some(dir) = std::env::current_dir().ok().as_deref() {
277 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
299fn 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#[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#[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 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 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
643const 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
708pub 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#[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#[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
805pub(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#[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 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 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 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(&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 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 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 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 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 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 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
1257fn 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 {
1268 enum_name,
1269 variant,
1270 fields,
1271 } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
1272 _ => 0,
1273 }
1274}
1275
1276struct JsonRunSession {
1290 emitter: self::json_events::NdjsonEmitter,
1291 prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
1292}
1293
1294impl JsonRunSession {
1295 fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1296 let emitter = NdjsonEmitter::new(out, options.quiet);
1297 let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
1298 Self {
1299 emitter,
1300 prior_sink,
1301 }
1302 }
1303
1304 fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1305 self.emitter.emit_result(value, exit_code);
1306 RunOutcome {
1307 stdout: String::new(),
1308 stderr: String::new(),
1309 exit_code,
1310 }
1311 }
1312
1313 fn finalize_error(
1314 self,
1315 code: impl Into<String>,
1316 message: impl Into<String>,
1317 exit_code: i32,
1318 ) -> RunOutcome {
1319 self.emitter.emit_error(code, message);
1320 RunOutcome {
1321 stdout: String::new(),
1322 stderr: String::new(),
1323 exit_code,
1324 }
1325 }
1326}
1327
1328impl Drop for JsonRunSession {
1329 fn drop(&mut self) {
1330 match self.prior_sink.take() {
1331 Some(prior) => {
1332 harn_vm::run_events::install_sink(prior);
1333 }
1334 None => harn_vm::run_events::clear_sink(),
1335 }
1336 }
1337}
1338
1339fn finalize_harnpack_error(
1344 mut stderr: String,
1345 json_session: Option<JsonRunSession>,
1346 err: HarnpackError,
1347) -> RunOutcome {
1348 stderr.push_str(&format!("error: {}\n", err.message));
1349 if let Some(session) = json_session {
1350 return session.finalize_error(err.code, err.message, 1);
1351 }
1352 RunOutcome {
1353 stdout: String::new(),
1354 stderr,
1355 exit_code: 1,
1356 }
1357}
1358
1359fn finalize_harnpack_dry_run(
1364 mut stderr: String,
1365 json_session: Option<JsonRunSession>,
1366 prepared: &PreparedHarnpack,
1367) -> RunOutcome {
1368 let summary = format!(
1369 "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1370 prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1371 );
1372 stderr.push_str(&summary);
1373 if let Some(session) = json_session {
1374 let value = serde_json::json!({
1375 "bundle_hash": prepared.bundle_hash,
1376 "signature_verified": prepared.signature_verified,
1377 "key_id": prepared.key_id,
1378 "cache_hit": prepared.cache_hit,
1379 "dry_run_verify": true,
1380 });
1381 return session.finalize_result(value, 0);
1382 }
1383 RunOutcome {
1384 stdout: String::new(),
1385 stderr,
1386 exit_code: 0,
1387 }
1388}
1389
1390fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1391 let harn_vm::VmValue::EnumVariant {
1392 enum_name,
1393 variant,
1394 fields,
1395 } = value
1396 else {
1397 return String::new();
1398 };
1399 if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
1400 return String::new();
1401 }
1402 let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
1403 if rendered.is_empty() {
1404 "error\n".to_string()
1405 } else if rendered.ends_with('\n') {
1406 rendered
1407 } else {
1408 format!("{rendered}\n")
1409 }
1410}
1411
1412pub(crate) async fn connect_mcp_servers(
1421 servers: &[package::McpServerConfig],
1422 vm: &mut harn_vm::Vm,
1423) {
1424 use std::collections::BTreeMap;
1425 use std::rc::Rc;
1426 use std::time::Duration;
1427
1428 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1429 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1430
1431 for server in servers {
1432 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1433 Ok(resolution) => resolution,
1434 Err(error) => {
1435 eprintln!(
1436 "warning: mcp: failed to load auth for '{}': {}",
1437 server.name, error
1438 );
1439 AuthResolution::None
1440 }
1441 };
1442 let spec = serde_json::json!({
1443 "name": server.name,
1444 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1445 "command": server.command,
1446 "args": server.args,
1447 "env": server.env,
1448 "url": server.url,
1449 "auth_token": match resolved_auth {
1450 AuthResolution::Bearer(token) => Some(token),
1451 AuthResolution::None => server.auth_token.clone(),
1452 },
1453 "protocol_version": server.protocol_version,
1454 "proxy_server_name": server.proxy_server_name,
1455 });
1456
1457 registrations.push(harn_vm::RegisteredMcpServer {
1460 name: server.name.clone(),
1461 spec: spec.clone(),
1462 lazy: server.lazy,
1463 card: server.card.clone(),
1464 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1465 });
1466
1467 if server.lazy {
1468 eprintln!(
1469 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1470 server.name
1471 );
1472 continue;
1473 }
1474
1475 match harn_vm::connect_mcp_server_from_json(&spec).await {
1476 Ok(handle) => {
1477 eprintln!("[harn] mcp: connected to '{}'", server.name);
1478 harn_vm::mcp_install_active(&server.name, handle.clone());
1479 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
1480 }
1481 Err(e) => {
1482 eprintln!(
1483 "warning: mcp: failed to connect to '{}': {}",
1484 server.name, e
1485 );
1486 }
1487 }
1488 }
1489
1490 harn_vm::mcp_register_servers(registrations);
1493
1494 if !mcp_dict.is_empty() {
1495 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1496 }
1497}
1498
1499pub(crate) fn render_trace_summary() -> String {
1500 use std::fmt::Write;
1501 let entries = harn_vm::llm::take_trace();
1502 if entries.is_empty() {
1503 return String::new();
1504 }
1505 let mut out = String::new();
1506 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1507 let mut total_input = 0i64;
1508 let mut total_output = 0i64;
1509 let mut total_ms = 0u64;
1510 for (i, entry) in entries.iter().enumerate() {
1511 let _ = writeln!(
1512 out,
1513 " #{}: {} | {} in + {} out tokens | {} ms",
1514 i + 1,
1515 entry.model,
1516 entry.input_tokens,
1517 entry.output_tokens,
1518 entry.duration_ms,
1519 );
1520 total_input += entry.input_tokens;
1521 total_output += entry.output_tokens;
1522 total_ms += entry.duration_ms;
1523 }
1524 let total_tokens = total_input + total_output;
1525 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1527 let _ = writeln!(
1528 out,
1529 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1530 entries.len(),
1531 if entries.len() == 1 { "" } else { "s" },
1532 total_tokens,
1533 total_input,
1534 total_output,
1535 total_ms,
1536 cost,
1537 );
1538 out
1539}
1540
1541pub(crate) async fn run_file_mcp_serve(
1555 path: &str,
1556 card_source: Option<&str>,
1557 mode: RunFileMcpServeMode,
1558) {
1559 let mut diagnostics = String::new();
1560 let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1561 else {
1562 eprint!("{diagnostics}");
1563 process::exit(1);
1564 };
1565 if !diagnostics.is_empty() {
1566 eprint!("{diagnostics}");
1567 }
1568
1569 let mut vm = harn_vm::Vm::new();
1570 harn_vm::register_vm_stdlib(&mut vm);
1571 crate::install_default_hostlib(&mut vm);
1572 let source_parent = std::path::Path::new(path)
1573 .parent()
1574 .unwrap_or(std::path::Path::new("."));
1575 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1576 let store_base = project_root.as_deref().unwrap_or(source_parent);
1577 harn_vm::register_store_builtins(&mut vm, store_base);
1578 harn_vm::register_metadata_builtins(&mut vm, store_base);
1579 let pipeline_name = std::path::Path::new(path)
1580 .file_stem()
1581 .and_then(|s| s.to_str())
1582 .unwrap_or("default");
1583 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1584 vm.set_source_info(path, &source);
1585 if let Some(ref root) = project_root {
1586 vm.set_project_root(root);
1587 }
1588 if let Some(p) = std::path::Path::new(path).parent() {
1589 if !p.as_os_str().is_empty() {
1590 vm.set_source_dir(p);
1591 }
1592 }
1593
1594 let loaded = load_skills(&SkillLoaderInputs {
1596 cli_dirs: Vec::new(),
1597 source_path: Some(std::path::PathBuf::from(path)),
1598 });
1599 emit_loader_warnings(&loaded.loader_warnings);
1600 install_skills_global(&mut vm, &loaded);
1601
1602 let extensions = package::load_runtime_extensions(Path::new(path));
1603 package::install_runtime_extensions(&extensions);
1604 if let Some(manifest) = extensions.root_manifest.as_ref() {
1605 if !manifest.mcp.is_empty() {
1606 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1607 }
1608 }
1609 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1610 eprintln!("error: failed to install manifest triggers: {error}");
1611 process::exit(1);
1612 }
1613 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1614 eprintln!("error: failed to install manifest hooks: {error}");
1615 process::exit(1);
1616 }
1617
1618 let local = tokio::task::LocalSet::new();
1619 local
1620 .run_until(async {
1621 match vm.execute(&chunk).await {
1622 Ok(_) => {}
1623 Err(e) => {
1624 eprint!("{}", vm.format_runtime_error(&e));
1625 process::exit(1);
1626 }
1627 }
1628
1629 let output = vm.output();
1631 if !output.is_empty() {
1632 eprint!("{output}");
1633 }
1634
1635 let registry = match harn_vm::take_mcp_serve_registry() {
1636 Some(r) => r,
1637 None => {
1638 eprintln!("error: pipeline did not call mcp_serve(registry)");
1639 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1640 process::exit(1);
1641 }
1642 };
1643
1644 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
1645 Ok(t) => t,
1646 Err(e) => {
1647 eprintln!("error: {e}");
1648 process::exit(1);
1649 }
1650 };
1651
1652 let resources = harn_vm::take_mcp_serve_resources();
1653 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1654 let prompts = harn_vm::take_mcp_serve_prompts();
1655
1656 let server_name = std::path::Path::new(path)
1657 .file_stem()
1658 .and_then(|s| s.to_str())
1659 .unwrap_or("harn")
1660 .to_string();
1661
1662 let mut caps = Vec::new();
1663 if !tools.is_empty() {
1664 caps.push(format!(
1665 "{} tool{}",
1666 tools.len(),
1667 if tools.len() == 1 { "" } else { "s" }
1668 ));
1669 }
1670 let total_resources = resources.len() + resource_templates.len();
1671 if total_resources > 0 {
1672 caps.push(format!(
1673 "{total_resources} resource{}",
1674 if total_resources == 1 { "" } else { "s" }
1675 ));
1676 }
1677 if !prompts.is_empty() {
1678 caps.push(format!(
1679 "{} prompt{}",
1680 prompts.len(),
1681 if prompts.len() == 1 { "" } else { "s" }
1682 ));
1683 }
1684 eprintln!(
1685 "[harn] serve mcp: serving {} as '{server_name}'",
1686 caps.join(", ")
1687 );
1688
1689 let mut server =
1690 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1691 if let Some(source) = card_source {
1692 match resolve_card_source(source) {
1693 Ok(card) => server = server.with_server_card(card),
1694 Err(e) => {
1695 eprintln!("error: --card: {e}");
1696 process::exit(1);
1697 }
1698 }
1699 }
1700 match mode {
1701 RunFileMcpServeMode::Stdio => {
1702 if let Err(e) = server.run(&mut vm).await {
1703 eprintln!("error: MCP server error: {e}");
1704 process::exit(1);
1705 }
1706 }
1707 RunFileMcpServeMode::Http {
1708 options,
1709 auth_policy,
1710 } => {
1711 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1712 server,
1713 vm,
1714 options,
1715 auth_policy,
1716 )
1717 .await
1718 {
1719 eprintln!("error: MCP server error: {e}");
1720 process::exit(1);
1721 }
1722 }
1723 }
1724 })
1725 .await;
1726}
1727
1728pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1733 let trimmed = source.trim_start();
1734 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1735 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1736 }
1737 let path = std::path::Path::new(source);
1738 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1739}
1740
1741pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1742 use notify::{Event, EventKind, RecursiveMode, Watcher};
1743
1744 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1745 eprintln!("Error: {e}");
1746 process::exit(1);
1747 });
1748 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1749
1750 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1751 run_file(
1752 path,
1753 false,
1754 denied_builtins.clone(),
1755 Vec::new(),
1756 CliLlmMockMode::Off,
1757 None,
1758 RunProfileOptions::default(),
1759 )
1760 .await;
1761
1762 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1763 let _watcher = {
1764 let tx = tx.clone();
1765 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1766 if let Ok(event) = res {
1767 if matches!(
1768 event.kind,
1769 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1770 ) {
1771 let has_harn = event
1772 .paths
1773 .iter()
1774 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1775 if has_harn {
1776 let _ = tx.blocking_send(());
1777 }
1778 }
1779 }
1780 })
1781 .unwrap_or_else(|e| {
1782 eprintln!("Error setting up file watcher: {e}");
1783 process::exit(1);
1784 });
1785 watcher
1786 .watch(watch_dir, RecursiveMode::Recursive)
1787 .unwrap_or_else(|e| {
1788 eprintln!("Error watching directory: {e}");
1789 process::exit(1);
1790 });
1791 watcher };
1793
1794 eprintln!(
1795 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1796 watch_dir.display()
1797 );
1798
1799 loop {
1800 rx.recv().await;
1801 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1803 while rx.try_recv().is_ok() {}
1804
1805 eprintln!();
1806 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1807 run_file(
1808 path,
1809 false,
1810 denied_builtins.clone(),
1811 Vec::new(),
1812 CliLlmMockMode::Off,
1813 None,
1814 RunProfileOptions::default(),
1815 )
1816 .await;
1817 }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::{
1823 execute_explain_cost, execute_run, split_eval_header, CliLlmMockMode, RunProfileOptions,
1824 StdoutPassthroughGuard,
1825 };
1826 use std::collections::HashSet;
1827
1828 #[test]
1829 fn split_eval_header_no_imports_returns_full_body() {
1830 let (header, body) = split_eval_header("println(1 + 2)");
1831 assert_eq!(header, "");
1832 assert_eq!(body, "println(1 + 2)");
1833 }
1834
1835 #[test]
1836 fn split_eval_header_lifts_leading_imports() {
1837 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1838 let (header, body) = split_eval_header(code);
1839 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1840 assert_eq!(body, "println(x)");
1841 }
1842
1843 #[test]
1844 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1845 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1846 let (header, body) = split_eval_header(code);
1847 assert_eq!(
1848 header,
1849 "// header comment\npub import { y } from \"./lib\"\n"
1850 );
1851 assert_eq!(body, "foo()");
1852 }
1853
1854 #[test]
1855 fn split_eval_header_does_not_lift_imports_after_other_statements() {
1856 let code = "let a = 1\nimport \"./lib\"";
1857 let (header, body) = split_eval_header(code);
1858 assert_eq!(header, "");
1859 assert_eq!(body, "let a = 1\nimport \"./lib\"");
1860 }
1861
1862 #[test]
1863 fn cli_llm_mock_roundtrips_logprobs() {
1864 let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
1865 "text": "visible",
1866 "logprobs": [{"token": "visible", "logprob": 0.0}]
1867 }))
1868 .expect("parse mock");
1869 assert_eq!(mock.logprobs.len(), 1);
1870
1871 let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
1872 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1873 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1874
1875 let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
1876 assert_eq!(reparsed.logprobs.len(), 1);
1877 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1878 }
1879
1880 #[test]
1881 fn stdout_passthrough_guard_restores_previous_state() {
1882 let original = harn_vm::set_stdout_passthrough(false);
1883 {
1884 let _guard = StdoutPassthroughGuard::enable();
1885 assert!(harn_vm::set_stdout_passthrough(true));
1886 }
1887 assert!(!harn_vm::set_stdout_passthrough(original));
1888 }
1889
1890 #[test]
1891 fn execute_explain_cost_does_not_execute_script() {
1892 let temp = tempfile::TempDir::new().expect("temp dir");
1893 let script = temp.path().join("main.harn");
1894 std::fs::write(
1895 &script,
1896 r#"
1897pipeline main() {
1898 write_file("executed.txt", "bad")
1899 llm_call("hello", nil, {provider: "mock", model: "mock"})
1900}
1901"#,
1902 )
1903 .expect("write script");
1904
1905 let outcome = execute_explain_cost(&script.to_string_lossy());
1906
1907 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1908 assert!(outcome.stdout.contains("LLM cost estimate"));
1909 assert!(
1910 !temp.path().join("executed.txt").exists(),
1911 "--explain-cost must not execute pipeline side effects"
1912 );
1913 }
1914
1915 #[cfg(feature = "hostlib")]
1916 #[tokio::test]
1917 async fn execute_run_installs_hostlib_gate() {
1918 let temp = tempfile::NamedTempFile::new().expect("temp file");
1919 std::fs::write(
1920 temp.path(),
1921 r#"
1922pipeline main() {
1923 let _ = hostlib_enable("tools:deterministic")
1924 println("enabled")
1925}
1926"#,
1927 )
1928 .expect("write script");
1929
1930 let outcome = execute_run(
1931 &temp.path().to_string_lossy(),
1932 false,
1933 HashSet::new(),
1934 Vec::new(),
1935 Vec::new(),
1936 CliLlmMockMode::Off,
1937 None,
1938 RunProfileOptions::default(),
1939 )
1940 .await;
1941
1942 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1943 assert_eq!(outcome.stdout.trim(), "enabled");
1944 }
1945
1946 #[cfg(all(feature = "hostlib", unix))]
1947 #[tokio::test]
1948 async fn execute_run_can_read_hostlib_command_artifacts() {
1949 let temp = tempfile::NamedTempFile::new().expect("temp file");
1950 std::fs::write(
1951 temp.path(),
1952 r#"
1953pipeline main() {
1954 let _ = hostlib_enable("tools:deterministic")
1955 let result = hostlib_tools_run_command({
1956 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1957 capture: {max_inline_bytes: 8},
1958 timeout_ms: 5000,
1959 })
1960 println(starts_with(result.command_id, "cmd_"))
1961 println(len(result.stdout))
1962 println(result.byte_count)
1963 let window = hostlib_tools_read_command_output({
1964 command_id: result.command_id,
1965 offset: 1990,
1966 length: 20,
1967 })
1968 println(len(window.content))
1969 println(window.eof)
1970}
1971"#,
1972 )
1973 .expect("write script");
1974
1975 let outcome = execute_run(
1976 &temp.path().to_string_lossy(),
1977 false,
1978 HashSet::new(),
1979 Vec::new(),
1980 Vec::new(),
1981 CliLlmMockMode::Off,
1982 None,
1983 RunProfileOptions::default(),
1984 )
1985 .await;
1986
1987 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1988 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1989 }
1990}