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;
12use serde::Serialize;
13
14use crate::commands::mcp::{self, AuthResolution};
15use crate::commands::time::{self, PhaseRecord, RunTiming};
16use crate::package;
17use crate::parse_source_file;
18use crate::skill_loader::{
19 canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
20 SkillLoaderInputs,
21};
22
23mod explain_cost;
24pub mod harnpack;
25pub mod json_events;
26
27use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
28use self::json_events::NdjsonEmitter;
29
30#[derive(Clone, Default)]
32pub struct RunJsonOptions {
33 pub quiet: bool,
36}
37
38#[derive(Clone, Debug)]
40pub struct RunSummaryOptions {
41 pub sink: RunJsonSink,
42}
43
44#[derive(Clone, Debug)]
45pub struct RunPhaseOptions {
46 pub sink: RunJsonSink,
47}
48
49#[derive(Clone, Debug)]
50pub struct RunRusageOptions {
51 pub sink: RunJsonSink,
52}
53
54#[derive(Clone, Debug, Default)]
55pub struct RunAuxOptions {
56 pub summary: Option<RunSummaryOptions>,
57 pub phase: Option<RunPhaseOptions>,
58 pub rusage: Option<RunRusageOptions>,
59}
60
61#[derive(Clone, Debug)]
62pub struct RunJsonSink {
63 pub target: RunJsonSinkTarget,
64 pub fd_flag: &'static str,
65}
66
67#[derive(Clone, Debug)]
68pub enum RunJsonSinkTarget {
69 Stderr,
73 File(PathBuf),
74 Fd(i32),
75}
76
77#[derive(Serialize)]
78struct RunSummary<'a> {
79 schema_version: u32,
80 event: &'static str,
81 wall_time_ms: u64,
82 exit_code: i32,
83 llm: RunSummaryLlm,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 profile: Option<&'a harn_vm::profile::RunProfile>,
86}
87
88#[derive(Serialize)]
89struct RunSummaryLlm {
90 call_count: i64,
91 input_tokens: i64,
92 output_tokens: i64,
93 time_ms: i64,
94 cost_usd: f64,
95}
96
97pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 1;
98pub const RUN_PHASE_SCHEMA_VERSION: u32 = 1;
99pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
100
101#[derive(Serialize)]
102struct RunPhaseEvent {
103 schema_version: u32,
104 event: &'static str,
105 phases: Vec<PhaseRecord>,
106}
107
108#[derive(Serialize)]
109struct RunRusageEvent {
110 schema_version: u32,
111 event: &'static str,
112 cpu_ms: u64,
113}
114
115pub(crate) fn run_summary_options_from_args(
116 args: &crate::cli::RunArgs,
117) -> Option<RunSummaryOptions> {
118 args.emit_summary_json.then(|| RunSummaryOptions {
119 sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
120 })
121}
122
123pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
124 RunAuxOptions {
125 summary: run_summary_options_from_args(args),
126 phase: run_phase_options_from_args(args),
127 rusage: run_rusage_options_from_args(args),
128 }
129}
130
131pub(crate) fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
132 args.emit_phase_json.then(|| RunPhaseOptions {
133 sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
134 })
135}
136
137pub(crate) fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
138 args.emit_rusage_json.then(|| RunRusageOptions {
139 sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
140 })
141}
142
143fn build_run_json_sink(
144 file: Option<PathBuf>,
145 fd: Option<i32>,
146 fd_flag: &'static str,
147) -> RunJsonSink {
148 RunJsonSink {
149 target: if let Some(path) = file {
150 RunJsonSinkTarget::File(path)
151 } else if let Some(fd) = fd {
152 RunJsonSinkTarget::Fd(fd)
153 } else {
154 RunJsonSinkTarget::Stderr
155 },
156 fd_flag,
157 }
158}
159
160pub(crate) enum RunFileMcpServeMode {
161 Stdio,
162 Http {
163 options: harn_serve::McpHttpServeOptions,
164 auth_policy: harn_serve::AuthPolicy,
165 },
166}
167
168const CORE_BUILTINS: &[&str] = &[
170 "println",
171 "print",
172 "log",
173 "type_of",
174 "to_string",
175 "to_int",
176 "to_float",
177 "len",
178 "assert",
179 "assert_eq",
180 "assert_ne",
181 "json_parse",
182 "json_stringify",
183 "runtime_context",
184 "task_current",
185 "runtime_context_values",
186 "runtime_context_get",
187 "runtime_context_set",
188 "runtime_context_clear",
189];
190
191pub(crate) fn build_denied_builtins(
196 deny_csv: Option<&str>,
197 allow_csv: Option<&str>,
198) -> HashSet<String> {
199 if let Some(csv) = deny_csv {
200 csv.split(',')
201 .map(|s| s.trim().to_string())
202 .filter(|s| !s.is_empty())
203 .collect()
204 } else if let Some(csv) = allow_csv {
205 let allowed: HashSet<String> = csv
208 .split(',')
209 .map(|s| s.trim().to_string())
210 .filter(|s| !s.is_empty())
211 .collect();
212 let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
213
214 let mut tmp = harn_vm::Vm::new();
216 harn_vm::register_vm_stdlib(&mut tmp);
217 harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
218 harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
219
220 tmp.builtin_names()
221 .into_iter()
222 .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
223 .collect()
224 } else {
225 HashSet::new()
226 }
227}
228
229pub(crate) struct LoadedChunk {
233 pub(crate) source: String,
234 pub(crate) chunk: harn_vm::Chunk,
235}
236
237pub(crate) fn compile_or_load_chunk_for_run(
249 path: &str,
250 stderr: &mut String,
251) -> Option<LoadedChunk> {
252 compile_or_load_chunk_with_timing(path, stderr, None)
253}
254
255#[allow(clippy::needless_option_as_deref)]
265pub(crate) fn compile_or_load_chunk_with_timing(
266 path: &str,
267 stderr: &mut String,
268 mut timing: Option<&mut RunTiming>,
269) -> Option<LoadedChunk> {
270 let source = match fs::read_to_string(path) {
271 Ok(s) => s,
272 Err(e) => {
273 stderr.push_str(&format!("Error reading {path}: {e}\n"));
274 return None;
275 }
276 };
277 if let Some(t) = timing.as_deref_mut() {
278 t.input_bytes = source.len() as u64;
279 }
280
281 let compile_phase_start = Instant::now();
282 let lookup = harn_vm::bytecode_cache::load(Path::new(path), &source);
283 if let Some(chunk) = lookup.chunk {
284 if let Some(t) = timing.as_deref_mut() {
285 t.cache_hit = true;
286 t.bytecode_compile = compile_phase_start.elapsed();
287 }
288 return Some(LoadedChunk { source, chunk });
289 }
290 if let Some(t) = timing.as_deref_mut() {
291 t.cache_hit = false;
292 }
293
294 let parse_start = Instant::now();
295 let program = parse_source_for_run(path, &source, stderr)?;
296 if let Some(t) = timing.as_deref_mut() {
297 t.parse = parse_start.elapsed();
298 }
299
300 let typecheck_start = Instant::now();
301 let mut had_type_error = false;
302 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
303 for diag in &type_diagnostics {
304 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
305 if matches!(diag.severity, DiagnosticSeverity::Error) {
306 had_type_error = true;
307 }
308 stderr.push_str(&rendered);
309 }
310 if let Some(t) = timing.as_deref_mut() {
311 t.typecheck = typecheck_start.elapsed();
312 }
313 if had_type_error {
314 return None;
315 }
316
317 let compile_step_start = Instant::now();
318 let chunk = match harn_vm::Compiler::new().compile(&program) {
319 Ok(c) => c,
320 Err(e) => {
321 stderr.push_str(&format!("error: compile error: {e}\n"));
322 return None;
323 }
324 };
325
326 if let Err(err) = harn_vm::bytecode_cache::store(&lookup.key, &chunk) {
331 if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
332 eprintln!("[harn] bytecode cache write skipped: {err}");
333 }
334 }
335 if let Some(t) = timing.as_deref_mut() {
336 t.bytecode_compile = compile_step_start.elapsed();
337 }
338
339 Some(LoadedChunk { source, chunk })
340}
341
342fn parse_source_for_run(
343 path: &str,
344 source: &str,
345 stderr: &mut String,
346) -> Option<Vec<harn_parser::SNode>> {
347 let mut lexer = harn_lexer::Lexer::new(source);
348 let tokens = match lexer.tokenize() {
349 Ok(tokens) => tokens,
350 Err(error) => {
351 let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
352 source,
353 path,
354 &error_span_from_lex(&error),
355 "error",
356 harn_parser::diagnostic::lexer_error_code(&error),
357 &error.to_string(),
358 Some("here"),
359 None,
360 );
361 stderr.push_str(&diagnostic);
362 return None;
363 }
364 };
365
366 let mut parser = harn_parser::Parser::new(tokens);
367 match parser.parse() {
368 Ok(program) => Some(program),
369 Err(error) => {
370 if parser.all_errors().is_empty() {
371 render_parse_error(path, source, &error, stderr);
372 } else {
373 for error in parser.all_errors() {
374 render_parse_error(path, source, error, stderr);
375 }
376 }
377 None
378 }
379 }
380}
381
382fn render_parse_error(
383 path: &str,
384 source: &str,
385 error: &harn_parser::ParserError,
386 stderr: &mut String,
387) {
388 let span = error_span_from_parse(error);
389 let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
390 source,
391 path,
392 &span,
393 "error",
394 harn_parser::diagnostic::parser_error_code(error),
395 &harn_parser::diagnostic::parser_error_message(error),
396 Some(harn_parser::diagnostic::parser_error_label(error)),
397 harn_parser::diagnostic::parser_error_help(error),
398 );
399 stderr.push_str(&diagnostic);
400}
401
402fn error_span_from_lex(error: &harn_lexer::LexerError) -> harn_lexer::Span {
403 match error {
404 harn_lexer::LexerError::UnexpectedCharacter(_, span)
405 | harn_lexer::LexerError::UnterminatedString(span)
406 | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
407 }
408}
409
410fn error_span_from_parse(error: &harn_parser::ParserError) -> harn_lexer::Span {
411 match error {
412 harn_parser::ParserError::Unexpected { span, .. } => *span,
413 harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
414 }
415}
416
417fn typecheck_with_imports(
422 program: &[harn_parser::SNode],
423 path: &Path,
424 source: &str,
425) -> Vec<harn_parser::TypeDiagnostic> {
426 if let Err(error) = package::ensure_dependencies_materialized(path) {
427 eprintln!("error: {error}");
428 process::exit(1);
429 }
430 let graph = harn_modules::build(&[path.to_path_buf()]);
431 let mut checker = harn_parser::TypeChecker::new();
432 if let Some(imported) = graph.imported_names_for_file(path) {
433 checker = checker.with_imported_names(imported);
434 }
435 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
436 checker = checker.with_imported_type_decls(imported);
437 }
438 if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
439 checker = checker.with_imported_callable_decls(imported);
440 }
441 checker.check_with_source(program, source)
442}
443
444pub(crate) fn prepare_eval_temp_file(
455 code: &str,
456) -> Result<(String, tempfile::NamedTempFile), String> {
457 let (header, body) = split_eval_header(code);
458 let wrapped = if header.is_empty() {
459 format!("pipeline main(task) {{\n{body}\n}}")
460 } else {
461 format!("{header}\npipeline main(task) {{\n{body}\n}}")
462 };
463
464 let tmp = create_eval_temp_file()?;
465 Ok((wrapped, tmp))
466}
467
468fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
473 if let Some(dir) = std::env::current_dir().ok().as_deref() {
474 match tempfile::Builder::new()
477 .prefix(".harn-eval-")
478 .suffix(".harn")
479 .tempfile_in(dir)
480 {
481 Ok(tmp) => return Ok(tmp),
482 Err(error) => eprintln!(
483 "warning: harn run -e: could not create temp file in {}: {error}; \
484 relative imports will not resolve",
485 dir.display()
486 ),
487 }
488 }
489 tempfile::Builder::new()
490 .prefix("harn-eval-")
491 .suffix(".harn")
492 .tempfile()
493 .map_err(|e| format!("failed to create temp file for -e: {e}"))
494}
495
496fn split_eval_header(code: &str) -> (String, String) {
504 let mut header_end = 0usize;
505 let mut last_kept = 0usize;
506 for (idx, line) in code.lines().enumerate() {
507 let trimmed = line.trim_start();
508 if trimmed.is_empty() || trimmed.starts_with("//") {
509 header_end = idx + 1;
510 continue;
511 }
512 let is_import = trimmed.starts_with("import ")
513 || trimmed.starts_with("import\t")
514 || trimmed.starts_with("import\"")
515 || trimmed.starts_with("pub import ")
516 || trimmed.starts_with("pub import\t");
517 if is_import {
518 header_end = idx + 1;
519 last_kept = idx + 1;
520 } else {
521 break;
522 }
523 }
524 if last_kept == 0 {
525 return (String::new(), code.to_string());
526 }
527 let mut header_lines: Vec<&str> = Vec::new();
528 let mut body_lines: Vec<&str> = Vec::new();
529 for (idx, line) in code.lines().enumerate() {
530 if idx < header_end {
531 header_lines.push(line);
532 } else {
533 body_lines.push(line);
534 }
535 }
536 (header_lines.join("\n"), body_lines.join("\n"))
537}
538
539#[derive(Clone, Debug, Default, PartialEq, Eq)]
540pub enum CliLlmMockMode {
541 #[default]
542 Off,
543 Replay {
544 fixture_path: PathBuf,
545 },
546 Record {
547 fixture_path: PathBuf,
548 },
549}
550
551#[derive(Clone, Debug, Default, PartialEq, Eq)]
552pub struct RunAttestationOptions {
553 pub receipt_out: Option<PathBuf>,
554 pub agent_id: Option<String>,
555}
556
557#[derive(Clone, Debug, Default, PartialEq, Eq)]
562pub struct RunProfileOptions {
563 pub text: bool,
564 pub json_path: Option<PathBuf>,
565}
566
567impl RunProfileOptions {
568 pub fn is_enabled(&self) -> bool {
569 self.text || self.json_path.is_some()
570 }
571}
572
573#[derive(Clone, Debug, PartialEq, Eq)]
574pub struct RunSandboxOptions {
575 pub enabled: bool,
577 pub workspace_root: Option<PathBuf>,
581}
582
583impl Default for RunSandboxOptions {
584 fn default() -> Self {
585 Self {
586 enabled: true,
587 workspace_root: None,
588 }
589 }
590}
591
592impl RunSandboxOptions {
593 pub fn disabled() -> Self {
595 Self {
596 enabled: false,
597 workspace_root: None,
598 }
599 }
600
601 pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
603 self.workspace_root = Some(workspace_root.into());
604 self
605 }
606}
607
608#[derive(Clone)]
609pub struct RunInterruptTokens {
610 pub cancel_token: Arc<AtomicBool>,
611 pub signal_token: Arc<Mutex<Option<String>>>,
612}
613
614struct ExecuteRunInputs<'a> {
615 path: &'a str,
616 trace: bool,
617 denied_builtins: HashSet<String>,
618 script_argv: Vec<String>,
619 skill_dirs_raw: Vec<String>,
620 llm_mock_mode: CliLlmMockMode,
621 attestation: Option<RunAttestationOptions>,
622 profile: RunProfileOptions,
623 sandbox: RunSandboxOptions,
624 interrupt_tokens: Option<RunInterruptTokens>,
625 json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
626 aux: RunAuxOptions,
627 timing: Option<&'a mut RunTiming>,
628 harnpack: HarnpackRunOptions,
629}
630
631#[derive(Clone, Debug, Default)]
635pub struct RunOutcome {
636 pub stdout: String,
637 pub stderr: String,
638 pub exit_code: i32,
639}
640
641pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
642 harn_vm::llm::clear_cli_llm_mock_mode();
643 match mode {
644 CliLlmMockMode::Off => Ok(()),
645 CliLlmMockMode::Replay { fixture_path } => {
646 let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
647 harn_vm::llm::install_cli_llm_mocks(mocks);
648 Ok(())
649 }
650 CliLlmMockMode::Record { .. } => {
651 harn_vm::llm::enable_cli_llm_mock_recording();
652 Ok(())
653 }
654 }
655}
656
657pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
658 let CliLlmMockMode::Record { fixture_path } = mode else {
659 return Ok(());
660 };
661 if let Some(parent) = fixture_path.parent() {
662 if !parent.as_os_str().is_empty() {
663 fs::create_dir_all(parent).map_err(|error| {
664 format!(
665 "failed to create fixture directory {}: {error}",
666 parent.display()
667 )
668 })?;
669 }
670 }
671
672 let lines = harn_vm::llm::take_cli_llm_recordings()
673 .into_iter()
674 .map(harn_vm::llm::serialize_llm_mock)
675 .collect::<Result<Vec<_>, _>>()?;
676 let body = if lines.is_empty() {
677 String::new()
678 } else {
679 format!("{}\n", lines.join("\n"))
680 };
681 fs::write(fixture_path, body)
682 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
683}
684
685pub(crate) async fn run_file(
686 path: &str,
687 trace: bool,
688 denied_builtins: HashSet<String>,
689 script_argv: Vec<String>,
690 llm_mock_mode: CliLlmMockMode,
691 attestation: Option<RunAttestationOptions>,
692 profile: RunProfileOptions,
693) {
694 run_file_with_skill_dirs(
695 path,
696 trace,
697 denied_builtins,
698 script_argv,
699 Vec::new(),
700 llm_mock_mode,
701 attestation,
702 profile,
703 RunSandboxOptions::default(),
704 None,
705 RunAuxOptions::default(),
706 HarnpackRunOptions::default(),
707 )
708 .await;
709}
710
711pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
712 let outcome = execute_explain_cost(path);
713 if !outcome.stderr.is_empty() {
714 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
715 }
716 if !outcome.stdout.is_empty() {
717 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
718 }
719 if outcome.exit_code != 0 {
720 process::exit(outcome.exit_code);
721 }
722}
723
724#[allow(clippy::too_many_arguments)]
725pub(crate) async fn run_file_with_skill_dirs(
726 path: &str,
727 trace: bool,
728 denied_builtins: HashSet<String>,
729 script_argv: Vec<String>,
730 skill_dirs_raw: Vec<String>,
731 llm_mock_mode: CliLlmMockMode,
732 attestation: Option<RunAttestationOptions>,
733 profile: RunProfileOptions,
734 sandbox: RunSandboxOptions,
735 json: Option<RunJsonOptions>,
736 aux: RunAuxOptions,
737 harnpack: HarnpackRunOptions,
738) {
739 let interrupt_tokens = install_signal_shutdown_handler();
741
742 let _stdout_passthrough = StdoutPassthroughGuard::enable();
743 let json_with_stdout =
744 json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
745 let outcome = execute_run_inner(ExecuteRunInputs {
746 path,
747 trace,
748 denied_builtins,
749 script_argv,
750 skill_dirs_raw,
751 llm_mock_mode,
752 attestation,
753 profile,
754 sandbox,
755 interrupt_tokens: Some(interrupt_tokens.clone()),
756 json: json_with_stdout,
757 aux,
758 timing: None,
759 harnpack,
760 })
761 .await;
762
763 if !outcome.stderr.is_empty() {
766 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
767 }
768 if !outcome.stdout.is_empty() {
769 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
770 }
771
772 let mut exit_code = outcome.exit_code;
773 if exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst) {
774 exit_code = 124;
775 }
776 if exit_code != 0 {
777 process::exit(exit_code);
778 }
779}
780
781#[allow(clippy::too_many_arguments)]
782pub(crate) async fn run_resume_with_skill_dirs(
783 target: &str,
784 trace: bool,
785 denied_builtins: HashSet<String>,
786 resume_argv: Vec<String>,
787 skill_dirs_raw: Vec<String>,
788 llm_mock_mode: CliLlmMockMode,
789 attestation: Option<RunAttestationOptions>,
790 profile: RunProfileOptions,
791 sandbox: RunSandboxOptions,
792 json: Option<RunJsonOptions>,
793 aux: RunAuxOptions,
794) {
795 let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
796
797pipeline main(task) {
798 let input = if len(argv) > 1 {
799 argv[1]
800 } else {
801 nil
802 }
803 let handle = resume_agent(argv[0], input, true)
804 return wait_agent(handle)
805}
806"#;
807 let tmp = create_eval_temp_file().unwrap_or_else(|e| {
808 eprintln!("error: {e}");
809 process::exit(1);
810 });
811 let tmp_path = tmp.path().to_path_buf();
812 if let Err(error) = fs::write(&tmp_path, source) {
813 eprintln!("error: failed to write temp file for --resume: {error}");
814 process::exit(1);
815 }
816 let mut argv = Vec::with_capacity(resume_argv.len() + 1);
817 argv.push(target.to_string());
818 argv.extend(resume_argv);
819 let tmp_str = tmp_path.to_string_lossy().into_owned();
820 run_file_with_skill_dirs(
821 &tmp_str,
822 trace,
823 denied_builtins,
824 argv,
825 skill_dirs_raw,
826 llm_mock_mode,
827 attestation,
828 profile,
829 sandbox,
830 json,
831 aux,
832 HarnpackRunOptions::default(),
833 )
834 .await;
835}
836
837pub fn execute_explain_cost(path: &str) -> RunOutcome {
838 let stdout = String::new();
839 let mut stderr = String::new();
840
841 let (source, program) = parse_source_file(path);
842
843 let mut had_type_error = false;
844 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
845 for diag in &type_diagnostics {
846 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
847 if matches!(diag.severity, DiagnosticSeverity::Error) {
848 had_type_error = true;
849 }
850 stderr.push_str(&rendered);
851 }
852 if had_type_error {
853 return RunOutcome {
854 stdout,
855 stderr,
856 exit_code: 1,
857 };
858 }
859
860 let extensions = package::load_runtime_extensions(Path::new(path));
861 package::install_runtime_extensions(&extensions);
862 RunOutcome {
863 stdout: explain_cost::render_explain_cost(path, &program),
864 stderr,
865 exit_code: 0,
866 }
867}
868
869pub(crate) struct StdoutPassthroughGuard {
870 previous: bool,
871}
872
873impl StdoutPassthroughGuard {
874 pub(crate) fn enable() -> Self {
875 Self {
876 previous: harn_vm::set_stdout_passthrough(true),
877 }
878 }
879}
880
881impl Drop for StdoutPassthroughGuard {
882 fn drop(&mut self) {
883 harn_vm::set_stdout_passthrough(self.previous);
884 }
885}
886
887struct ExecutionPolicyGuard;
888
889impl Drop for ExecutionPolicyGuard {
890 fn drop(&mut self) {
891 harn_vm::orchestration::pop_execution_policy();
892 }
893}
894
895struct RunSandboxScope {
896 _execution_policy: Option<ExecutionPolicyGuard>,
897 _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
898}
899
900impl RunSandboxScope {
901 fn disabled() -> Self {
902 Self {
903 _execution_policy: None,
904 _egress_policy: None,
905 }
906 }
907}
908
909fn install_run_sandbox_scope(
910 options: &RunSandboxOptions,
911 workspace_root: &Path,
912 stderr: &mut String,
913) -> RunSandboxScope {
914 if !options.enabled {
915 stderr.push_str(
916 "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
917 );
918 return RunSandboxScope::disabled();
919 }
920
921 let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
922 harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
923 workspace_root,
924 ));
925 Some(ExecutionPolicyGuard)
926 } else {
927 None
928 };
929 let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
930
931 RunSandboxScope {
932 _execution_policy: execution_policy,
933 _egress_policy: egress_policy,
934 }
935}
936
937fn default_run_capability_policy(
938 workspace_root: &Path,
939) -> harn_vm::orchestration::CapabilityPolicy {
940 harn_vm::orchestration::CapabilityPolicy {
941 workspace_roots: vec![normalize_run_workspace_root(workspace_root)
942 .display()
943 .to_string()],
944 side_effect_level: Some("process_exec".to_string()),
945 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
946 ..harn_vm::orchestration::CapabilityPolicy::default()
947 }
948}
949
950fn normalize_run_workspace_root(path: &Path) -> PathBuf {
951 if path.is_absolute() {
952 return path.to_path_buf();
953 }
954 std::env::current_dir()
955 .map(|cwd| cwd.join(path))
956 .unwrap_or_else(|_| path.to_path_buf())
957}
958
959fn default_run_workspace_root(project_root: Option<&Path>, source_parent: &Path) -> PathBuf {
960 project_root
961 .map(Path::to_path_buf)
962 .or_else(|| std::env::current_dir().ok())
963 .unwrap_or_else(|| source_parent.to_path_buf())
964}
965
966fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
967 let active_policy = harn_vm::orchestration::current_execution_policy();
968 let active = active_policy.is_some();
969 let workspace_roots = active_policy
970 .as_ref()
971 .map(|policy| policy.workspace_roots.clone())
972 .unwrap_or_default();
973 let profile = active_policy
974 .as_ref()
975 .map(|policy| policy.sandbox_profile.as_str())
976 .unwrap_or("unrestricted");
977 let egress = if sandbox.enabled {
978 "explicit_policy_required"
979 } else if active {
980 "host_policy"
981 } else {
982 "unrestricted"
983 };
984
985 serde_json::json!({
986 "run_default_enabled": sandbox.enabled,
987 "active": active,
988 "workspace_roots": workspace_roots,
989 "profile": profile,
990 "egress": egress,
991 })
992}
993
994const FIRST_SIGNAL_MESSAGE: &str =
1003 "[harn] signal received, interrupting VM (give it a moment to unwind in-flight async ops; Ctrl-C again to force-exit)...";
1004
1005fn install_signal_shutdown_handler() -> RunInterruptTokens {
1006 let tokens = RunInterruptTokens {
1007 cancel_token: Arc::new(AtomicBool::new(false)),
1008 signal_token: Arc::new(Mutex::new(None)),
1009 };
1010 let tokens_clone = tokens.clone();
1011 tokio::spawn(async move {
1012 #[cfg(unix)]
1013 {
1014 use tokio::signal::unix::{signal, SignalKind};
1015 let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
1016 let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
1017 let mut sighup = signal(SignalKind::hangup()).expect("SIGHUP handler");
1018 let mut seen_signal = false;
1019 loop {
1020 let signal_name = tokio::select! {
1021 _ = sigterm.recv() => "SIGTERM",
1022 _ = sigint.recv() => "SIGINT",
1023 _ = sighup.recv() => "SIGHUP",
1024 };
1025 if seen_signal {
1026 eprintln!("[harn] second signal received, terminating");
1027 process::exit(124);
1028 }
1029 seen_signal = true;
1030 request_vm_interrupt(&tokens_clone, signal_name);
1031 eprintln!("{FIRST_SIGNAL_MESSAGE}");
1032 }
1033 }
1034 #[cfg(not(unix))]
1035 {
1036 let mut seen_signal = false;
1037 loop {
1038 let _ = tokio::signal::ctrl_c().await;
1039 if seen_signal {
1040 eprintln!("[harn] second signal received, terminating");
1041 process::exit(124);
1042 }
1043 seen_signal = true;
1044 request_vm_interrupt(&tokens_clone, "SIGINT");
1045 eprintln!("{FIRST_SIGNAL_MESSAGE}");
1046 }
1047 }
1048 });
1049 tokens
1050}
1051
1052fn request_vm_interrupt(tokens: &RunInterruptTokens, signal_name: &str) {
1053 if let Ok(mut signal) = tokens.signal_token.lock() {
1054 *signal = Some(signal_name.to_string());
1055 }
1056 tokens.cancel_token.store(true, Ordering::SeqCst);
1057}
1058
1059pub async fn execute_run(
1065 path: &str,
1066 trace: bool,
1067 denied_builtins: HashSet<String>,
1068 script_argv: Vec<String>,
1069 skill_dirs_raw: Vec<String>,
1070 llm_mock_mode: CliLlmMockMode,
1071 attestation: Option<RunAttestationOptions>,
1072 profile: RunProfileOptions,
1073) -> RunOutcome {
1074 execute_run_with_harnpack_and_sandbox_options(
1075 path,
1076 trace,
1077 denied_builtins,
1078 script_argv,
1079 skill_dirs_raw,
1080 llm_mock_mode,
1081 attestation,
1082 profile,
1083 RunSandboxOptions::default(),
1084 HarnpackRunOptions::default(),
1085 )
1086 .await
1087}
1088
1089#[allow(clippy::too_many_arguments)]
1093pub async fn execute_run_with_sandbox_options(
1094 path: &str,
1095 trace: bool,
1096 denied_builtins: HashSet<String>,
1097 script_argv: Vec<String>,
1098 skill_dirs_raw: Vec<String>,
1099 llm_mock_mode: CliLlmMockMode,
1100 attestation: Option<RunAttestationOptions>,
1101 profile: RunProfileOptions,
1102 sandbox: RunSandboxOptions,
1103) -> RunOutcome {
1104 execute_run_with_harnpack_and_sandbox_options(
1105 path,
1106 trace,
1107 denied_builtins,
1108 script_argv,
1109 skill_dirs_raw,
1110 llm_mock_mode,
1111 attestation,
1112 profile,
1113 sandbox,
1114 HarnpackRunOptions::default(),
1115 )
1116 .await
1117}
1118
1119#[allow(clippy::too_many_arguments)]
1124pub async fn execute_run_with_harnpack_options(
1125 path: &str,
1126 trace: bool,
1127 denied_builtins: HashSet<String>,
1128 script_argv: Vec<String>,
1129 skill_dirs_raw: Vec<String>,
1130 llm_mock_mode: CliLlmMockMode,
1131 attestation: Option<RunAttestationOptions>,
1132 profile: RunProfileOptions,
1133 harnpack: HarnpackRunOptions,
1134) -> RunOutcome {
1135 execute_run_with_harnpack_and_sandbox_options(
1136 path,
1137 trace,
1138 denied_builtins,
1139 script_argv,
1140 skill_dirs_raw,
1141 llm_mock_mode,
1142 attestation,
1143 profile,
1144 RunSandboxOptions::default(),
1145 harnpack,
1146 )
1147 .await
1148}
1149
1150#[allow(clippy::too_many_arguments)]
1151async fn execute_run_with_harnpack_and_sandbox_options(
1152 path: &str,
1153 trace: bool,
1154 denied_builtins: HashSet<String>,
1155 script_argv: Vec<String>,
1156 skill_dirs_raw: Vec<String>,
1157 llm_mock_mode: CliLlmMockMode,
1158 attestation: Option<RunAttestationOptions>,
1159 profile: RunProfileOptions,
1160 sandbox: RunSandboxOptions,
1161 harnpack: HarnpackRunOptions,
1162) -> RunOutcome {
1163 execute_run_inner(ExecuteRunInputs {
1164 path,
1165 trace,
1166 denied_builtins,
1167 script_argv,
1168 skill_dirs_raw,
1169 llm_mock_mode,
1170 attestation,
1171 profile,
1172 sandbox,
1173 interrupt_tokens: None,
1174 json: None,
1175 aux: RunAuxOptions::default(),
1176 timing: None,
1177 harnpack,
1178 })
1179 .await
1180}
1181
1182#[allow(clippy::too_many_arguments)]
1188pub async fn execute_run_json(
1189 path: &str,
1190 trace: bool,
1191 denied_builtins: HashSet<String>,
1192 script_argv: Vec<String>,
1193 skill_dirs_raw: Vec<String>,
1194 llm_mock_mode: CliLlmMockMode,
1195 attestation: Option<RunAttestationOptions>,
1196 profile: RunProfileOptions,
1197 out: Box<dyn io::Write + Send>,
1198 options: RunJsonOptions,
1199) -> RunOutcome {
1200 execute_run_inner(ExecuteRunInputs {
1201 path,
1202 trace,
1203 denied_builtins,
1204 script_argv,
1205 skill_dirs_raw,
1206 llm_mock_mode,
1207 attestation,
1208 profile,
1209 sandbox: RunSandboxOptions::default(),
1210 interrupt_tokens: None,
1211 json: Some((options, out)),
1212 aux: RunAuxOptions::default(),
1213 timing: None,
1214 harnpack: HarnpackRunOptions::default(),
1215 })
1216 .await
1217}
1218
1219pub(crate) async fn execute_run_with_timing(
1223 path: &str,
1224 script_argv: Vec<String>,
1225 timing: Option<&mut RunTiming>,
1226 sandbox: RunSandboxOptions,
1227) -> RunOutcome {
1228 execute_run_inner(ExecuteRunInputs {
1229 path,
1230 trace: false,
1231 denied_builtins: HashSet::new(),
1232 script_argv,
1233 skill_dirs_raw: Vec::new(),
1234 llm_mock_mode: CliLlmMockMode::Off,
1235 attestation: None,
1236 profile: RunProfileOptions::default(),
1237 sandbox,
1238 interrupt_tokens: None,
1239 json: None,
1240 aux: RunAuxOptions::default(),
1241 timing,
1242 harnpack: HarnpackRunOptions::default(),
1243 })
1244 .await
1245}
1246
1247#[allow(clippy::needless_option_as_deref)]
1250async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
1251 let ExecuteRunInputs {
1252 path,
1253 trace,
1254 denied_builtins,
1255 script_argv,
1256 skill_dirs_raw,
1257 llm_mock_mode,
1258 attestation,
1259 profile,
1260 sandbox,
1261 interrupt_tokens,
1262 json,
1263 aux,
1264 timing,
1265 harnpack,
1266 } = inputs;
1267 let RunAuxOptions {
1268 summary,
1269 phase,
1270 rusage,
1271 } = aux;
1272 let run_started = Instant::now();
1273 let cpu_started_ms = rusage.as_ref().map(|_| time::cpu_ms());
1274 let mut owned_timing = if timing.is_none() && (phase.is_some() || rusage.is_some()) {
1275 Some(RunTiming::default())
1276 } else {
1277 None
1278 };
1279 let mut timing = timing.or(owned_timing.as_mut());
1280
1281 let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
1287
1288 let mut stderr = String::new();
1289 let mut stdout = String::new();
1290
1291 let owned_run_path: String;
1296 let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
1297 let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
1298 Ok(prepared) => prepared,
1299 Err(err) => {
1300 return finalize_harnpack_error(
1301 stderr,
1302 json_session,
1303 summary.as_ref(),
1304 phase.as_ref(),
1305 rusage.as_ref(),
1306 run_started,
1307 err,
1308 );
1309 }
1310 };
1311 harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
1312 bundle_hash: outcome.bundle_hash.clone(),
1313 signature_verified: outcome.signature_verified,
1314 key_id: outcome.key_id.clone(),
1315 cache_hit: outcome.cache_hit,
1316 dry_run_verify: harnpack.dry_run_verify,
1317 });
1318 if harnpack.dry_run_verify {
1319 return finalize_harnpack_dry_run(
1320 stderr,
1321 json_session,
1322 summary.as_ref(),
1323 phase.as_ref(),
1324 rusage.as_ref(),
1325 run_started,
1326 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1327 &outcome,
1328 );
1329 }
1330 owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
1331 owned_run_path.as_str()
1332 } else {
1333 path
1334 };
1335
1336 let Some(LoadedChunk { source, chunk }) =
1337 compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
1338 else {
1339 let message = stderr.clone();
1340 return finalize_run_error(
1341 stdout,
1342 stderr,
1343 json_session,
1344 summary.as_ref(),
1345 phase.as_ref(),
1346 rusage.as_ref(),
1347 run_started,
1348 None,
1349 timing.as_deref(),
1350 0,
1351 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1352 "compile_error",
1353 message,
1354 );
1355 };
1356 let path = resolved_path;
1357
1358 let setup_start = Instant::now();
1362
1363 if trace || summary.is_some() {
1364 harn_vm::llm::enable_tracing();
1365 }
1366 if profile.is_enabled() || phase.is_some() {
1367 harn_vm::tracing::set_tracing_enabled(true);
1368 }
1369 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
1370 stderr.push_str(&format!("error: {error}\n"));
1371 return finalize_run_error(
1372 stdout,
1373 stderr,
1374 json_session,
1375 summary.as_ref(),
1376 phase.as_ref(),
1377 rusage.as_ref(),
1378 run_started,
1379 None,
1380 timing.as_deref(),
1381 0,
1382 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1383 "llm_mock_install",
1384 error,
1385 );
1386 }
1387
1388 let mut vm = harn_vm::Vm::new();
1389 if let Some(interrupt_tokens) = interrupt_tokens {
1390 vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
1391 vm.install_cancel_token(interrupt_tokens.cancel_token);
1392 }
1393 harn_vm::register_vm_stdlib_with_deferred_llm(&mut vm);
1394 crate::install_default_hostlib(&mut vm);
1395 let source_parent = std::path::Path::new(path)
1396 .parent()
1397 .unwrap_or(std::path::Path::new("."));
1398 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1400 let store_base = project_root.as_deref().unwrap_or(source_parent);
1401 let sandbox_root = sandbox
1402 .workspace_root
1403 .clone()
1404 .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
1405 let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
1406 let attestation_started_at_ms = now_ms();
1407 let attestation_log = if attestation.is_some() {
1408 Some(harn_vm::event_log::install_memory_for_current_thread(256))
1409 } else {
1410 None
1411 };
1412 if let Some(log) = attestation_log.as_ref() {
1413 append_run_provenance_event(
1414 log,
1415 "started",
1416 serde_json::json!({
1417 "pipeline": path,
1418 "argv": &script_argv,
1419 "project_root": store_base.display().to_string(),
1420 "sandbox": run_sandbox_attestation(&sandbox),
1421 }),
1422 )
1423 .await;
1424 }
1425 harn_vm::register_store_builtins(&mut vm, store_base);
1426 harn_vm::register_metadata_builtins(&mut vm, store_base);
1427 let pipeline_name = std::path::Path::new(path)
1428 .file_stem()
1429 .and_then(|s| s.to_str())
1430 .unwrap_or("default");
1431 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1432 vm.set_source_info(path, &source);
1433 if !denied_builtins.is_empty() {
1434 vm.set_denied_builtins(denied_builtins);
1435 }
1436 if let Some(ref root) = project_root {
1437 vm.set_project_root(root);
1438 }
1439
1440 if let Some(p) = std::path::Path::new(path).parent() {
1441 if !p.as_os_str().is_empty() {
1442 vm.set_source_dir(p);
1443 }
1444 }
1445
1446 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
1449 let loaded = load_skills(&SkillLoaderInputs {
1450 cli_dirs,
1451 source_path: Some(std::path::PathBuf::from(path)),
1452 });
1453 emit_loader_warnings(&loaded.loader_warnings);
1454 install_skills_global(&mut vm, &loaded);
1455
1456 let argv_values: Vec<harn_vm::VmValue> = script_argv
1459 .iter()
1460 .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
1461 .collect();
1462 vm.set_global(
1463 "argv",
1464 harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
1465 );
1466
1467 vm.set_harness(harn_vm::Harness::real());
1471
1472 let extensions = package::load_runtime_extensions(Path::new(path));
1473 package::install_runtime_extensions(&extensions);
1474 if let Some(manifest) = extensions.root_manifest.as_ref() {
1475 if !manifest.mcp.is_empty() {
1476 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1477 }
1478 }
1479 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1480 stderr.push_str(&format!(
1481 "error: failed to install manifest triggers: {error}\n"
1482 ));
1483 return finalize_run_error(
1484 stdout,
1485 stderr,
1486 json_session,
1487 summary.as_ref(),
1488 phase.as_ref(),
1489 rusage.as_ref(),
1490 run_started,
1491 None,
1492 timing.as_deref(),
1493 0,
1494 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1495 "manifest_triggers",
1496 error.to_string(),
1497 );
1498 }
1499 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1500 stderr.push_str(&format!(
1501 "error: failed to install manifest hooks: {error}\n"
1502 ));
1503 return finalize_run_error(
1504 stdout,
1505 stderr,
1506 json_session,
1507 summary.as_ref(),
1508 phase.as_ref(),
1509 rusage.as_ref(),
1510 run_started,
1511 None,
1512 timing.as_deref(),
1513 0,
1514 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1515 "manifest_hooks",
1516 error.to_string(),
1517 );
1518 }
1519
1520 let local = tokio::task::LocalSet::new();
1522 if let Some(t) = timing.as_deref_mut() {
1523 t.run_setup = setup_start.elapsed();
1524 }
1525 let main_start = Instant::now();
1526 let execution = local
1527 .run_until(async {
1528 match vm.execute(&chunk).await {
1529 Ok(value) => Ok((vm.output(), value)),
1530 Err(e) => Err(vm.format_runtime_error(&e)),
1531 }
1532 })
1533 .await;
1534 if let Some(t) = timing.as_deref_mut() {
1535 t.run_main = main_start.elapsed();
1536 }
1537 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1538 stderr.push_str(&format!("error: {error}\n"));
1539 let profile_rollup = if profile.is_enabled() {
1540 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1541 } else {
1542 None
1543 };
1544 return finalize_run_error(
1545 stdout,
1546 stderr,
1547 json_session,
1548 summary.as_ref(),
1549 phase.as_ref(),
1550 rusage.as_ref(),
1551 run_started,
1552 profile_rollup.as_ref(),
1553 timing.as_deref(),
1554 harn_vm::tracing::peek_spans().len() as u64,
1555 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1556 "llm_mock_record",
1557 error,
1558 );
1559 }
1560
1561 let buffered_stderr = harn_vm::take_stderr_buffer();
1563 stderr.push_str(&buffered_stderr);
1564
1565 let exit_code = match &execution {
1566 Ok((_, return_value)) => exit_code_from_return_value(return_value),
1567 Err(_) => 1,
1568 };
1569
1570 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1571 if let Err(error) = emit_run_attestation(
1572 log,
1573 path,
1574 store_base,
1575 attestation_started_at_ms,
1576 exit_code,
1577 options,
1578 &mut stderr,
1579 )
1580 .await
1581 {
1582 stderr.push_str(&format!(
1583 "error: failed to emit provenance receipt: {error}\n"
1584 ));
1585 let profile_rollup = if profile.is_enabled() {
1586 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1587 } else {
1588 None
1589 };
1590 return finalize_run_error(
1591 stdout,
1592 stderr,
1593 json_session,
1594 summary.as_ref(),
1595 phase.as_ref(),
1596 rusage.as_ref(),
1597 run_started,
1598 profile_rollup.as_ref(),
1599 timing.as_deref(),
1600 harn_vm::tracing::peek_spans().len() as u64,
1601 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1602 "attestation",
1603 error.to_string(),
1604 );
1605 }
1606 harn_vm::event_log::reset_active_event_log();
1607 }
1608
1609 match execution {
1610 Ok((output, return_value)) => {
1611 stdout.push_str(output);
1612 let main_events = harn_vm::tracing::peek_spans().len() as u64;
1613 let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1614 let profile_rollup = if profile.is_enabled() {
1615 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1616 } else {
1617 None
1618 };
1619 let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1620 if trace {
1621 stderr.push_str(&render_trace_summary());
1622 }
1623 if let Some(profile_rollup) = profile_rollup.as_ref() {
1624 if let Err(error) =
1625 render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1626 {
1627 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1628 }
1629 }
1630 if exit_code != 0 {
1631 stderr.push_str(&render_return_value_error(&return_value));
1632 }
1633 let aux_emission = emit_run_aux_for_exit(
1634 summary.as_ref(),
1635 phase.as_ref(),
1636 rusage.as_ref(),
1637 run_started,
1638 exit_code,
1639 profile_rollup.as_ref(),
1640 summary_llm,
1641 timing.as_deref(),
1642 main_events,
1643 cpu_ms_total,
1644 json_session.is_some(),
1645 &mut stderr,
1646 );
1647 if let Some(session) = json_session {
1648 if let Some(error) = aux_emission.error {
1649 let mut outcome = session.finalize_error(
1650 "run_aux",
1651 format!("failed to emit auxiliary run JSON: {error}"),
1652 1,
1653 );
1654 outcome.stderr = aux_emission.stderr;
1655 return outcome;
1656 }
1657 let value = harn_vm::llm::vm_value_to_json(&return_value);
1658 let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1659 outcome.stderr = aux_emission.stderr;
1660 return outcome;
1661 }
1662 RunOutcome {
1663 stdout,
1664 stderr,
1665 exit_code: aux_emission.exit_code,
1666 }
1667 }
1668 Err(rendered_error) => {
1669 stderr.push_str(&rendered_error);
1670 let main_events = harn_vm::tracing::peek_spans().len() as u64;
1671 let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1672 let profile_rollup = if profile.is_enabled() {
1673 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1674 } else {
1675 None
1676 };
1677 if let Some(profile_rollup) = profile_rollup.as_ref() {
1678 if let Err(error) =
1679 render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1680 {
1681 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1682 }
1683 }
1684 let aux_emission = emit_run_aux_for_exit(
1685 summary.as_ref(),
1686 phase.as_ref(),
1687 rusage.as_ref(),
1688 run_started,
1689 1,
1690 profile_rollup.as_ref(),
1691 None,
1692 timing.as_deref(),
1693 main_events,
1694 cpu_ms_total,
1695 json_session.is_some(),
1696 &mut stderr,
1697 );
1698 if let Some(session) = json_session {
1699 let mut outcome =
1700 session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1701 outcome.stderr = aux_emission.stderr;
1702 return outcome;
1703 }
1704 RunOutcome {
1705 stdout,
1706 stderr,
1707 exit_code: aux_emission.exit_code,
1708 }
1709 }
1710 }
1711}
1712
1713fn render_and_persist_profile_rollup(
1714 options: &RunProfileOptions,
1715 profile: &harn_vm::profile::RunProfile,
1716 stderr: &mut String,
1717) -> Result<(), String> {
1718 if options.text {
1719 stderr.push_str(&harn_vm::profile::render(profile));
1720 }
1721 if let Some(path) = options.json_path.as_ref() {
1722 if let Some(parent) = path.parent() {
1723 if !parent.as_os_str().is_empty() {
1724 fs::create_dir_all(parent)
1725 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1726 }
1727 }
1728 let json = serde_json::to_string_pretty(profile)
1729 .map_err(|error| format!("serialize profile: {error}"))?;
1730 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1731 }
1732 Ok(())
1733}
1734
1735fn build_run_summary<'a>(
1736 started: Instant,
1737 exit_code: i32,
1738 profile: Option<&'a harn_vm::profile::RunProfile>,
1739 llm: RunSummaryLlm,
1740) -> RunSummary<'a> {
1741 RunSummary {
1742 schema_version: RUN_SUMMARY_SCHEMA_VERSION,
1743 event: "run_summary",
1744 wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1745 exit_code,
1746 llm,
1747 profile,
1748 }
1749}
1750
1751fn run_summary_llm_snapshot() -> RunSummaryLlm {
1752 let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
1753 let cost_usd = harn_vm::llm::peek_total_cost();
1754 RunSummaryLlm {
1755 call_count,
1756 input_tokens,
1757 output_tokens,
1758 time_ms,
1759 cost_usd: if cost_usd.is_finite() { cost_usd } else { 0.0 },
1760 }
1761}
1762
1763struct RunAuxEmission {
1764 stderr: String,
1765 exit_code: i32,
1766 error: Option<String>,
1767}
1768
1769#[allow(clippy::too_many_arguments)]
1770fn emit_run_aux_for_exit(
1771 summary: Option<&RunSummaryOptions>,
1772 phase: Option<&RunPhaseOptions>,
1773 rusage: Option<&RunRusageOptions>,
1774 started: Instant,
1775 exit_code: i32,
1776 profile: Option<&harn_vm::profile::RunProfile>,
1777 llm: Option<RunSummaryLlm>,
1778 timing: Option<&RunTiming>,
1779 main_events: u64,
1780 cpu_ms_total: Option<u64>,
1781 json_mode: bool,
1782 stderr: &mut String,
1783) -> RunAuxEmission {
1784 let mut aux_stderr = String::new();
1785 let mut final_exit_code = exit_code;
1786 let mut aux_error = None;
1787 let aux_target = if json_mode { &mut aux_stderr } else { stderr };
1788 let default_timing = RunTiming::default();
1789 let timing = timing.unwrap_or(&default_timing);
1790
1791 if let Some(options) = summary {
1792 let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
1793 let summary = build_run_summary(started, exit_code, profile, llm);
1794 if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
1795 record_aux_error(
1796 &mut final_exit_code,
1797 &mut aux_error,
1798 aux_target,
1799 "run summary",
1800 error,
1801 );
1802 }
1803 }
1804 if let Some(options) = phase {
1805 let phase_event = RunPhaseEvent {
1806 schema_version: RUN_PHASE_SCHEMA_VERSION,
1807 event: "run_phase",
1808 phases: time::build_phase_records(timing, main_events),
1809 };
1810 if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
1811 {
1812 record_aux_error(
1813 &mut final_exit_code,
1814 &mut aux_error,
1815 aux_target,
1816 "run phase",
1817 error,
1818 );
1819 }
1820 }
1821 if let Some(options) = rusage {
1822 let rusage_event = RunRusageEvent {
1823 schema_version: RUN_RUSAGE_SCHEMA_VERSION,
1824 event: "run_rusage",
1825 cpu_ms: cpu_ms_total.unwrap_or(0),
1826 };
1827 if let Err(error) =
1828 emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
1829 {
1830 record_aux_error(
1831 &mut final_exit_code,
1832 &mut aux_error,
1833 aux_target,
1834 "run rusage",
1835 error,
1836 );
1837 }
1838 }
1839
1840 RunAuxEmission {
1841 stderr: aux_stderr,
1842 exit_code: final_exit_code,
1843 error: aux_error,
1844 }
1845}
1846
1847fn record_aux_error(
1848 final_exit_code: &mut i32,
1849 aux_error: &mut Option<String>,
1850 stderr: &mut String,
1851 label: &str,
1852 error: String,
1853) {
1854 stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
1855 if *final_exit_code == 0 {
1856 *final_exit_code = 1;
1857 }
1858 if aux_error.is_none() {
1859 *aux_error = Some(error);
1860 }
1861}
1862
1863fn emit_raw_json_line(
1864 sink: &RunJsonSink,
1865 value: &impl Serialize,
1866 label: &str,
1867 stderr: &mut String,
1868) -> Result<(), String> {
1869 let line =
1870 serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
1871 match &sink.target {
1872 RunJsonSinkTarget::Stderr => {
1873 stderr.push_str(&line);
1874 Ok(())
1875 }
1876 RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
1877 RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
1878 }
1879}
1880
1881fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
1882 if let Some(parent) = path.parent() {
1883 if !parent.as_os_str().is_empty() {
1884 fs::create_dir_all(parent)
1885 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1886 }
1887 }
1888 fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
1889}
1890
1891#[cfg(unix)]
1892fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
1893 use std::fs::File;
1894 use std::os::unix::io::FromRawFd;
1895
1896 if fd < 0 {
1897 return Err(format!("invalid {flag} {fd}: must be non-negative"));
1898 }
1899 let duped = unsafe { libc::dup(fd) };
1900 if duped < 0 {
1901 return Err(format!(
1902 "duplicate {flag} {fd}: {}",
1903 io::Error::last_os_error()
1904 ));
1905 }
1906 let mut file = unsafe { File::from_raw_fd(duped) };
1907 file.write_all(line.as_bytes())
1908 .and_then(|_| file.flush())
1909 .map_err(|error| format!("write {flag} {fd}: {error}"))
1910}
1911
1912#[cfg(not(unix))]
1913fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
1914 Err(format!("{flag} is only supported on Unix platforms"))
1915}
1916
1917async fn append_run_provenance_event(
1918 log: &Arc<harn_vm::event_log::AnyEventLog>,
1919 kind: &str,
1920 payload: serde_json::Value,
1921) {
1922 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1923 return;
1924 };
1925 let _ = log
1926 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1927 .await;
1928}
1929
1930async fn emit_run_attestation(
1931 log: &Arc<harn_vm::event_log::AnyEventLog>,
1932 path: &str,
1933 store_base: &Path,
1934 started_at_ms: i64,
1935 exit_code: i32,
1936 options: &RunAttestationOptions,
1937 stderr: &mut String,
1938) -> Result<(), String> {
1939 let finished_at_ms = now_ms();
1940 let status = if exit_code == 0 { "success" } else { "failure" };
1941 append_run_provenance_event(
1942 log,
1943 "finished",
1944 serde_json::json!({
1945 "pipeline": path,
1946 "status": status,
1947 "exit_code": exit_code,
1948 }),
1949 )
1950 .await;
1951 log.flush()
1952 .await
1953 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1954 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1955 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1956 let (signing_key, key_id) =
1957 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1958 .await
1959 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1960 let receipt = harn_vm::build_signed_receipt(
1961 log,
1962 harn_vm::ReceiptBuildOptions {
1963 pipeline: path.to_string(),
1964 status: status.to_string(),
1965 started_at_ms,
1966 finished_at_ms,
1967 exit_code,
1968 producer_name: "harn-cli".to_string(),
1969 producer_version: env!("CARGO_PKG_VERSION").to_string(),
1970 },
1971 &signing_key,
1972 key_id,
1973 )
1974 .await
1975 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1976 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1977 if let Some(parent) = receipt_path.parent() {
1978 fs::create_dir_all(parent)
1979 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1980 }
1981 let encoded = serde_json::to_vec_pretty(&receipt)
1982 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1983 fs::write(&receipt_path, encoded)
1984 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1985 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1986 Ok(())
1987}
1988
1989fn receipt_output_path(
1990 store_base: &Path,
1991 options: &RunAttestationOptions,
1992 receipt_id: &str,
1993) -> PathBuf {
1994 if let Some(path) = options.receipt_out.as_ref() {
1995 return path.clone();
1996 }
1997 harn_vm::runtime_paths::state_root(store_base)
1998 .join("receipts")
1999 .join(format!("{receipt_id}.json"))
2000}
2001
2002fn now_ms() -> i64 {
2003 std::time::SystemTime::now()
2004 .duration_since(std::time::UNIX_EPOCH)
2005 .map(|duration| duration.as_millis() as i64)
2006 .unwrap_or(0)
2007}
2008
2009fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
2016 use harn_vm::VmValue;
2017 match value {
2018 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
2019 VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
2020 _ => 0,
2021 }
2022}
2023
2024struct JsonRunSession {
2038 emitter: self::json_events::NdjsonEmitter,
2039 prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
2040}
2041
2042impl JsonRunSession {
2043 fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
2044 let emitter = NdjsonEmitter::new(out, options.quiet);
2045 let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
2046 Self {
2047 emitter,
2048 prior_sink,
2049 }
2050 }
2051
2052 fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
2053 self.emitter.emit_result(value, exit_code);
2054 RunOutcome {
2055 stdout: String::new(),
2056 stderr: String::new(),
2057 exit_code,
2058 }
2059 }
2060
2061 fn finalize_error(
2062 self,
2063 code: impl Into<String>,
2064 message: impl Into<String>,
2065 exit_code: i32,
2066 ) -> RunOutcome {
2067 self.emitter.emit_error(code, message);
2068 RunOutcome {
2069 stdout: String::new(),
2070 stderr: String::new(),
2071 exit_code,
2072 }
2073 }
2074}
2075
2076impl Drop for JsonRunSession {
2077 fn drop(&mut self) {
2078 match self.prior_sink.take() {
2079 Some(prior) => {
2080 harn_vm::run_events::install_sink(prior);
2081 }
2082 None => harn_vm::run_events::clear_sink(),
2083 }
2084 }
2085}
2086
2087#[allow(clippy::too_many_arguments)]
2088fn finalize_run_error(
2089 stdout: String,
2090 mut stderr: String,
2091 json_session: Option<JsonRunSession>,
2092 summary: Option<&RunSummaryOptions>,
2093 phase: Option<&RunPhaseOptions>,
2094 rusage: Option<&RunRusageOptions>,
2095 started: Instant,
2096 profile: Option<&harn_vm::profile::RunProfile>,
2097 timing: Option<&RunTiming>,
2098 main_events: u64,
2099 cpu_ms_total: Option<u64>,
2100 code: impl Into<String>,
2101 message: impl Into<String>,
2102) -> RunOutcome {
2103 let aux_emission = emit_run_aux_for_exit(
2104 summary,
2105 phase,
2106 rusage,
2107 started,
2108 1,
2109 profile,
2110 None,
2111 timing,
2112 main_events,
2113 cpu_ms_total,
2114 json_session.is_some(),
2115 &mut stderr,
2116 );
2117 if let Some(session) = json_session {
2118 let mut outcome = session.finalize_error(code, message, aux_emission.exit_code);
2119 outcome.stderr = aux_emission.stderr;
2120 return outcome;
2121 }
2122 RunOutcome {
2123 stdout,
2124 stderr,
2125 exit_code: aux_emission.exit_code,
2126 }
2127}
2128
2129fn finalize_harnpack_error(
2134 mut stderr: String,
2135 json_session: Option<JsonRunSession>,
2136 summary: Option<&RunSummaryOptions>,
2137 phase: Option<&RunPhaseOptions>,
2138 rusage: Option<&RunRusageOptions>,
2139 started: Instant,
2140 err: HarnpackError,
2141) -> RunOutcome {
2142 let code = err.code;
2143 let message = err.message;
2144 stderr.push_str(&format!("error: {message}\n"));
2145 finalize_run_error(
2146 String::new(),
2147 stderr,
2148 json_session,
2149 summary,
2150 phase,
2151 rusage,
2152 started,
2153 None,
2154 None,
2155 0,
2156 None,
2157 code,
2158 message,
2159 )
2160}
2161
2162fn finalize_harnpack_dry_run(
2167 mut stderr: String,
2168 json_session: Option<JsonRunSession>,
2169 summary_options: Option<&RunSummaryOptions>,
2170 phase_options: Option<&RunPhaseOptions>,
2171 rusage_options: Option<&RunRusageOptions>,
2172 started: Instant,
2173 cpu_ms_total: Option<u64>,
2174 prepared: &PreparedHarnpack,
2175) -> RunOutcome {
2176 let summary = format!(
2177 "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
2178 prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
2179 );
2180 stderr.push_str(&summary);
2181 let aux_emission = emit_run_aux_for_exit(
2182 summary_options,
2183 phase_options,
2184 rusage_options,
2185 started,
2186 0,
2187 None,
2188 None,
2189 None,
2190 0,
2191 cpu_ms_total,
2192 json_session.is_some(),
2193 &mut stderr,
2194 );
2195 if let Some(session) = json_session {
2196 if let Some(error) = aux_emission.error {
2197 let mut outcome = session.finalize_error(
2198 "run_aux",
2199 format!("failed to emit auxiliary run JSON: {error}"),
2200 1,
2201 );
2202 outcome.stderr = aux_emission.stderr;
2203 return outcome;
2204 }
2205 let value = serde_json::json!({
2206 "bundle_hash": prepared.bundle_hash,
2207 "signature_verified": prepared.signature_verified,
2208 "key_id": prepared.key_id,
2209 "cache_hit": prepared.cache_hit,
2210 "dry_run_verify": true,
2211 });
2212 let mut outcome = session.finalize_result(value, aux_emission.exit_code);
2213 outcome.stderr = aux_emission.stderr;
2214 return outcome;
2215 }
2216 RunOutcome {
2217 stdout: String::new(),
2218 stderr,
2219 exit_code: aux_emission.exit_code,
2220 }
2221}
2222
2223fn render_return_value_error(value: &harn_vm::VmValue) -> String {
2224 let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
2225 return String::new();
2226 };
2227 if !enum_variant.is_variant("Result", "Err") {
2228 return String::new();
2229 }
2230 let rendered = enum_variant
2231 .fields
2232 .first()
2233 .map(|p| p.display())
2234 .unwrap_or_default();
2235 if rendered.is_empty() {
2236 "error\n".to_string()
2237 } else if rendered.ends_with('\n') {
2238 rendered
2239 } else {
2240 format!("{rendered}\n")
2241 }
2242}
2243
2244pub(crate) async fn connect_mcp_servers(
2253 servers: &[package::McpServerConfig],
2254 vm: &mut harn_vm::Vm,
2255) {
2256 use std::collections::BTreeMap;
2257 use std::rc::Rc;
2258 use std::time::Duration;
2259
2260 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
2261 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
2262
2263 for server in servers {
2264 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
2265 Ok(resolution) => resolution,
2266 Err(error) => {
2267 eprintln!(
2268 "warning: mcp: failed to load auth for '{}': {}",
2269 server.name, error
2270 );
2271 AuthResolution::None
2272 }
2273 };
2274 let spec = serde_json::json!({
2275 "name": server.name,
2276 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
2277 "command": server.command,
2278 "args": server.args,
2279 "env": server.env,
2280 "url": server.url,
2281 "auth_token": match resolved_auth {
2282 AuthResolution::Bearer(token) => Some(token),
2283 AuthResolution::None => server.auth_token.clone(),
2284 },
2285 "protocol_version": server.protocol_version,
2286 "protocol_mode": server.protocol_mode,
2287 "proxy_server_name": server.proxy_server_name,
2288 });
2289
2290 registrations.push(harn_vm::RegisteredMcpServer {
2293 name: server.name.clone(),
2294 spec: spec.clone(),
2295 lazy: server.lazy,
2296 card: server.card.clone(),
2297 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
2298 });
2299
2300 if server.lazy {
2301 eprintln!(
2302 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
2303 server.name
2304 );
2305 continue;
2306 }
2307
2308 match harn_vm::connect_mcp_server_from_json(&spec).await {
2309 Ok(handle) => {
2310 eprintln!("[harn] mcp: connected to '{}'", server.name);
2311 harn_vm::mcp_install_active(&server.name, handle.clone());
2312 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::mcp_client(handle));
2313 }
2314 Err(e) => {
2315 eprintln!(
2316 "warning: mcp: failed to connect to '{}': {}",
2317 server.name, e
2318 );
2319 }
2320 }
2321 }
2322
2323 harn_vm::mcp_register_servers(registrations);
2326
2327 if !mcp_dict.is_empty() {
2328 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
2329 }
2330}
2331
2332pub(crate) fn render_trace_summary() -> String {
2333 use std::fmt::Write;
2334 let entries = harn_vm::llm::take_trace();
2335 if entries.is_empty() {
2336 return String::new();
2337 }
2338 let mut out = String::new();
2339 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
2340 let mut total_input = 0i64;
2341 let mut total_output = 0i64;
2342 let mut total_ms = 0u64;
2343 for (i, entry) in entries.iter().enumerate() {
2344 let _ = writeln!(
2345 out,
2346 " #{}: {} | {} in + {} out tokens | {} ms",
2347 i + 1,
2348 entry.model,
2349 entry.input_tokens,
2350 entry.output_tokens,
2351 entry.duration_ms,
2352 );
2353 total_input += entry.input_tokens;
2354 total_output += entry.output_tokens;
2355 total_ms += entry.duration_ms;
2356 }
2357 let total_tokens = total_input + total_output;
2358 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
2360 let _ = writeln!(
2361 out,
2362 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
2363 entries.len(),
2364 if entries.len() == 1 { "" } else { "s" },
2365 total_tokens,
2366 total_input,
2367 total_output,
2368 total_ms,
2369 cost,
2370 );
2371 out
2372}
2373
2374pub(crate) async fn run_file_mcp_serve(
2388 path: &str,
2389 card_source: Option<&str>,
2390 mode: RunFileMcpServeMode,
2391) {
2392 let mut diagnostics = String::new();
2393 let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
2394 else {
2395 eprint!("{diagnostics}");
2396 process::exit(1);
2397 };
2398 if !diagnostics.is_empty() {
2399 eprint!("{diagnostics}");
2400 }
2401
2402 let mut vm = harn_vm::Vm::new();
2403 harn_vm::register_vm_stdlib(&mut vm);
2404 crate::install_default_hostlib(&mut vm);
2405 let source_parent = std::path::Path::new(path)
2406 .parent()
2407 .unwrap_or(std::path::Path::new("."));
2408 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2409 let store_base = project_root.as_deref().unwrap_or(source_parent);
2410 harn_vm::register_store_builtins(&mut vm, store_base);
2411 harn_vm::register_metadata_builtins(&mut vm, store_base);
2412 let pipeline_name = std::path::Path::new(path)
2413 .file_stem()
2414 .and_then(|s| s.to_str())
2415 .unwrap_or("default");
2416 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2417 vm.set_source_info(path, &source);
2418 if let Some(ref root) = project_root {
2419 vm.set_project_root(root);
2420 }
2421 if let Some(p) = std::path::Path::new(path).parent() {
2422 if !p.as_os_str().is_empty() {
2423 vm.set_source_dir(p);
2424 }
2425 }
2426
2427 let loaded = load_skills(&SkillLoaderInputs {
2429 cli_dirs: Vec::new(),
2430 source_path: Some(std::path::PathBuf::from(path)),
2431 });
2432 emit_loader_warnings(&loaded.loader_warnings);
2433 install_skills_global(&mut vm, &loaded);
2434
2435 let extensions = package::load_runtime_extensions(Path::new(path));
2436 package::install_runtime_extensions(&extensions);
2437 if let Some(manifest) = extensions.root_manifest.as_ref() {
2438 if !manifest.mcp.is_empty() {
2439 connect_mcp_servers(&manifest.mcp, &mut vm).await;
2440 }
2441 }
2442 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
2443 eprintln!("error: failed to install manifest triggers: {error}");
2444 process::exit(1);
2445 }
2446 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
2447 eprintln!("error: failed to install manifest hooks: {error}");
2448 process::exit(1);
2449 }
2450
2451 let local = tokio::task::LocalSet::new();
2452 local
2453 .run_until(async {
2454 match vm.execute(&chunk).await {
2455 Ok(_) => {}
2456 Err(e) => {
2457 eprint!("{}", vm.format_runtime_error(&e));
2458 process::exit(1);
2459 }
2460 }
2461
2462 let output = vm.output();
2464 if !output.is_empty() {
2465 eprint!("{output}");
2466 }
2467
2468 let registry = match harn_vm::take_mcp_serve_registry() {
2469 Some(r) => r,
2470 None => {
2471 eprintln!("error: pipeline did not call mcp_serve(registry)");
2472 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
2473 process::exit(1);
2474 }
2475 };
2476
2477 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
2478 Ok(t) => t,
2479 Err(e) => {
2480 eprintln!("error: {e}");
2481 process::exit(1);
2482 }
2483 };
2484
2485 let resources = harn_vm::take_mcp_serve_resources();
2486 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
2487 let prompts = harn_vm::take_mcp_serve_prompts();
2488
2489 let server_name = std::path::Path::new(path)
2490 .file_stem()
2491 .and_then(|s| s.to_str())
2492 .unwrap_or("harn")
2493 .to_string();
2494
2495 let mut caps = Vec::new();
2496 if !tools.is_empty() {
2497 caps.push(format!(
2498 "{} tool{}",
2499 tools.len(),
2500 if tools.len() == 1 { "" } else { "s" }
2501 ));
2502 }
2503 let total_resources = resources.len() + resource_templates.len();
2504 if total_resources > 0 {
2505 caps.push(format!(
2506 "{total_resources} resource{}",
2507 if total_resources == 1 { "" } else { "s" }
2508 ));
2509 }
2510 if !prompts.is_empty() {
2511 caps.push(format!(
2512 "{} prompt{}",
2513 prompts.len(),
2514 if prompts.len() == 1 { "" } else { "s" }
2515 ));
2516 }
2517 eprintln!(
2518 "[harn] serve mcp: serving {} as '{server_name}'",
2519 caps.join(", ")
2520 );
2521
2522 let mut server =
2523 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
2524 if let Some(source) = card_source {
2525 match resolve_card_source(source) {
2526 Ok(card) => server = server.with_server_card(card),
2527 Err(e) => {
2528 eprintln!("error: --card: {e}");
2529 process::exit(1);
2530 }
2531 }
2532 }
2533 match mode {
2534 RunFileMcpServeMode::Stdio => {
2535 if let Err(e) = server.run(&mut vm).await {
2536 eprintln!("error: MCP server error: {e}");
2537 process::exit(1);
2538 }
2539 }
2540 RunFileMcpServeMode::Http {
2541 options,
2542 auth_policy,
2543 } => {
2544 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
2545 server,
2546 vm,
2547 options,
2548 auth_policy,
2549 )
2550 .await
2551 {
2552 eprintln!("error: MCP server error: {e}");
2553 process::exit(1);
2554 }
2555 }
2556 }
2557 })
2558 .await;
2559}
2560
2561pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
2566 let trimmed = source.trim_start();
2567 if trimmed.starts_with('{') || trimmed.starts_with('[') {
2568 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
2569 }
2570 let path = std::path::Path::new(source);
2571 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
2572}
2573
2574pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
2575 use notify::{Event, EventKind, RecursiveMode, Watcher};
2576
2577 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
2578 eprintln!("Error: {e}");
2579 process::exit(1);
2580 });
2581 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
2582
2583 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
2584 run_file(
2585 path,
2586 false,
2587 denied_builtins.clone(),
2588 Vec::new(),
2589 CliLlmMockMode::Off,
2590 None,
2591 RunProfileOptions::default(),
2592 )
2593 .await;
2594
2595 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
2596 let _watcher = {
2597 let tx = tx.clone();
2598 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
2599 if let Ok(event) = res {
2600 if matches!(
2601 event.kind,
2602 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
2603 ) {
2604 let has_harn = event
2605 .paths
2606 .iter()
2607 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
2608 if has_harn {
2609 let _ = tx.blocking_send(());
2610 }
2611 }
2612 }
2613 })
2614 .unwrap_or_else(|e| {
2615 eprintln!("Error setting up file watcher: {e}");
2616 process::exit(1);
2617 });
2618 watcher
2619 .watch(watch_dir, RecursiveMode::Recursive)
2620 .unwrap_or_else(|e| {
2621 eprintln!("Error watching directory: {e}");
2622 process::exit(1);
2623 });
2624 watcher };
2626
2627 eprintln!(
2628 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
2629 watch_dir.display()
2630 );
2631
2632 loop {
2633 rx.recv().await;
2634 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2636 while rx.try_recv().is_ok() {}
2637
2638 eprintln!();
2639 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
2640 run_file(
2641 path,
2642 false,
2643 denied_builtins.clone(),
2644 Vec::new(),
2645 CliLlmMockMode::Off,
2646 None,
2647 RunProfileOptions::default(),
2648 )
2649 .await;
2650 }
2651}
2652
2653#[cfg(test)]
2654mod tests {
2655 use super::harnpack::HarnpackRunOptions;
2656 use super::{
2657 default_run_workspace_root, execute_explain_cost, execute_run,
2658 execute_run_with_harnpack_and_sandbox_options, run_sandbox_attestation, split_eval_header,
2659 CliLlmMockMode, RunProfileOptions, RunSandboxOptions, StdoutPassthroughGuard,
2660 };
2661 use std::collections::HashSet;
2662 use std::path::Path;
2663
2664 #[test]
2665 fn split_eval_header_no_imports_returns_full_body() {
2666 let (header, body) = split_eval_header("log(1 + 2)");
2667 assert_eq!(header, "");
2668 assert_eq!(body, "log(1 + 2)");
2669 }
2670
2671 #[test]
2672 fn split_eval_header_lifts_leading_imports() {
2673 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nlog(x)";
2674 let (header, body) = split_eval_header(code);
2675 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
2676 assert_eq!(body, "log(x)");
2677 }
2678
2679 #[test]
2680 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
2681 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
2682 let (header, body) = split_eval_header(code);
2683 assert_eq!(
2684 header,
2685 "// header comment\npub import { y } from \"./lib\"\n"
2686 );
2687 assert_eq!(body, "foo()");
2688 }
2689
2690 #[test]
2691 fn split_eval_header_does_not_lift_imports_after_other_statements() {
2692 let code = "let a = 1\nimport \"./lib\"";
2693 let (header, body) = split_eval_header(code);
2694 assert_eq!(header, "");
2695 assert_eq!(body, "let a = 1\nimport \"./lib\"");
2696 }
2697
2698 #[test]
2699 fn cli_llm_mock_roundtrips_logprobs() {
2700 let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
2701 "text": "visible",
2702 "logprobs": [{"token": "visible", "logprob": 0.0}]
2703 }))
2704 .expect("parse mock");
2705 assert_eq!(mock.logprobs.len(), 1);
2706
2707 let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
2708 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
2709 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
2710
2711 let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
2712 assert_eq!(reparsed.logprobs.len(), 1);
2713 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
2714 }
2715
2716 #[test]
2717 fn stdout_passthrough_guard_restores_previous_state() {
2718 let original = harn_vm::set_stdout_passthrough(false);
2719 {
2720 let _guard = StdoutPassthroughGuard::enable();
2721 assert!(harn_vm::set_stdout_passthrough(true));
2722 }
2723 assert!(!harn_vm::set_stdout_passthrough(original));
2724 }
2725
2726 #[test]
2727 fn execute_explain_cost_does_not_execute_script() {
2728 let temp = tempfile::TempDir::new().expect("temp dir");
2729 let script = temp.path().join("main.harn");
2730 std::fs::write(
2731 &script,
2732 r#"
2733pipeline main() {
2734 write_file("executed.txt", "bad")
2735 llm_call("hello", nil, {provider: "mock", model: "mock"})
2736}
2737"#,
2738 )
2739 .expect("write script");
2740
2741 let outcome = execute_explain_cost(&script.to_string_lossy());
2742
2743 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2744 assert!(outcome.stdout.contains("LLM cost estimate"));
2745 assert!(
2746 !temp.path().join("executed.txt").exists(),
2747 "--explain-cost must not execute pipeline side effects"
2748 );
2749 }
2750
2751 #[test]
2752 fn default_run_workspace_root_prefers_manifest_root_then_cwd() {
2753 let project = tempfile::TempDir::new().expect("project");
2754 let source_parent = project.path().join("scripts");
2755 let cwd = std::env::current_dir().expect("cwd");
2756
2757 assert_eq!(
2758 default_run_workspace_root(Some(project.path()), &source_parent),
2759 project.path()
2760 );
2761 assert_eq!(default_run_workspace_root(None, Path::new("scripts")), cwd);
2762 }
2763
2764 #[test]
2765 fn run_sandbox_attestation_reports_effective_policy() {
2766 harn_vm::reset_thread_local_state();
2767 let policy = harn_vm::orchestration::CapabilityPolicy {
2768 workspace_roots: vec!["/tmp/workspace".to_string()],
2769 sandbox_profile: harn_vm::orchestration::SandboxProfile::OsHardened,
2770 ..harn_vm::orchestration::CapabilityPolicy::default()
2771 };
2772 harn_vm::orchestration::push_execution_policy(policy);
2773
2774 let metadata = run_sandbox_attestation(&RunSandboxOptions::disabled());
2775
2776 assert_eq!(metadata["run_default_enabled"], false);
2777 assert_eq!(metadata["active"], true);
2778 assert_eq!(metadata["workspace_roots"][0], "/tmp/workspace");
2779 assert_eq!(metadata["profile"], "os_hardened");
2780 assert_eq!(metadata["egress"], "host_policy");
2781 harn_vm::reset_thread_local_state();
2782 }
2783
2784 #[tokio::test]
2785 async fn execute_run_default_sandbox_reports_worktree_profile() {
2786 harn_vm::reset_thread_local_state();
2787 let temp = tempfile::TempDir::new().expect("temp dir");
2788 let script = temp.path().join("main.harn");
2789 std::fs::write(
2790 &script,
2791 r#"
2792pipeline main() {
2793 __io_println(sandbox_active_profile())
2794}
2795"#,
2796 )
2797 .expect("write script");
2798
2799 let outcome = execute_run(
2800 &script.to_string_lossy(),
2801 false,
2802 HashSet::new(),
2803 Vec::new(),
2804 Vec::new(),
2805 CliLlmMockMode::Off,
2806 None,
2807 RunProfileOptions::default(),
2808 )
2809 .await;
2810
2811 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2812 assert_eq!(outcome.stdout.trim(), "worktree");
2813 harn_vm::reset_thread_local_state();
2814 }
2815
2816 #[tokio::test]
2817 async fn execute_run_default_sandbox_blocks_outside_workspace_read() {
2818 harn_vm::reset_thread_local_state();
2819 let temp = tempfile::TempDir::new().expect("temp dir");
2820 let project = temp.path().join("project");
2821 let outside = temp.path().join("outside.txt");
2822 std::fs::create_dir(&project).expect("create project");
2823 std::fs::write(project.join("harn.toml"), "").expect("write manifest");
2824 std::fs::write(&outside, "secret").expect("write outside");
2825 let script = project.join("main.harn");
2826 let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2827 std::fs::write(
2828 &script,
2829 format!(
2830 r#"
2831pipeline main() {{
2832 __io_println(sandbox_active_profile())
2833 let _ = read_file("{}")
2834}}
2835"#,
2836 outside_literal
2837 ),
2838 )
2839 .expect("write script");
2840
2841 let outcome = execute_run(
2842 &script.to_string_lossy(),
2843 false,
2844 HashSet::new(),
2845 Vec::new(),
2846 Vec::new(),
2847 CliLlmMockMode::Off,
2848 None,
2849 RunProfileOptions::default(),
2850 )
2851 .await;
2852
2853 assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2854 assert!(
2855 outcome.stderr.contains("sandbox violation"),
2856 "stderr:\n{}",
2857 outcome.stderr
2858 );
2859 harn_vm::reset_thread_local_state();
2860 }
2861
2862 #[tokio::test]
2863 async fn execute_run_no_sandbox_allows_outside_workspace_read() {
2864 harn_vm::reset_thread_local_state();
2865 let temp = tempfile::TempDir::new().expect("temp dir");
2866 let project = temp.path().join("project");
2867 let outside = temp.path().join("outside.txt");
2868 std::fs::create_dir(&project).expect("create project");
2869 std::fs::write(&outside, "secret").expect("write outside");
2870 let script = project.join("main.harn");
2871 let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2872 std::fs::write(
2873 &script,
2874 format!(
2875 r#"
2876pipeline main() {{
2877 __io_println(sandbox_active_profile())
2878 __io_println(read_file("{}"))
2879}}
2880"#,
2881 outside_literal
2882 ),
2883 )
2884 .expect("write script");
2885
2886 let outcome = execute_run_with_harnpack_and_sandbox_options(
2887 &script.to_string_lossy(),
2888 false,
2889 HashSet::new(),
2890 Vec::new(),
2891 Vec::new(),
2892 CliLlmMockMode::Off,
2893 None,
2894 RunProfileOptions::default(),
2895 RunSandboxOptions::disabled(),
2896 HarnpackRunOptions::default(),
2897 )
2898 .await;
2899
2900 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2901 assert_eq!(outcome.stdout.trim(), "unrestricted\nsecret");
2902 assert!(outcome.stderr.contains("--no-sandbox"));
2903 harn_vm::reset_thread_local_state();
2904 }
2905
2906 #[tokio::test]
2907 async fn execute_run_denies_network_by_default() {
2908 harn_vm::reset_thread_local_state();
2909 let temp = tempfile::TempDir::new().expect("temp dir");
2910 let script = temp.path().join("main.harn");
2911 std::fs::write(
2912 &script,
2913 r#"
2914pipeline main() {
2915 let _ = http_get("https://example.com/")
2916}
2917"#,
2918 )
2919 .expect("write script");
2920
2921 let outcome = execute_run(
2922 &script.to_string_lossy(),
2923 false,
2924 HashSet::new(),
2925 Vec::new(),
2926 Vec::new(),
2927 CliLlmMockMode::Off,
2928 None,
2929 RunProfileOptions::default(),
2930 )
2931 .await;
2932
2933 assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2934 assert!(
2935 outcome.stderr.contains("exceeds network ceiling"),
2936 "stderr:\n{}",
2937 outcome.stderr
2938 );
2939 harn_vm::reset_thread_local_state();
2940 }
2941
2942 #[cfg(feature = "hostlib")]
2943 #[tokio::test]
2944 async fn execute_run_installs_hostlib_gate() {
2945 let temp = tempfile::NamedTempFile::new().expect("temp file");
2946 std::fs::write(
2947 temp.path(),
2948 r#"
2949pipeline main() {
2950 let _ = hostlib_enable("tools:deterministic")
2951 __io_println("enabled")
2952}
2953"#,
2954 )
2955 .expect("write script");
2956
2957 let outcome = execute_run(
2958 &temp.path().to_string_lossy(),
2959 false,
2960 HashSet::new(),
2961 Vec::new(),
2962 Vec::new(),
2963 CliLlmMockMode::Off,
2964 None,
2965 RunProfileOptions::default(),
2966 )
2967 .await;
2968
2969 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2970 assert_eq!(outcome.stdout.trim(), "enabled");
2971 }
2972
2973 #[cfg(all(feature = "hostlib", unix))]
2974 #[tokio::test]
2975 async fn execute_run_can_read_hostlib_command_artifacts() {
2976 let temp = tempfile::NamedTempFile::new().expect("temp file");
2977 std::fs::write(
2978 temp.path(),
2979 r#"
2980pipeline main() {
2981 let _ = hostlib_enable("tools:deterministic")
2982 let result = hostlib_tools_run_command({
2983 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
2984 capture: {max_inline_bytes: 8},
2985 timeout_ms: 5000,
2986 })
2987 __io_println(starts_with(result.command_id, "cmd_"))
2988 __io_println(len(result.stdout))
2989 __io_println(result.byte_count)
2990 let window = hostlib_tools_read_command_output({
2991 command_id: result.command_id,
2992 offset: 1990,
2993 length: 20,
2994 })
2995 __io_println(len(window.content))
2996 __io_println(window.eof)
2997}
2998"#,
2999 )
3000 .expect("write script");
3001
3002 let outcome = execute_run_with_harnpack_and_sandbox_options(
3003 &temp.path().to_string_lossy(),
3004 false,
3005 HashSet::new(),
3006 Vec::new(),
3007 Vec::new(),
3008 CliLlmMockMode::Off,
3009 None,
3010 RunProfileOptions::default(),
3011 RunSandboxOptions::disabled(),
3012 HarnpackRunOptions::default(),
3013 )
3014 .await;
3015
3016 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
3017 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
3018 }
3019}