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, Debug, PartialEq, Eq)]
377pub struct RunSandboxOptions {
378 pub enabled: bool,
380 pub workspace_root: Option<PathBuf>,
384}
385
386impl Default for RunSandboxOptions {
387 fn default() -> Self {
388 Self {
389 enabled: true,
390 workspace_root: None,
391 }
392 }
393}
394
395impl RunSandboxOptions {
396 pub fn disabled() -> Self {
398 Self {
399 enabled: false,
400 workspace_root: None,
401 }
402 }
403
404 pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
406 self.workspace_root = Some(workspace_root.into());
407 self
408 }
409}
410
411#[derive(Clone)]
412pub struct RunInterruptTokens {
413 pub cancel_token: Arc<AtomicBool>,
414 pub signal_token: Arc<Mutex<Option<String>>>,
415}
416
417struct ExecuteRunInputs<'a> {
418 path: &'a str,
419 trace: bool,
420 denied_builtins: HashSet<String>,
421 script_argv: Vec<String>,
422 skill_dirs_raw: Vec<String>,
423 llm_mock_mode: CliLlmMockMode,
424 attestation: Option<RunAttestationOptions>,
425 profile: RunProfileOptions,
426 sandbox: RunSandboxOptions,
427 interrupt_tokens: Option<RunInterruptTokens>,
428 json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
429 timing: Option<&'a mut RunTiming>,
430 harnpack: HarnpackRunOptions,
431}
432
433#[derive(Clone, Debug, Default)]
437pub struct RunOutcome {
438 pub stdout: String,
439 pub stderr: String,
440 pub exit_code: i32,
441}
442
443pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
444 harn_vm::llm::clear_cli_llm_mock_mode();
445 match mode {
446 CliLlmMockMode::Off => Ok(()),
447 CliLlmMockMode::Replay { fixture_path } => {
448 let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
449 harn_vm::llm::install_cli_llm_mocks(mocks);
450 Ok(())
451 }
452 CliLlmMockMode::Record { .. } => {
453 harn_vm::llm::enable_cli_llm_mock_recording();
454 Ok(())
455 }
456 }
457}
458
459pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
460 let CliLlmMockMode::Record { fixture_path } = mode else {
461 return Ok(());
462 };
463 if let Some(parent) = fixture_path.parent() {
464 if !parent.as_os_str().is_empty() {
465 fs::create_dir_all(parent).map_err(|error| {
466 format!(
467 "failed to create fixture directory {}: {error}",
468 parent.display()
469 )
470 })?;
471 }
472 }
473
474 let lines = harn_vm::llm::take_cli_llm_recordings()
475 .into_iter()
476 .map(harn_vm::llm::serialize_llm_mock)
477 .collect::<Result<Vec<_>, _>>()?;
478 let body = if lines.is_empty() {
479 String::new()
480 } else {
481 format!("{}\n", lines.join("\n"))
482 };
483 fs::write(fixture_path, body)
484 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
485}
486
487pub(crate) async fn run_file(
488 path: &str,
489 trace: bool,
490 denied_builtins: HashSet<String>,
491 script_argv: Vec<String>,
492 llm_mock_mode: CliLlmMockMode,
493 attestation: Option<RunAttestationOptions>,
494 profile: RunProfileOptions,
495) {
496 run_file_with_skill_dirs(
497 path,
498 trace,
499 denied_builtins,
500 script_argv,
501 Vec::new(),
502 llm_mock_mode,
503 attestation,
504 profile,
505 RunSandboxOptions::default(),
506 None,
507 HarnpackRunOptions::default(),
508 )
509 .await;
510}
511
512pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
513 let outcome = execute_explain_cost(path);
514 if !outcome.stderr.is_empty() {
515 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
516 }
517 if !outcome.stdout.is_empty() {
518 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
519 }
520 if outcome.exit_code != 0 {
521 process::exit(outcome.exit_code);
522 }
523}
524
525#[allow(clippy::too_many_arguments)]
526pub(crate) async fn run_file_with_skill_dirs(
527 path: &str,
528 trace: bool,
529 denied_builtins: HashSet<String>,
530 script_argv: Vec<String>,
531 skill_dirs_raw: Vec<String>,
532 llm_mock_mode: CliLlmMockMode,
533 attestation: Option<RunAttestationOptions>,
534 profile: RunProfileOptions,
535 sandbox: RunSandboxOptions,
536 json: Option<RunJsonOptions>,
537 harnpack: HarnpackRunOptions,
538) {
539 let interrupt_tokens = install_signal_shutdown_handler();
541
542 let _stdout_passthrough = StdoutPassthroughGuard::enable();
543 let json_with_stdout =
544 json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
545 let outcome = execute_run_inner(ExecuteRunInputs {
546 path,
547 trace,
548 denied_builtins,
549 script_argv,
550 skill_dirs_raw,
551 llm_mock_mode,
552 attestation,
553 profile,
554 sandbox,
555 interrupt_tokens: Some(interrupt_tokens.clone()),
556 json: json_with_stdout,
557 timing: None,
558 harnpack,
559 })
560 .await;
561
562 if !outcome.stderr.is_empty() {
565 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
566 }
567 if !outcome.stdout.is_empty() {
568 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
569 }
570
571 let mut exit_code = outcome.exit_code;
572 if exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst) {
573 exit_code = 124;
574 }
575 if exit_code != 0 {
576 process::exit(exit_code);
577 }
578}
579
580#[allow(clippy::too_many_arguments)]
581pub(crate) async fn run_resume_with_skill_dirs(
582 target: &str,
583 trace: bool,
584 denied_builtins: HashSet<String>,
585 resume_argv: Vec<String>,
586 skill_dirs_raw: Vec<String>,
587 llm_mock_mode: CliLlmMockMode,
588 attestation: Option<RunAttestationOptions>,
589 profile: RunProfileOptions,
590 sandbox: RunSandboxOptions,
591 json: Option<RunJsonOptions>,
592) {
593 let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
594
595pipeline main(task) {
596 let input = if len(argv) > 1 {
597 argv[1]
598 } else {
599 nil
600 }
601 let handle = resume_agent(argv[0], input, true)
602 return wait_agent(handle)
603}
604"#;
605 let tmp = create_eval_temp_file().unwrap_or_else(|e| {
606 eprintln!("error: {e}");
607 process::exit(1);
608 });
609 let tmp_path = tmp.path().to_path_buf();
610 if let Err(error) = fs::write(&tmp_path, source) {
611 eprintln!("error: failed to write temp file for --resume: {error}");
612 process::exit(1);
613 }
614 let mut argv = Vec::with_capacity(resume_argv.len() + 1);
615 argv.push(target.to_string());
616 argv.extend(resume_argv);
617 let tmp_str = tmp_path.to_string_lossy().into_owned();
618 run_file_with_skill_dirs(
619 &tmp_str,
620 trace,
621 denied_builtins,
622 argv,
623 skill_dirs_raw,
624 llm_mock_mode,
625 attestation,
626 profile,
627 sandbox,
628 json,
629 HarnpackRunOptions::default(),
630 )
631 .await;
632}
633
634pub fn execute_explain_cost(path: &str) -> RunOutcome {
635 let stdout = String::new();
636 let mut stderr = String::new();
637
638 let (source, program) = parse_source_file(path);
639
640 let mut had_type_error = false;
641 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
642 for diag in &type_diagnostics {
643 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
644 if matches!(diag.severity, DiagnosticSeverity::Error) {
645 had_type_error = true;
646 }
647 stderr.push_str(&rendered);
648 }
649 if had_type_error {
650 return RunOutcome {
651 stdout,
652 stderr,
653 exit_code: 1,
654 };
655 }
656
657 let extensions = package::load_runtime_extensions(Path::new(path));
658 package::install_runtime_extensions(&extensions);
659 RunOutcome {
660 stdout: explain_cost::render_explain_cost(path, &program),
661 stderr,
662 exit_code: 0,
663 }
664}
665
666pub(crate) struct StdoutPassthroughGuard {
667 previous: bool,
668}
669
670impl StdoutPassthroughGuard {
671 pub(crate) fn enable() -> Self {
672 Self {
673 previous: harn_vm::set_stdout_passthrough(true),
674 }
675 }
676}
677
678impl Drop for StdoutPassthroughGuard {
679 fn drop(&mut self) {
680 harn_vm::set_stdout_passthrough(self.previous);
681 }
682}
683
684struct ExecutionPolicyGuard;
685
686impl Drop for ExecutionPolicyGuard {
687 fn drop(&mut self) {
688 harn_vm::orchestration::pop_execution_policy();
689 }
690}
691
692struct RunSandboxScope {
693 _execution_policy: Option<ExecutionPolicyGuard>,
694 _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
695}
696
697impl RunSandboxScope {
698 fn disabled() -> Self {
699 Self {
700 _execution_policy: None,
701 _egress_policy: None,
702 }
703 }
704}
705
706fn install_run_sandbox_scope(
707 options: &RunSandboxOptions,
708 workspace_root: &Path,
709 stderr: &mut String,
710) -> RunSandboxScope {
711 if !options.enabled {
712 stderr.push_str(
713 "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
714 );
715 return RunSandboxScope::disabled();
716 }
717
718 let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
719 harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
720 workspace_root,
721 ));
722 Some(ExecutionPolicyGuard)
723 } else {
724 None
725 };
726 let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
727
728 RunSandboxScope {
729 _execution_policy: execution_policy,
730 _egress_policy: egress_policy,
731 }
732}
733
734fn default_run_capability_policy(
735 workspace_root: &Path,
736) -> harn_vm::orchestration::CapabilityPolicy {
737 harn_vm::orchestration::CapabilityPolicy {
738 workspace_roots: vec![normalize_run_workspace_root(workspace_root)
739 .display()
740 .to_string()],
741 side_effect_level: Some("process_exec".to_string()),
742 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
743 ..harn_vm::orchestration::CapabilityPolicy::default()
744 }
745}
746
747fn normalize_run_workspace_root(path: &Path) -> PathBuf {
748 if path.is_absolute() {
749 return path.to_path_buf();
750 }
751 std::env::current_dir()
752 .map(|cwd| cwd.join(path))
753 .unwrap_or_else(|_| path.to_path_buf())
754}
755
756fn default_run_workspace_root(project_root: Option<&Path>, source_parent: &Path) -> PathBuf {
757 project_root
758 .map(Path::to_path_buf)
759 .or_else(|| std::env::current_dir().ok())
760 .unwrap_or_else(|| source_parent.to_path_buf())
761}
762
763fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
764 let active_policy = harn_vm::orchestration::current_execution_policy();
765 let active = active_policy.is_some();
766 let workspace_roots = active_policy
767 .as_ref()
768 .map(|policy| policy.workspace_roots.clone())
769 .unwrap_or_default();
770 let profile = active_policy
771 .as_ref()
772 .map(|policy| policy.sandbox_profile.as_str())
773 .unwrap_or("unrestricted");
774 let egress = if sandbox.enabled {
775 "explicit_policy_required"
776 } else if active {
777 "host_policy"
778 } else {
779 "unrestricted"
780 };
781
782 serde_json::json!({
783 "run_default_enabled": sandbox.enabled,
784 "active": active,
785 "workspace_roots": workspace_roots,
786 "profile": profile,
787 "egress": egress,
788 })
789}
790
791const FIRST_SIGNAL_MESSAGE: &str =
800 "[harn] signal received, interrupting VM (give it a moment to unwind in-flight async ops; Ctrl-C again to force-exit)...";
801
802fn install_signal_shutdown_handler() -> RunInterruptTokens {
803 let tokens = RunInterruptTokens {
804 cancel_token: Arc::new(AtomicBool::new(false)),
805 signal_token: Arc::new(Mutex::new(None)),
806 };
807 let tokens_clone = tokens.clone();
808 tokio::spawn(async move {
809 #[cfg(unix)]
810 {
811 use tokio::signal::unix::{signal, SignalKind};
812 let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
813 let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
814 let mut sighup = signal(SignalKind::hangup()).expect("SIGHUP handler");
815 let mut seen_signal = false;
816 loop {
817 let signal_name = tokio::select! {
818 _ = sigterm.recv() => "SIGTERM",
819 _ = sigint.recv() => "SIGINT",
820 _ = sighup.recv() => "SIGHUP",
821 };
822 if seen_signal {
823 eprintln!("[harn] second signal received, terminating");
824 process::exit(124);
825 }
826 seen_signal = true;
827 request_vm_interrupt(&tokens_clone, signal_name);
828 eprintln!("{FIRST_SIGNAL_MESSAGE}");
829 }
830 }
831 #[cfg(not(unix))]
832 {
833 let mut seen_signal = false;
834 loop {
835 let _ = tokio::signal::ctrl_c().await;
836 if seen_signal {
837 eprintln!("[harn] second signal received, terminating");
838 process::exit(124);
839 }
840 seen_signal = true;
841 request_vm_interrupt(&tokens_clone, "SIGINT");
842 eprintln!("{FIRST_SIGNAL_MESSAGE}");
843 }
844 }
845 });
846 tokens
847}
848
849fn request_vm_interrupt(tokens: &RunInterruptTokens, signal_name: &str) {
850 if let Ok(mut signal) = tokens.signal_token.lock() {
851 *signal = Some(signal_name.to_string());
852 }
853 tokens.cancel_token.store(true, Ordering::SeqCst);
854}
855
856pub async fn execute_run(
862 path: &str,
863 trace: bool,
864 denied_builtins: HashSet<String>,
865 script_argv: Vec<String>,
866 skill_dirs_raw: Vec<String>,
867 llm_mock_mode: CliLlmMockMode,
868 attestation: Option<RunAttestationOptions>,
869 profile: RunProfileOptions,
870) -> RunOutcome {
871 execute_run_with_harnpack_and_sandbox_options(
872 path,
873 trace,
874 denied_builtins,
875 script_argv,
876 skill_dirs_raw,
877 llm_mock_mode,
878 attestation,
879 profile,
880 RunSandboxOptions::default(),
881 HarnpackRunOptions::default(),
882 )
883 .await
884}
885
886#[allow(clippy::too_many_arguments)]
890pub async fn execute_run_with_sandbox_options(
891 path: &str,
892 trace: bool,
893 denied_builtins: HashSet<String>,
894 script_argv: Vec<String>,
895 skill_dirs_raw: Vec<String>,
896 llm_mock_mode: CliLlmMockMode,
897 attestation: Option<RunAttestationOptions>,
898 profile: RunProfileOptions,
899 sandbox: RunSandboxOptions,
900) -> RunOutcome {
901 execute_run_with_harnpack_and_sandbox_options(
902 path,
903 trace,
904 denied_builtins,
905 script_argv,
906 skill_dirs_raw,
907 llm_mock_mode,
908 attestation,
909 profile,
910 sandbox,
911 HarnpackRunOptions::default(),
912 )
913 .await
914}
915
916#[allow(clippy::too_many_arguments)]
921pub async fn execute_run_with_harnpack_options(
922 path: &str,
923 trace: bool,
924 denied_builtins: HashSet<String>,
925 script_argv: Vec<String>,
926 skill_dirs_raw: Vec<String>,
927 llm_mock_mode: CliLlmMockMode,
928 attestation: Option<RunAttestationOptions>,
929 profile: RunProfileOptions,
930 harnpack: HarnpackRunOptions,
931) -> RunOutcome {
932 execute_run_with_harnpack_and_sandbox_options(
933 path,
934 trace,
935 denied_builtins,
936 script_argv,
937 skill_dirs_raw,
938 llm_mock_mode,
939 attestation,
940 profile,
941 RunSandboxOptions::default(),
942 harnpack,
943 )
944 .await
945}
946
947#[allow(clippy::too_many_arguments)]
948async fn execute_run_with_harnpack_and_sandbox_options(
949 path: &str,
950 trace: bool,
951 denied_builtins: HashSet<String>,
952 script_argv: Vec<String>,
953 skill_dirs_raw: Vec<String>,
954 llm_mock_mode: CliLlmMockMode,
955 attestation: Option<RunAttestationOptions>,
956 profile: RunProfileOptions,
957 sandbox: RunSandboxOptions,
958 harnpack: HarnpackRunOptions,
959) -> RunOutcome {
960 execute_run_inner(ExecuteRunInputs {
961 path,
962 trace,
963 denied_builtins,
964 script_argv,
965 skill_dirs_raw,
966 llm_mock_mode,
967 attestation,
968 profile,
969 sandbox,
970 interrupt_tokens: None,
971 json: None,
972 timing: None,
973 harnpack,
974 })
975 .await
976}
977
978#[allow(clippy::too_many_arguments)]
984pub async fn execute_run_json(
985 path: &str,
986 trace: bool,
987 denied_builtins: HashSet<String>,
988 script_argv: Vec<String>,
989 skill_dirs_raw: Vec<String>,
990 llm_mock_mode: CliLlmMockMode,
991 attestation: Option<RunAttestationOptions>,
992 profile: RunProfileOptions,
993 out: Box<dyn io::Write + Send>,
994 options: RunJsonOptions,
995) -> RunOutcome {
996 execute_run_inner(ExecuteRunInputs {
997 path,
998 trace,
999 denied_builtins,
1000 script_argv,
1001 skill_dirs_raw,
1002 llm_mock_mode,
1003 attestation,
1004 profile,
1005 sandbox: RunSandboxOptions::default(),
1006 interrupt_tokens: None,
1007 json: Some((options, out)),
1008 timing: None,
1009 harnpack: HarnpackRunOptions::default(),
1010 })
1011 .await
1012}
1013
1014pub(crate) async fn execute_run_with_timing(
1018 path: &str,
1019 script_argv: Vec<String>,
1020 timing: Option<&mut RunTiming>,
1021 sandbox: RunSandboxOptions,
1022) -> RunOutcome {
1023 execute_run_inner(ExecuteRunInputs {
1024 path,
1025 trace: false,
1026 denied_builtins: HashSet::new(),
1027 script_argv,
1028 skill_dirs_raw: Vec::new(),
1029 llm_mock_mode: CliLlmMockMode::Off,
1030 attestation: None,
1031 profile: RunProfileOptions::default(),
1032 sandbox,
1033 interrupt_tokens: None,
1034 json: None,
1035 timing,
1036 harnpack: HarnpackRunOptions::default(),
1037 })
1038 .await
1039}
1040
1041#[allow(clippy::needless_option_as_deref)]
1044async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
1045 let ExecuteRunInputs {
1046 path,
1047 trace,
1048 denied_builtins,
1049 script_argv,
1050 skill_dirs_raw,
1051 llm_mock_mode,
1052 attestation,
1053 profile,
1054 sandbox,
1055 interrupt_tokens,
1056 json,
1057 mut timing,
1058 harnpack,
1059 } = inputs;
1060
1061 let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
1067
1068 let mut stderr = String::new();
1069 let mut stdout = String::new();
1070
1071 let owned_run_path: String;
1076 let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
1077 let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
1078 Ok(prepared) => prepared,
1079 Err(err) => return finalize_harnpack_error(stderr, json_session, err),
1080 };
1081 harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
1082 bundle_hash: outcome.bundle_hash.clone(),
1083 signature_verified: outcome.signature_verified,
1084 key_id: outcome.key_id.clone(),
1085 cache_hit: outcome.cache_hit,
1086 dry_run_verify: harnpack.dry_run_verify,
1087 });
1088 if harnpack.dry_run_verify {
1089 return finalize_harnpack_dry_run(stderr, json_session, &outcome);
1090 }
1091 owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
1092 owned_run_path.as_str()
1093 } else {
1094 path
1095 };
1096
1097 let Some(LoadedChunk { source, chunk }) =
1098 compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
1099 else {
1100 if let Some(session) = json_session {
1101 return session.finalize_error("compile_error", stderr, 1);
1102 }
1103 return RunOutcome {
1104 stdout,
1105 stderr,
1106 exit_code: 1,
1107 };
1108 };
1109 let path = resolved_path;
1110
1111 let setup_start = Instant::now();
1115
1116 if trace {
1117 harn_vm::llm::enable_tracing();
1118 }
1119 if profile.is_enabled() {
1120 harn_vm::tracing::set_tracing_enabled(true);
1121 }
1122 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
1123 stderr.push_str(&format!("error: {error}\n"));
1124 if let Some(session) = json_session {
1125 return session.finalize_error("llm_mock_install", error, 1);
1126 }
1127 return RunOutcome {
1128 stdout,
1129 stderr,
1130 exit_code: 1,
1131 };
1132 }
1133
1134 let mut vm = harn_vm::Vm::new();
1135 if let Some(interrupt_tokens) = interrupt_tokens {
1136 vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
1137 vm.install_cancel_token(interrupt_tokens.cancel_token);
1138 }
1139 harn_vm::register_vm_stdlib_with_deferred_llm(&mut vm);
1140 crate::install_default_hostlib(&mut vm);
1141 let source_parent = std::path::Path::new(path)
1142 .parent()
1143 .unwrap_or(std::path::Path::new("."));
1144 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1146 let store_base = project_root.as_deref().unwrap_or(source_parent);
1147 let sandbox_root = sandbox
1148 .workspace_root
1149 .clone()
1150 .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
1151 let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
1152 let attestation_started_at_ms = now_ms();
1153 let attestation_log = if attestation.is_some() {
1154 Some(harn_vm::event_log::install_memory_for_current_thread(256))
1155 } else {
1156 None
1157 };
1158 if let Some(log) = attestation_log.as_ref() {
1159 append_run_provenance_event(
1160 log,
1161 "started",
1162 serde_json::json!({
1163 "pipeline": path,
1164 "argv": &script_argv,
1165 "project_root": store_base.display().to_string(),
1166 "sandbox": run_sandbox_attestation(&sandbox),
1167 }),
1168 )
1169 .await;
1170 }
1171 harn_vm::register_store_builtins(&mut vm, store_base);
1172 harn_vm::register_metadata_builtins(&mut vm, store_base);
1173 let pipeline_name = std::path::Path::new(path)
1174 .file_stem()
1175 .and_then(|s| s.to_str())
1176 .unwrap_or("default");
1177 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1178 vm.set_source_info(path, &source);
1179 if !denied_builtins.is_empty() {
1180 vm.set_denied_builtins(denied_builtins);
1181 }
1182 if let Some(ref root) = project_root {
1183 vm.set_project_root(root);
1184 }
1185
1186 if let Some(p) = std::path::Path::new(path).parent() {
1187 if !p.as_os_str().is_empty() {
1188 vm.set_source_dir(p);
1189 }
1190 }
1191
1192 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
1195 let loaded = load_skills(&SkillLoaderInputs {
1196 cli_dirs,
1197 source_path: Some(std::path::PathBuf::from(path)),
1198 });
1199 emit_loader_warnings(&loaded.loader_warnings);
1200 install_skills_global(&mut vm, &loaded);
1201
1202 let argv_values: Vec<harn_vm::VmValue> = script_argv
1205 .iter()
1206 .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
1207 .collect();
1208 vm.set_global(
1209 "argv",
1210 harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
1211 );
1212
1213 vm.set_harness(harn_vm::Harness::real());
1217
1218 let extensions = package::load_runtime_extensions(Path::new(path));
1219 package::install_runtime_extensions(&extensions);
1220 if let Some(manifest) = extensions.root_manifest.as_ref() {
1221 if !manifest.mcp.is_empty() {
1222 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1223 }
1224 }
1225 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1226 stderr.push_str(&format!(
1227 "error: failed to install manifest triggers: {error}\n"
1228 ));
1229 if let Some(session) = json_session {
1230 return session.finalize_error("manifest_triggers", error.to_string(), 1);
1231 }
1232 return RunOutcome {
1233 stdout,
1234 stderr,
1235 exit_code: 1,
1236 };
1237 }
1238 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1239 stderr.push_str(&format!(
1240 "error: failed to install manifest hooks: {error}\n"
1241 ));
1242 if let Some(session) = json_session {
1243 return session.finalize_error("manifest_hooks", error.to_string(), 1);
1244 }
1245 return RunOutcome {
1246 stdout,
1247 stderr,
1248 exit_code: 1,
1249 };
1250 }
1251
1252 let local = tokio::task::LocalSet::new();
1254 if let Some(t) = timing.as_deref_mut() {
1255 t.run_setup = setup_start.elapsed();
1256 }
1257 let main_start = Instant::now();
1258 let execution = local
1259 .run_until(async {
1260 match vm.execute(&chunk).await {
1261 Ok(value) => Ok((vm.output(), value)),
1262 Err(e) => Err(vm.format_runtime_error(&e)),
1263 }
1264 })
1265 .await;
1266 if let Some(t) = timing.as_deref_mut() {
1267 t.run_main = main_start.elapsed();
1268 }
1269 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1270 stderr.push_str(&format!("error: {error}\n"));
1271 if let Some(session) = json_session {
1272 return session.finalize_error("llm_mock_record", error, 1);
1273 }
1274 return RunOutcome {
1275 stdout,
1276 stderr,
1277 exit_code: 1,
1278 };
1279 }
1280
1281 let buffered_stderr = harn_vm::take_stderr_buffer();
1283 stderr.push_str(&buffered_stderr);
1284
1285 let exit_code = match &execution {
1286 Ok((_, return_value)) => exit_code_from_return_value(return_value),
1287 Err(_) => 1,
1288 };
1289
1290 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1291 if let Err(error) = emit_run_attestation(
1292 log,
1293 path,
1294 store_base,
1295 attestation_started_at_ms,
1296 exit_code,
1297 options,
1298 &mut stderr,
1299 )
1300 .await
1301 {
1302 stderr.push_str(&format!(
1303 "error: failed to emit provenance receipt: {error}\n"
1304 ));
1305 if let Some(session) = json_session {
1306 return session.finalize_error("attestation", error.to_string(), 1);
1307 }
1308 return RunOutcome {
1309 stdout,
1310 stderr,
1311 exit_code: 1,
1312 };
1313 }
1314 harn_vm::event_log::reset_active_event_log();
1315 }
1316
1317 match execution {
1318 Ok((output, return_value)) => {
1319 stdout.push_str(output);
1320 if trace {
1321 stderr.push_str(&render_trace_summary());
1322 }
1323 if profile.is_enabled() {
1324 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1325 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1326 }
1327 }
1328 if exit_code != 0 {
1329 stderr.push_str(&render_return_value_error(&return_value));
1330 }
1331 if let Some(session) = json_session {
1332 let value = harn_vm::llm::vm_value_to_json(&return_value);
1333 return session.finalize_result(value, exit_code);
1334 }
1335 RunOutcome {
1336 stdout,
1337 stderr,
1338 exit_code,
1339 }
1340 }
1341 Err(rendered_error) => {
1342 stderr.push_str(&rendered_error);
1343 if profile.is_enabled() {
1344 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1345 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1346 }
1347 }
1348 if let Some(session) = json_session {
1349 return session.finalize_error("runtime", rendered_error, 1);
1350 }
1351 RunOutcome {
1352 stdout,
1353 stderr,
1354 exit_code: 1,
1355 }
1356 }
1357 }
1358}
1359
1360fn render_and_persist_profile(
1361 options: &RunProfileOptions,
1362 stderr: &mut String,
1363) -> Result<(), String> {
1364 let spans = harn_vm::tracing::peek_spans();
1365 let profile = harn_vm::profile::build(&spans);
1366 if options.text {
1367 stderr.push_str(&harn_vm::profile::render(&profile));
1368 }
1369 if let Some(path) = options.json_path.as_ref() {
1370 if let Some(parent) = path.parent() {
1371 if !parent.as_os_str().is_empty() {
1372 fs::create_dir_all(parent)
1373 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1374 }
1375 }
1376 let json = serde_json::to_string_pretty(&profile)
1377 .map_err(|error| format!("serialize profile: {error}"))?;
1378 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1379 }
1380 Ok(())
1381}
1382
1383async fn append_run_provenance_event(
1384 log: &Arc<harn_vm::event_log::AnyEventLog>,
1385 kind: &str,
1386 payload: serde_json::Value,
1387) {
1388 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1389 return;
1390 };
1391 let _ = log
1392 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1393 .await;
1394}
1395
1396async fn emit_run_attestation(
1397 log: &Arc<harn_vm::event_log::AnyEventLog>,
1398 path: &str,
1399 store_base: &Path,
1400 started_at_ms: i64,
1401 exit_code: i32,
1402 options: &RunAttestationOptions,
1403 stderr: &mut String,
1404) -> Result<(), String> {
1405 let finished_at_ms = now_ms();
1406 let status = if exit_code == 0 { "success" } else { "failure" };
1407 append_run_provenance_event(
1408 log,
1409 "finished",
1410 serde_json::json!({
1411 "pipeline": path,
1412 "status": status,
1413 "exit_code": exit_code,
1414 }),
1415 )
1416 .await;
1417 log.flush()
1418 .await
1419 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1420 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1421 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1422 let (signing_key, key_id) =
1423 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1424 .await
1425 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1426 let receipt = harn_vm::build_signed_receipt(
1427 log,
1428 harn_vm::ReceiptBuildOptions {
1429 pipeline: path.to_string(),
1430 status: status.to_string(),
1431 started_at_ms,
1432 finished_at_ms,
1433 exit_code,
1434 producer_name: "harn-cli".to_string(),
1435 producer_version: env!("CARGO_PKG_VERSION").to_string(),
1436 },
1437 &signing_key,
1438 key_id,
1439 )
1440 .await
1441 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1442 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1443 if let Some(parent) = receipt_path.parent() {
1444 fs::create_dir_all(parent)
1445 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1446 }
1447 let encoded = serde_json::to_vec_pretty(&receipt)
1448 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1449 fs::write(&receipt_path, encoded)
1450 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1451 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1452 Ok(())
1453}
1454
1455fn receipt_output_path(
1456 store_base: &Path,
1457 options: &RunAttestationOptions,
1458 receipt_id: &str,
1459) -> PathBuf {
1460 if let Some(path) = options.receipt_out.as_ref() {
1461 return path.clone();
1462 }
1463 harn_vm::runtime_paths::state_root(store_base)
1464 .join("receipts")
1465 .join(format!("{receipt_id}.json"))
1466}
1467
1468fn now_ms() -> i64 {
1469 std::time::SystemTime::now()
1470 .duration_since(std::time::UNIX_EPOCH)
1471 .map(|duration| duration.as_millis() as i64)
1472 .unwrap_or(0)
1473}
1474
1475fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1482 use harn_vm::VmValue;
1483 match value {
1484 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1485 VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
1486 _ => 0,
1487 }
1488}
1489
1490struct JsonRunSession {
1504 emitter: self::json_events::NdjsonEmitter,
1505 prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
1506}
1507
1508impl JsonRunSession {
1509 fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1510 let emitter = NdjsonEmitter::new(out, options.quiet);
1511 let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
1512 Self {
1513 emitter,
1514 prior_sink,
1515 }
1516 }
1517
1518 fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1519 self.emitter.emit_result(value, exit_code);
1520 RunOutcome {
1521 stdout: String::new(),
1522 stderr: String::new(),
1523 exit_code,
1524 }
1525 }
1526
1527 fn finalize_error(
1528 self,
1529 code: impl Into<String>,
1530 message: impl Into<String>,
1531 exit_code: i32,
1532 ) -> RunOutcome {
1533 self.emitter.emit_error(code, message);
1534 RunOutcome {
1535 stdout: String::new(),
1536 stderr: String::new(),
1537 exit_code,
1538 }
1539 }
1540}
1541
1542impl Drop for JsonRunSession {
1543 fn drop(&mut self) {
1544 match self.prior_sink.take() {
1545 Some(prior) => {
1546 harn_vm::run_events::install_sink(prior);
1547 }
1548 None => harn_vm::run_events::clear_sink(),
1549 }
1550 }
1551}
1552
1553fn finalize_harnpack_error(
1558 mut stderr: String,
1559 json_session: Option<JsonRunSession>,
1560 err: HarnpackError,
1561) -> RunOutcome {
1562 stderr.push_str(&format!("error: {}\n", err.message));
1563 if let Some(session) = json_session {
1564 return session.finalize_error(err.code, err.message, 1);
1565 }
1566 RunOutcome {
1567 stdout: String::new(),
1568 stderr,
1569 exit_code: 1,
1570 }
1571}
1572
1573fn finalize_harnpack_dry_run(
1578 mut stderr: String,
1579 json_session: Option<JsonRunSession>,
1580 prepared: &PreparedHarnpack,
1581) -> RunOutcome {
1582 let summary = format!(
1583 "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1584 prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1585 );
1586 stderr.push_str(&summary);
1587 if let Some(session) = json_session {
1588 let value = serde_json::json!({
1589 "bundle_hash": prepared.bundle_hash,
1590 "signature_verified": prepared.signature_verified,
1591 "key_id": prepared.key_id,
1592 "cache_hit": prepared.cache_hit,
1593 "dry_run_verify": true,
1594 });
1595 return session.finalize_result(value, 0);
1596 }
1597 RunOutcome {
1598 stdout: String::new(),
1599 stderr,
1600 exit_code: 0,
1601 }
1602}
1603
1604fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1605 let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
1606 return String::new();
1607 };
1608 if !enum_variant.is_variant("Result", "Err") {
1609 return String::new();
1610 }
1611 let rendered = enum_variant
1612 .fields
1613 .first()
1614 .map(|p| p.display())
1615 .unwrap_or_default();
1616 if rendered.is_empty() {
1617 "error\n".to_string()
1618 } else if rendered.ends_with('\n') {
1619 rendered
1620 } else {
1621 format!("{rendered}\n")
1622 }
1623}
1624
1625pub(crate) async fn connect_mcp_servers(
1634 servers: &[package::McpServerConfig],
1635 vm: &mut harn_vm::Vm,
1636) {
1637 use std::collections::BTreeMap;
1638 use std::rc::Rc;
1639 use std::time::Duration;
1640
1641 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1642 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1643
1644 for server in servers {
1645 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1646 Ok(resolution) => resolution,
1647 Err(error) => {
1648 eprintln!(
1649 "warning: mcp: failed to load auth for '{}': {}",
1650 server.name, error
1651 );
1652 AuthResolution::None
1653 }
1654 };
1655 let spec = serde_json::json!({
1656 "name": server.name,
1657 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1658 "command": server.command,
1659 "args": server.args,
1660 "env": server.env,
1661 "url": server.url,
1662 "auth_token": match resolved_auth {
1663 AuthResolution::Bearer(token) => Some(token),
1664 AuthResolution::None => server.auth_token.clone(),
1665 },
1666 "protocol_version": server.protocol_version,
1667 "protocol_mode": server.protocol_mode,
1668 "proxy_server_name": server.proxy_server_name,
1669 });
1670
1671 registrations.push(harn_vm::RegisteredMcpServer {
1674 name: server.name.clone(),
1675 spec: spec.clone(),
1676 lazy: server.lazy,
1677 card: server.card.clone(),
1678 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1679 });
1680
1681 if server.lazy {
1682 eprintln!(
1683 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1684 server.name
1685 );
1686 continue;
1687 }
1688
1689 match harn_vm::connect_mcp_server_from_json(&spec).await {
1690 Ok(handle) => {
1691 eprintln!("[harn] mcp: connected to '{}'", server.name);
1692 harn_vm::mcp_install_active(&server.name, handle.clone());
1693 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::mcp_client(handle));
1694 }
1695 Err(e) => {
1696 eprintln!(
1697 "warning: mcp: failed to connect to '{}': {}",
1698 server.name, e
1699 );
1700 }
1701 }
1702 }
1703
1704 harn_vm::mcp_register_servers(registrations);
1707
1708 if !mcp_dict.is_empty() {
1709 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1710 }
1711}
1712
1713pub(crate) fn render_trace_summary() -> String {
1714 use std::fmt::Write;
1715 let entries = harn_vm::llm::take_trace();
1716 if entries.is_empty() {
1717 return String::new();
1718 }
1719 let mut out = String::new();
1720 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1721 let mut total_input = 0i64;
1722 let mut total_output = 0i64;
1723 let mut total_ms = 0u64;
1724 for (i, entry) in entries.iter().enumerate() {
1725 let _ = writeln!(
1726 out,
1727 " #{}: {} | {} in + {} out tokens | {} ms",
1728 i + 1,
1729 entry.model,
1730 entry.input_tokens,
1731 entry.output_tokens,
1732 entry.duration_ms,
1733 );
1734 total_input += entry.input_tokens;
1735 total_output += entry.output_tokens;
1736 total_ms += entry.duration_ms;
1737 }
1738 let total_tokens = total_input + total_output;
1739 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1741 let _ = writeln!(
1742 out,
1743 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1744 entries.len(),
1745 if entries.len() == 1 { "" } else { "s" },
1746 total_tokens,
1747 total_input,
1748 total_output,
1749 total_ms,
1750 cost,
1751 );
1752 out
1753}
1754
1755pub(crate) async fn run_file_mcp_serve(
1769 path: &str,
1770 card_source: Option<&str>,
1771 mode: RunFileMcpServeMode,
1772) {
1773 let mut diagnostics = String::new();
1774 let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1775 else {
1776 eprint!("{diagnostics}");
1777 process::exit(1);
1778 };
1779 if !diagnostics.is_empty() {
1780 eprint!("{diagnostics}");
1781 }
1782
1783 let mut vm = harn_vm::Vm::new();
1784 harn_vm::register_vm_stdlib(&mut vm);
1785 crate::install_default_hostlib(&mut vm);
1786 let source_parent = std::path::Path::new(path)
1787 .parent()
1788 .unwrap_or(std::path::Path::new("."));
1789 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1790 let store_base = project_root.as_deref().unwrap_or(source_parent);
1791 harn_vm::register_store_builtins(&mut vm, store_base);
1792 harn_vm::register_metadata_builtins(&mut vm, store_base);
1793 let pipeline_name = std::path::Path::new(path)
1794 .file_stem()
1795 .and_then(|s| s.to_str())
1796 .unwrap_or("default");
1797 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1798 vm.set_source_info(path, &source);
1799 if let Some(ref root) = project_root {
1800 vm.set_project_root(root);
1801 }
1802 if let Some(p) = std::path::Path::new(path).parent() {
1803 if !p.as_os_str().is_empty() {
1804 vm.set_source_dir(p);
1805 }
1806 }
1807
1808 let loaded = load_skills(&SkillLoaderInputs {
1810 cli_dirs: Vec::new(),
1811 source_path: Some(std::path::PathBuf::from(path)),
1812 });
1813 emit_loader_warnings(&loaded.loader_warnings);
1814 install_skills_global(&mut vm, &loaded);
1815
1816 let extensions = package::load_runtime_extensions(Path::new(path));
1817 package::install_runtime_extensions(&extensions);
1818 if let Some(manifest) = extensions.root_manifest.as_ref() {
1819 if !manifest.mcp.is_empty() {
1820 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1821 }
1822 }
1823 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1824 eprintln!("error: failed to install manifest triggers: {error}");
1825 process::exit(1);
1826 }
1827 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1828 eprintln!("error: failed to install manifest hooks: {error}");
1829 process::exit(1);
1830 }
1831
1832 let local = tokio::task::LocalSet::new();
1833 local
1834 .run_until(async {
1835 match vm.execute(&chunk).await {
1836 Ok(_) => {}
1837 Err(e) => {
1838 eprint!("{}", vm.format_runtime_error(&e));
1839 process::exit(1);
1840 }
1841 }
1842
1843 let output = vm.output();
1845 if !output.is_empty() {
1846 eprint!("{output}");
1847 }
1848
1849 let registry = match harn_vm::take_mcp_serve_registry() {
1850 Some(r) => r,
1851 None => {
1852 eprintln!("error: pipeline did not call mcp_serve(registry)");
1853 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1854 process::exit(1);
1855 }
1856 };
1857
1858 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
1859 Ok(t) => t,
1860 Err(e) => {
1861 eprintln!("error: {e}");
1862 process::exit(1);
1863 }
1864 };
1865
1866 let resources = harn_vm::take_mcp_serve_resources();
1867 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1868 let prompts = harn_vm::take_mcp_serve_prompts();
1869
1870 let server_name = std::path::Path::new(path)
1871 .file_stem()
1872 .and_then(|s| s.to_str())
1873 .unwrap_or("harn")
1874 .to_string();
1875
1876 let mut caps = Vec::new();
1877 if !tools.is_empty() {
1878 caps.push(format!(
1879 "{} tool{}",
1880 tools.len(),
1881 if tools.len() == 1 { "" } else { "s" }
1882 ));
1883 }
1884 let total_resources = resources.len() + resource_templates.len();
1885 if total_resources > 0 {
1886 caps.push(format!(
1887 "{total_resources} resource{}",
1888 if total_resources == 1 { "" } else { "s" }
1889 ));
1890 }
1891 if !prompts.is_empty() {
1892 caps.push(format!(
1893 "{} prompt{}",
1894 prompts.len(),
1895 if prompts.len() == 1 { "" } else { "s" }
1896 ));
1897 }
1898 eprintln!(
1899 "[harn] serve mcp: serving {} as '{server_name}'",
1900 caps.join(", ")
1901 );
1902
1903 let mut server =
1904 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1905 if let Some(source) = card_source {
1906 match resolve_card_source(source) {
1907 Ok(card) => server = server.with_server_card(card),
1908 Err(e) => {
1909 eprintln!("error: --card: {e}");
1910 process::exit(1);
1911 }
1912 }
1913 }
1914 match mode {
1915 RunFileMcpServeMode::Stdio => {
1916 if let Err(e) = server.run(&mut vm).await {
1917 eprintln!("error: MCP server error: {e}");
1918 process::exit(1);
1919 }
1920 }
1921 RunFileMcpServeMode::Http {
1922 options,
1923 auth_policy,
1924 } => {
1925 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1926 server,
1927 vm,
1928 options,
1929 auth_policy,
1930 )
1931 .await
1932 {
1933 eprintln!("error: MCP server error: {e}");
1934 process::exit(1);
1935 }
1936 }
1937 }
1938 })
1939 .await;
1940}
1941
1942pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1947 let trimmed = source.trim_start();
1948 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1949 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1950 }
1951 let path = std::path::Path::new(source);
1952 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1953}
1954
1955pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1956 use notify::{Event, EventKind, RecursiveMode, Watcher};
1957
1958 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1959 eprintln!("Error: {e}");
1960 process::exit(1);
1961 });
1962 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1963
1964 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1965 run_file(
1966 path,
1967 false,
1968 denied_builtins.clone(),
1969 Vec::new(),
1970 CliLlmMockMode::Off,
1971 None,
1972 RunProfileOptions::default(),
1973 )
1974 .await;
1975
1976 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1977 let _watcher = {
1978 let tx = tx.clone();
1979 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1980 if let Ok(event) = res {
1981 if matches!(
1982 event.kind,
1983 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1984 ) {
1985 let has_harn = event
1986 .paths
1987 .iter()
1988 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1989 if has_harn {
1990 let _ = tx.blocking_send(());
1991 }
1992 }
1993 }
1994 })
1995 .unwrap_or_else(|e| {
1996 eprintln!("Error setting up file watcher: {e}");
1997 process::exit(1);
1998 });
1999 watcher
2000 .watch(watch_dir, RecursiveMode::Recursive)
2001 .unwrap_or_else(|e| {
2002 eprintln!("Error watching directory: {e}");
2003 process::exit(1);
2004 });
2005 watcher };
2007
2008 eprintln!(
2009 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
2010 watch_dir.display()
2011 );
2012
2013 loop {
2014 rx.recv().await;
2015 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2017 while rx.try_recv().is_ok() {}
2018
2019 eprintln!();
2020 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
2021 run_file(
2022 path,
2023 false,
2024 denied_builtins.clone(),
2025 Vec::new(),
2026 CliLlmMockMode::Off,
2027 None,
2028 RunProfileOptions::default(),
2029 )
2030 .await;
2031 }
2032}
2033
2034#[cfg(test)]
2035mod tests {
2036 use super::harnpack::HarnpackRunOptions;
2037 use super::{
2038 default_run_workspace_root, execute_explain_cost, execute_run,
2039 execute_run_with_harnpack_and_sandbox_options, run_sandbox_attestation, split_eval_header,
2040 CliLlmMockMode, RunProfileOptions, RunSandboxOptions, StdoutPassthroughGuard,
2041 };
2042 use std::collections::HashSet;
2043 use std::path::Path;
2044
2045 #[test]
2046 fn split_eval_header_no_imports_returns_full_body() {
2047 let (header, body) = split_eval_header("log(1 + 2)");
2048 assert_eq!(header, "");
2049 assert_eq!(body, "log(1 + 2)");
2050 }
2051
2052 #[test]
2053 fn split_eval_header_lifts_leading_imports() {
2054 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nlog(x)";
2055 let (header, body) = split_eval_header(code);
2056 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
2057 assert_eq!(body, "log(x)");
2058 }
2059
2060 #[test]
2061 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
2062 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
2063 let (header, body) = split_eval_header(code);
2064 assert_eq!(
2065 header,
2066 "// header comment\npub import { y } from \"./lib\"\n"
2067 );
2068 assert_eq!(body, "foo()");
2069 }
2070
2071 #[test]
2072 fn split_eval_header_does_not_lift_imports_after_other_statements() {
2073 let code = "let a = 1\nimport \"./lib\"";
2074 let (header, body) = split_eval_header(code);
2075 assert_eq!(header, "");
2076 assert_eq!(body, "let a = 1\nimport \"./lib\"");
2077 }
2078
2079 #[test]
2080 fn cli_llm_mock_roundtrips_logprobs() {
2081 let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
2082 "text": "visible",
2083 "logprobs": [{"token": "visible", "logprob": 0.0}]
2084 }))
2085 .expect("parse mock");
2086 assert_eq!(mock.logprobs.len(), 1);
2087
2088 let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
2089 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
2090 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
2091
2092 let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
2093 assert_eq!(reparsed.logprobs.len(), 1);
2094 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
2095 }
2096
2097 #[test]
2098 fn stdout_passthrough_guard_restores_previous_state() {
2099 let original = harn_vm::set_stdout_passthrough(false);
2100 {
2101 let _guard = StdoutPassthroughGuard::enable();
2102 assert!(harn_vm::set_stdout_passthrough(true));
2103 }
2104 assert!(!harn_vm::set_stdout_passthrough(original));
2105 }
2106
2107 #[test]
2108 fn execute_explain_cost_does_not_execute_script() {
2109 let temp = tempfile::TempDir::new().expect("temp dir");
2110 let script = temp.path().join("main.harn");
2111 std::fs::write(
2112 &script,
2113 r#"
2114pipeline main() {
2115 write_file("executed.txt", "bad")
2116 llm_call("hello", nil, {provider: "mock", model: "mock"})
2117}
2118"#,
2119 )
2120 .expect("write script");
2121
2122 let outcome = execute_explain_cost(&script.to_string_lossy());
2123
2124 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2125 assert!(outcome.stdout.contains("LLM cost estimate"));
2126 assert!(
2127 !temp.path().join("executed.txt").exists(),
2128 "--explain-cost must not execute pipeline side effects"
2129 );
2130 }
2131
2132 #[test]
2133 fn default_run_workspace_root_prefers_manifest_root_then_cwd() {
2134 let project = tempfile::TempDir::new().expect("project");
2135 let source_parent = project.path().join("scripts");
2136 let cwd = std::env::current_dir().expect("cwd");
2137
2138 assert_eq!(
2139 default_run_workspace_root(Some(project.path()), &source_parent),
2140 project.path()
2141 );
2142 assert_eq!(default_run_workspace_root(None, Path::new("scripts")), cwd);
2143 }
2144
2145 #[test]
2146 fn run_sandbox_attestation_reports_effective_policy() {
2147 harn_vm::reset_thread_local_state();
2148 let policy = harn_vm::orchestration::CapabilityPolicy {
2149 workspace_roots: vec!["/tmp/workspace".to_string()],
2150 sandbox_profile: harn_vm::orchestration::SandboxProfile::OsHardened,
2151 ..harn_vm::orchestration::CapabilityPolicy::default()
2152 };
2153 harn_vm::orchestration::push_execution_policy(policy);
2154
2155 let metadata = run_sandbox_attestation(&RunSandboxOptions::disabled());
2156
2157 assert_eq!(metadata["run_default_enabled"], false);
2158 assert_eq!(metadata["active"], true);
2159 assert_eq!(metadata["workspace_roots"][0], "/tmp/workspace");
2160 assert_eq!(metadata["profile"], "os_hardened");
2161 assert_eq!(metadata["egress"], "host_policy");
2162 harn_vm::reset_thread_local_state();
2163 }
2164
2165 #[tokio::test]
2166 async fn execute_run_default_sandbox_reports_worktree_profile() {
2167 harn_vm::reset_thread_local_state();
2168 let temp = tempfile::TempDir::new().expect("temp dir");
2169 let script = temp.path().join("main.harn");
2170 std::fs::write(
2171 &script,
2172 r#"
2173pipeline main() {
2174 __io_println(sandbox_active_profile())
2175}
2176"#,
2177 )
2178 .expect("write script");
2179
2180 let outcome = execute_run(
2181 &script.to_string_lossy(),
2182 false,
2183 HashSet::new(),
2184 Vec::new(),
2185 Vec::new(),
2186 CliLlmMockMode::Off,
2187 None,
2188 RunProfileOptions::default(),
2189 )
2190 .await;
2191
2192 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2193 assert_eq!(outcome.stdout.trim(), "worktree");
2194 harn_vm::reset_thread_local_state();
2195 }
2196
2197 #[tokio::test]
2198 async fn execute_run_default_sandbox_blocks_outside_workspace_read() {
2199 harn_vm::reset_thread_local_state();
2200 let temp = tempfile::TempDir::new().expect("temp dir");
2201 let project = temp.path().join("project");
2202 let outside = temp.path().join("outside.txt");
2203 std::fs::create_dir(&project).expect("create project");
2204 std::fs::write(project.join("harn.toml"), "").expect("write manifest");
2205 std::fs::write(&outside, "secret").expect("write outside");
2206 let script = project.join("main.harn");
2207 let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2208 std::fs::write(
2209 &script,
2210 format!(
2211 r#"
2212pipeline main() {{
2213 __io_println(sandbox_active_profile())
2214 let _ = read_file("{}")
2215}}
2216"#,
2217 outside_literal
2218 ),
2219 )
2220 .expect("write script");
2221
2222 let outcome = execute_run(
2223 &script.to_string_lossy(),
2224 false,
2225 HashSet::new(),
2226 Vec::new(),
2227 Vec::new(),
2228 CliLlmMockMode::Off,
2229 None,
2230 RunProfileOptions::default(),
2231 )
2232 .await;
2233
2234 assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2235 assert!(
2236 outcome.stderr.contains("sandbox violation"),
2237 "stderr:\n{}",
2238 outcome.stderr
2239 );
2240 harn_vm::reset_thread_local_state();
2241 }
2242
2243 #[tokio::test]
2244 async fn execute_run_no_sandbox_allows_outside_workspace_read() {
2245 harn_vm::reset_thread_local_state();
2246 let temp = tempfile::TempDir::new().expect("temp dir");
2247 let project = temp.path().join("project");
2248 let outside = temp.path().join("outside.txt");
2249 std::fs::create_dir(&project).expect("create project");
2250 std::fs::write(&outside, "secret").expect("write outside");
2251 let script = project.join("main.harn");
2252 let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2253 std::fs::write(
2254 &script,
2255 format!(
2256 r#"
2257pipeline main() {{
2258 __io_println(sandbox_active_profile())
2259 __io_println(read_file("{}"))
2260}}
2261"#,
2262 outside_literal
2263 ),
2264 )
2265 .expect("write script");
2266
2267 let outcome = execute_run_with_harnpack_and_sandbox_options(
2268 &script.to_string_lossy(),
2269 false,
2270 HashSet::new(),
2271 Vec::new(),
2272 Vec::new(),
2273 CliLlmMockMode::Off,
2274 None,
2275 RunProfileOptions::default(),
2276 RunSandboxOptions::disabled(),
2277 HarnpackRunOptions::default(),
2278 )
2279 .await;
2280
2281 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2282 assert_eq!(outcome.stdout.trim(), "unrestricted\nsecret");
2283 assert!(outcome.stderr.contains("--no-sandbox"));
2284 harn_vm::reset_thread_local_state();
2285 }
2286
2287 #[tokio::test]
2288 async fn execute_run_denies_network_by_default() {
2289 harn_vm::reset_thread_local_state();
2290 let temp = tempfile::TempDir::new().expect("temp dir");
2291 let script = temp.path().join("main.harn");
2292 std::fs::write(
2293 &script,
2294 r#"
2295pipeline main() {
2296 let _ = http_get("https://example.com/")
2297}
2298"#,
2299 )
2300 .expect("write script");
2301
2302 let outcome = execute_run(
2303 &script.to_string_lossy(),
2304 false,
2305 HashSet::new(),
2306 Vec::new(),
2307 Vec::new(),
2308 CliLlmMockMode::Off,
2309 None,
2310 RunProfileOptions::default(),
2311 )
2312 .await;
2313
2314 assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2315 assert!(
2316 outcome.stderr.contains("exceeds network ceiling"),
2317 "stderr:\n{}",
2318 outcome.stderr
2319 );
2320 harn_vm::reset_thread_local_state();
2321 }
2322
2323 #[cfg(feature = "hostlib")]
2324 #[tokio::test]
2325 async fn execute_run_installs_hostlib_gate() {
2326 let temp = tempfile::NamedTempFile::new().expect("temp file");
2327 std::fs::write(
2328 temp.path(),
2329 r#"
2330pipeline main() {
2331 let _ = hostlib_enable("tools:deterministic")
2332 __io_println("enabled")
2333}
2334"#,
2335 )
2336 .expect("write script");
2337
2338 let outcome = execute_run(
2339 &temp.path().to_string_lossy(),
2340 false,
2341 HashSet::new(),
2342 Vec::new(),
2343 Vec::new(),
2344 CliLlmMockMode::Off,
2345 None,
2346 RunProfileOptions::default(),
2347 )
2348 .await;
2349
2350 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2351 assert_eq!(outcome.stdout.trim(), "enabled");
2352 }
2353
2354 #[cfg(all(feature = "hostlib", unix))]
2355 #[tokio::test]
2356 async fn execute_run_can_read_hostlib_command_artifacts() {
2357 let temp = tempfile::NamedTempFile::new().expect("temp file");
2358 std::fs::write(
2359 temp.path(),
2360 r#"
2361pipeline main() {
2362 let _ = hostlib_enable("tools:deterministic")
2363 let result = hostlib_tools_run_command({
2364 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
2365 capture: {max_inline_bytes: 8},
2366 timeout_ms: 5000,
2367 })
2368 __io_println(starts_with(result.command_id, "cmd_"))
2369 __io_println(len(result.stdout))
2370 __io_println(result.byte_count)
2371 let window = hostlib_tools_read_command_output({
2372 command_id: result.command_id,
2373 offset: 1990,
2374 length: 20,
2375 })
2376 __io_println(len(window.content))
2377 __io_println(window.eof)
2378}
2379"#,
2380 )
2381 .expect("write script");
2382
2383 let outcome = execute_run_with_harnpack_and_sandbox_options(
2384 &temp.path().to_string_lossy(),
2385 false,
2386 HashSet::new(),
2387 Vec::new(),
2388 Vec::new(),
2389 CliLlmMockMode::Off,
2390 None,
2391 RunProfileOptions::default(),
2392 RunSandboxOptions::disabled(),
2393 HarnpackRunOptions::default(),
2394 )
2395 .await;
2396
2397 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2398 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
2399 }
2400}