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;
8
9use harn_parser::DiagnosticSeverity;
10use harn_vm::event_log::EventLog;
11
12use crate::commands::mcp::{self, AuthResolution};
13use crate::package;
14use crate::parse_source_file;
15use crate::skill_loader::{
16 canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
17 SkillLoaderInputs,
18};
19
20pub(crate) enum RunFileMcpServeMode {
21 Stdio,
22 Http {
23 options: harn_serve::McpHttpServeOptions,
24 auth_policy: harn_serve::AuthPolicy,
25 },
26}
27
28const CORE_BUILTINS: &[&str] = &[
30 "println",
31 "print",
32 "log",
33 "type_of",
34 "to_string",
35 "to_int",
36 "to_float",
37 "len",
38 "assert",
39 "assert_eq",
40 "assert_ne",
41 "json_parse",
42 "json_stringify",
43 "runtime_context",
44 "task_current",
45 "runtime_context_values",
46 "runtime_context_get",
47 "runtime_context_set",
48 "runtime_context_clear",
49];
50
51pub(crate) fn build_denied_builtins(
56 deny_csv: Option<&str>,
57 allow_csv: Option<&str>,
58) -> HashSet<String> {
59 if let Some(csv) = deny_csv {
60 csv.split(',')
61 .map(|s| s.trim().to_string())
62 .filter(|s| !s.is_empty())
63 .collect()
64 } else if let Some(csv) = allow_csv {
65 let allowed: HashSet<String> = csv
68 .split(',')
69 .map(|s| s.trim().to_string())
70 .filter(|s| !s.is_empty())
71 .collect();
72 let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
73
74 let mut tmp = harn_vm::Vm::new();
76 harn_vm::register_vm_stdlib(&mut tmp);
77 harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
78 harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
79
80 tmp.builtin_names()
81 .into_iter()
82 .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
83 .collect()
84 } else {
85 HashSet::new()
86 }
87}
88
89fn typecheck_with_imports(
94 program: &[harn_parser::SNode],
95 path: &Path,
96 source: &str,
97) -> Vec<harn_parser::TypeDiagnostic> {
98 if let Err(error) = package::ensure_dependencies_materialized(path) {
99 eprintln!("error: {error}");
100 process::exit(1);
101 }
102 let graph = harn_modules::build(&[path.to_path_buf()]);
103 let mut checker = harn_parser::TypeChecker::new();
104 if let Some(imported) = graph.imported_names_for_file(path) {
105 checker = checker.with_imported_names(imported);
106 }
107 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
108 checker = checker.with_imported_type_decls(imported);
109 }
110 checker.check_with_source(program, source)
111}
112
113pub(crate) fn prepare_eval_temp_file(
124 code: &str,
125) -> Result<(String, tempfile::NamedTempFile), String> {
126 let (header, body) = split_eval_header(code);
127 let wrapped = if header.is_empty() {
128 format!("pipeline main(task) {{\n{body}\n}}")
129 } else {
130 format!("{header}\npipeline main(task) {{\n{body}\n}}")
131 };
132
133 let tmp = create_eval_temp_file()?;
134 Ok((wrapped, tmp))
135}
136
137fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
142 if let Some(dir) = std::env::current_dir().ok().as_deref() {
143 match tempfile::Builder::new()
146 .prefix(".harn-eval-")
147 .suffix(".harn")
148 .tempfile_in(dir)
149 {
150 Ok(tmp) => return Ok(tmp),
151 Err(error) => eprintln!(
152 "warning: harn run -e: could not create temp file in {}: {error}; \
153 relative imports will not resolve",
154 dir.display()
155 ),
156 }
157 }
158 tempfile::Builder::new()
159 .prefix("harn-eval-")
160 .suffix(".harn")
161 .tempfile()
162 .map_err(|e| format!("failed to create temp file for -e: {e}"))
163}
164
165fn split_eval_header(code: &str) -> (String, String) {
173 let mut header_end = 0usize;
174 let mut last_kept = 0usize;
175 for (idx, line) in code.lines().enumerate() {
176 let trimmed = line.trim_start();
177 if trimmed.is_empty() || trimmed.starts_with("//") {
178 header_end = idx + 1;
179 continue;
180 }
181 let is_import = trimmed.starts_with("import ")
182 || trimmed.starts_with("import\t")
183 || trimmed.starts_with("import\"")
184 || trimmed.starts_with("pub import ")
185 || trimmed.starts_with("pub import\t");
186 if is_import {
187 header_end = idx + 1;
188 last_kept = idx + 1;
189 } else {
190 break;
191 }
192 }
193 if last_kept == 0 {
194 return (String::new(), code.to_string());
195 }
196 let mut header_lines: Vec<&str> = Vec::new();
197 let mut body_lines: Vec<&str> = Vec::new();
198 for (idx, line) in code.lines().enumerate() {
199 if idx < header_end {
200 header_lines.push(line);
201 } else {
202 body_lines.push(line);
203 }
204 }
205 (header_lines.join("\n"), body_lines.join("\n"))
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Eq)]
209pub enum CliLlmMockMode {
210 #[default]
211 Off,
212 Replay {
213 fixture_path: PathBuf,
214 },
215 Record {
216 fixture_path: PathBuf,
217 },
218}
219
220#[derive(Clone, Debug, Default, PartialEq, Eq)]
221pub struct RunAttestationOptions {
222 pub receipt_out: Option<PathBuf>,
223 pub agent_id: Option<String>,
224}
225
226#[derive(Clone, Debug, Default, PartialEq, Eq)]
231pub struct RunProfileOptions {
232 pub text: bool,
233 pub json_path: Option<PathBuf>,
234}
235
236impl RunProfileOptions {
237 pub fn is_enabled(&self) -> bool {
238 self.text || self.json_path.is_some()
239 }
240}
241
242#[derive(Clone, Debug, Default)]
246pub struct RunOutcome {
247 pub stdout: String,
248 pub stderr: String,
249 pub exit_code: i32,
250}
251
252fn load_cli_llm_mocks(path: &Path) -> Result<Vec<harn_vm::llm::LlmMock>, String> {
253 let content = fs::read_to_string(path)
254 .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
255 let mut mocks = Vec::new();
256 for (idx, raw_line) in content.lines().enumerate() {
257 let line_no = idx + 1;
258 let line = raw_line.trim();
259 if line.is_empty() {
260 continue;
261 }
262 let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
263 format!(
264 "invalid JSON in {} line {}: {error}",
265 path.display(),
266 line_no
267 )
268 })?;
269 mocks.push(parse_cli_llm_mock_value(&value).map_err(|error| {
270 format!(
271 "invalid --llm-mock fixture in {} line {}: {error}",
272 path.display(),
273 line_no
274 )
275 })?);
276 }
277 Ok(mocks)
278}
279
280fn parse_cli_llm_mock_value(value: &serde_json::Value) -> Result<harn_vm::llm::LlmMock, String> {
281 let object = value
282 .as_object()
283 .ok_or_else(|| "fixture line must be a JSON object".to_string())?;
284
285 let match_pattern = optional_string_field(object, "match")?;
286 let consume_on_match = object
287 .get("consume_match")
288 .and_then(|value| value.as_bool())
289 .unwrap_or(false);
290 let text = optional_string_field(object, "text")?.unwrap_or_default();
291 let input_tokens = optional_i64_field(object, "input_tokens")?;
292 let output_tokens = optional_i64_field(object, "output_tokens")?;
293 let cache_read_tokens = optional_i64_field(object, "cache_read_tokens")?;
294 let cache_write_tokens = optional_i64_field(object, "cache_write_tokens")?
295 .or(optional_i64_field(object, "cache_creation_input_tokens")?);
296 let thinking = optional_string_field(object, "thinking")?;
297 let thinking_summary = optional_string_field(object, "thinking_summary")?;
298 let stop_reason = optional_string_field(object, "stop_reason")?;
299 let model = optional_string_field(object, "model")?.unwrap_or_else(|| "mock".to_string());
300 let provider = optional_string_field(object, "provider")?;
301 let blocks = optional_vec_field(object, "blocks")?;
302 let logprobs = optional_vec_field(object, "logprobs")?.unwrap_or_default();
303 let tool_calls = parse_cli_llm_tool_calls(object.get("tool_calls"))?;
304 let error = parse_cli_llm_mock_error(object.get("error"))?;
305
306 Ok(harn_vm::llm::LlmMock {
307 text,
308 tool_calls,
309 match_pattern,
310 consume_on_match,
311 input_tokens,
312 output_tokens,
313 cache_read_tokens,
314 cache_write_tokens,
315 thinking,
316 thinking_summary,
317 stop_reason,
318 model,
319 provider,
320 blocks,
321 logprobs,
322 error,
323 })
324}
325
326fn parse_cli_llm_tool_calls(
327 value: Option<&serde_json::Value>,
328) -> Result<Vec<serde_json::Value>, String> {
329 let Some(value) = value else {
330 return Ok(Vec::new());
331 };
332 let items = value
333 .as_array()
334 .ok_or_else(|| "tool_calls must be an array".to_string())?;
335 items
336 .iter()
337 .enumerate()
338 .map(|(idx, item)| {
339 normalize_cli_llm_tool_call(item).map_err(|error| format!("tool_calls[{idx}] {error}"))
340 })
341 .collect()
342}
343
344fn normalize_cli_llm_tool_call(value: &serde_json::Value) -> Result<serde_json::Value, String> {
345 let object = value
346 .as_object()
347 .ok_or_else(|| "must be a JSON object".to_string())?;
348 let name = object
349 .get("name")
350 .and_then(|value| value.as_str())
351 .ok_or_else(|| "is missing string field `name`".to_string())?;
352 let arguments = object
353 .get("arguments")
354 .cloned()
355 .or_else(|| object.get("args").cloned())
356 .unwrap_or_else(|| serde_json::json!({}));
357 Ok(serde_json::json!({
358 "name": name,
359 "arguments": arguments,
360 }))
361}
362
363fn parse_cli_llm_mock_error(
364 value: Option<&serde_json::Value>,
365) -> Result<Option<harn_vm::llm::MockError>, String> {
366 let Some(value) = value else {
367 return Ok(None);
368 };
369 if value.is_null() {
370 return Ok(None);
371 }
372 let object = value.as_object().ok_or_else(|| {
373 "error must be an object {category, message, retry_after_ms?}".to_string()
374 })?;
375 let category_str = object
376 .get("category")
377 .and_then(|value| value.as_str())
378 .ok_or_else(|| "error.category is required".to_string())?;
379 let category = harn_vm::ErrorCategory::parse(category_str);
380 if category.as_str() != category_str {
381 return Err(format!("unknown error category `{category_str}`"));
382 }
383 let message = object
384 .get("message")
385 .and_then(|value| value.as_str())
386 .unwrap_or_default()
387 .to_string();
388 let retry_after_ms = match object.get("retry_after_ms") {
389 None | Some(serde_json::Value::Null) => None,
390 Some(serde_json::Value::Number(n)) => match n.as_u64() {
391 Some(v) => Some(v),
392 None => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
393 },
394 Some(_) => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
395 };
396 Ok(Some(harn_vm::llm::MockError {
397 category,
398 message,
399 retry_after_ms,
400 }))
401}
402
403fn optional_string_field(
404 object: &serde_json::Map<String, serde_json::Value>,
405 key: &str,
406) -> Result<Option<String>, String> {
407 match object.get(key) {
408 None | Some(serde_json::Value::Null) => Ok(None),
409 Some(serde_json::Value::String(value)) => Ok(Some(value.clone())),
410 Some(_) => Err(format!("`{key}` must be a string")),
411 }
412}
413
414fn optional_i64_field(
415 object: &serde_json::Map<String, serde_json::Value>,
416 key: &str,
417) -> Result<Option<i64>, String> {
418 match object.get(key) {
419 None | Some(serde_json::Value::Null) => Ok(None),
420 Some(value) => value
421 .as_i64()
422 .map(Some)
423 .ok_or_else(|| format!("`{key}` must be an integer")),
424 }
425}
426
427fn optional_vec_field(
428 object: &serde_json::Map<String, serde_json::Value>,
429 key: &str,
430) -> Result<Option<Vec<serde_json::Value>>, String> {
431 match object.get(key) {
432 None | Some(serde_json::Value::Null) => Ok(None),
433 Some(serde_json::Value::Array(items)) => Ok(Some(items.clone())),
434 Some(_) => Err(format!("`{key}` must be an array")),
435 }
436}
437
438pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
439 harn_vm::llm::clear_cli_llm_mock_mode();
440 match mode {
441 CliLlmMockMode::Off => Ok(()),
442 CliLlmMockMode::Replay { fixture_path } => {
443 let mocks = load_cli_llm_mocks(fixture_path)?;
444 harn_vm::llm::install_cli_llm_mocks(mocks);
445 Ok(())
446 }
447 CliLlmMockMode::Record { .. } => {
448 harn_vm::llm::enable_cli_llm_mock_recording();
449 Ok(())
450 }
451 }
452}
453
454pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
455 let CliLlmMockMode::Record { fixture_path } = mode else {
456 return Ok(());
457 };
458 if let Some(parent) = fixture_path.parent() {
459 if !parent.as_os_str().is_empty() {
460 fs::create_dir_all(parent).map_err(|error| {
461 format!(
462 "failed to create fixture directory {}: {error}",
463 parent.display()
464 )
465 })?;
466 }
467 }
468
469 let lines = harn_vm::llm::take_cli_llm_recordings()
470 .into_iter()
471 .map(serialize_cli_llm_mock)
472 .collect::<Result<Vec<_>, _>>()?;
473 let body = if lines.is_empty() {
474 String::new()
475 } else {
476 format!("{}\n", lines.join("\n"))
477 };
478 fs::write(fixture_path, body)
479 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
480}
481
482fn serialize_cli_llm_mock(mock: harn_vm::llm::LlmMock) -> Result<String, String> {
483 let mut object = serde_json::Map::new();
484 if let Some(match_pattern) = mock.match_pattern {
485 object.insert(
486 "match".to_string(),
487 serde_json::Value::String(match_pattern),
488 );
489 }
490 if !mock.text.is_empty() {
491 object.insert("text".to_string(), serde_json::Value::String(mock.text));
492 }
493 if !mock.tool_calls.is_empty() {
494 let tool_calls = mock
495 .tool_calls
496 .into_iter()
497 .map(|tool_call| {
498 let object = tool_call
499 .as_object()
500 .ok_or_else(|| "recorded tool call must be an object".to_string())?;
501 let name = object
502 .get("name")
503 .and_then(|value| value.as_str())
504 .ok_or_else(|| "recorded tool call is missing `name`".to_string())?;
505 Ok(serde_json::json!({
506 "name": name,
507 "args": object
508 .get("arguments")
509 .cloned()
510 .unwrap_or_else(|| serde_json::json!({})),
511 }))
512 })
513 .collect::<Result<Vec<_>, String>>()?;
514 object.insert(
515 "tool_calls".to_string(),
516 serde_json::Value::Array(tool_calls),
517 );
518 }
519 if let Some(input_tokens) = mock.input_tokens {
520 object.insert(
521 "input_tokens".to_string(),
522 serde_json::Value::Number(input_tokens.into()),
523 );
524 }
525 if let Some(output_tokens) = mock.output_tokens {
526 object.insert(
527 "output_tokens".to_string(),
528 serde_json::Value::Number(output_tokens.into()),
529 );
530 }
531 if let Some(cache_read_tokens) = mock.cache_read_tokens {
532 object.insert(
533 "cache_read_tokens".to_string(),
534 serde_json::Value::Number(cache_read_tokens.into()),
535 );
536 }
537 if let Some(cache_write_tokens) = mock.cache_write_tokens {
538 object.insert(
539 "cache_write_tokens".to_string(),
540 serde_json::Value::Number(cache_write_tokens.into()),
541 );
542 object.insert(
543 "cache_creation_input_tokens".to_string(),
544 serde_json::Value::Number(cache_write_tokens.into()),
545 );
546 }
547 if let Some(thinking) = mock.thinking {
548 object.insert("thinking".to_string(), serde_json::Value::String(thinking));
549 }
550 if let Some(stop_reason) = mock.stop_reason {
551 object.insert(
552 "stop_reason".to_string(),
553 serde_json::Value::String(stop_reason),
554 );
555 }
556 object.insert("model".to_string(), serde_json::Value::String(mock.model));
557 if let Some(provider) = mock.provider {
558 object.insert("provider".to_string(), serde_json::Value::String(provider));
559 }
560 if let Some(blocks) = mock.blocks {
561 object.insert("blocks".to_string(), serde_json::Value::Array(blocks));
562 }
563 if !mock.logprobs.is_empty() {
564 object.insert(
565 "logprobs".to_string(),
566 serde_json::Value::Array(mock.logprobs),
567 );
568 }
569 if let Some(error) = mock.error {
570 object.insert(
571 "error".to_string(),
572 serde_json::json!({
573 "category": error.category.as_str(),
574 "message": error.message,
575 }),
576 );
577 }
578 serde_json::to_string(&serde_json::Value::Object(object))
579 .map_err(|error| format!("failed to serialize recorded fixture: {error}"))
580}
581
582pub(crate) async fn run_file(
583 path: &str,
584 trace: bool,
585 denied_builtins: HashSet<String>,
586 script_argv: Vec<String>,
587 llm_mock_mode: CliLlmMockMode,
588 attestation: Option<RunAttestationOptions>,
589 profile: RunProfileOptions,
590) {
591 run_file_with_skill_dirs(
592 path,
593 trace,
594 denied_builtins,
595 script_argv,
596 Vec::new(),
597 llm_mock_mode,
598 attestation,
599 profile,
600 )
601 .await;
602}
603
604pub(crate) async fn run_file_with_skill_dirs(
605 path: &str,
606 trace: bool,
607 denied_builtins: HashSet<String>,
608 script_argv: Vec<String>,
609 skill_dirs_raw: Vec<String>,
610 llm_mock_mode: CliLlmMockMode,
611 attestation: Option<RunAttestationOptions>,
612 profile: RunProfileOptions,
613) {
614 let cancelled = install_signal_shutdown_handler();
616
617 let _stdout_passthrough = StdoutPassthroughGuard::enable();
618 let outcome = execute_run(
619 path,
620 trace,
621 denied_builtins,
622 script_argv,
623 skill_dirs_raw,
624 llm_mock_mode,
625 attestation,
626 profile,
627 )
628 .await;
629
630 if !outcome.stderr.is_empty() {
633 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
634 }
635 if !outcome.stdout.is_empty() {
636 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
637 }
638
639 let mut exit_code = outcome.exit_code;
640 if exit_code != 0 && cancelled.load(Ordering::SeqCst) {
641 exit_code = 124;
642 }
643 if exit_code != 0 {
644 process::exit(exit_code);
645 }
646}
647
648struct StdoutPassthroughGuard {
649 previous: bool,
650}
651
652impl StdoutPassthroughGuard {
653 fn enable() -> Self {
654 Self {
655 previous: harn_vm::set_stdout_passthrough(true),
656 }
657 }
658}
659
660impl Drop for StdoutPassthroughGuard {
661 fn drop(&mut self) {
662 harn_vm::set_stdout_passthrough(self.previous);
663 }
664}
665
666fn install_signal_shutdown_handler() -> Arc<AtomicBool> {
667 let cancelled = Arc::new(AtomicBool::new(false));
668 let cancelled_clone = cancelled.clone();
669 tokio::spawn(async move {
670 #[cfg(unix)]
671 {
672 use tokio::signal::unix::{signal, SignalKind};
673 let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
674 let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
675 tokio::select! {
676 _ = sigterm.recv() => {},
677 _ = sigint.recv() => {},
678 }
679 cancelled_clone.store(true, Ordering::SeqCst);
680 eprintln!("[harn] signal received, flushing state...");
681 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
682 process::exit(124);
683 }
684 #[cfg(not(unix))]
685 {
686 let _ = tokio::signal::ctrl_c().await;
687 cancelled_clone.store(true, Ordering::SeqCst);
688 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
689 process::exit(124);
690 }
691 });
692 cancelled
693}
694
695pub async fn execute_run(
701 path: &str,
702 trace: bool,
703 denied_builtins: HashSet<String>,
704 script_argv: Vec<String>,
705 skill_dirs_raw: Vec<String>,
706 llm_mock_mode: CliLlmMockMode,
707 attestation: Option<RunAttestationOptions>,
708 profile: RunProfileOptions,
709) -> RunOutcome {
710 let mut stderr = String::new();
711 let mut stdout = String::new();
712
713 let (source, program) = parse_source_file(path);
714
715 let mut had_type_error = false;
716 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
717 for diag in &type_diagnostics {
718 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
719 if matches!(diag.severity, DiagnosticSeverity::Error) {
720 had_type_error = true;
721 }
722 stderr.push_str(&rendered);
723 }
724 if had_type_error {
725 return RunOutcome {
726 stdout,
727 stderr,
728 exit_code: 1,
729 };
730 }
731
732 let chunk = match harn_vm::Compiler::new().compile(&program) {
733 Ok(c) => c,
734 Err(e) => {
735 stderr.push_str(&format!("error: compile error: {e}\n"));
736 return RunOutcome {
737 stdout,
738 stderr,
739 exit_code: 1,
740 };
741 }
742 };
743
744 if trace {
745 harn_vm::llm::enable_tracing();
746 }
747 if profile.is_enabled() {
748 harn_vm::tracing::set_tracing_enabled(true);
749 }
750 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
751 stderr.push_str(&format!("error: {error}\n"));
752 return RunOutcome {
753 stdout,
754 stderr,
755 exit_code: 1,
756 };
757 }
758
759 let mut vm = harn_vm::Vm::new();
760 harn_vm::register_vm_stdlib(&mut vm);
761 crate::install_default_hostlib(&mut vm);
762 let source_parent = std::path::Path::new(path)
763 .parent()
764 .unwrap_or(std::path::Path::new("."));
765 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
767 let store_base = project_root.as_deref().unwrap_or(source_parent);
768 let attestation_started_at_ms = now_ms();
769 let attestation_log = if attestation.is_some() {
770 Some(harn_vm::event_log::install_memory_for_current_thread(256))
771 } else {
772 None
773 };
774 if let Some(log) = attestation_log.as_ref() {
775 append_run_provenance_event(
776 log,
777 "started",
778 serde_json::json!({
779 "pipeline": path,
780 "argv": &script_argv,
781 "project_root": store_base.display().to_string(),
782 }),
783 )
784 .await;
785 }
786 harn_vm::register_store_builtins(&mut vm, store_base);
787 harn_vm::register_metadata_builtins(&mut vm, store_base);
788 let pipeline_name = std::path::Path::new(path)
789 .file_stem()
790 .and_then(|s| s.to_str())
791 .unwrap_or("default");
792 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
793 vm.set_source_info(path, &source);
794 if !denied_builtins.is_empty() {
795 vm.set_denied_builtins(denied_builtins);
796 }
797 if let Some(ref root) = project_root {
798 vm.set_project_root(root);
799 }
800
801 if let Some(p) = std::path::Path::new(path).parent() {
802 if !p.as_os_str().is_empty() {
803 vm.set_source_dir(p);
804 }
805 }
806
807 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
810 let loaded = load_skills(&SkillLoaderInputs {
811 cli_dirs,
812 source_path: Some(std::path::PathBuf::from(path)),
813 });
814 emit_loader_warnings(&loaded.loader_warnings);
815 install_skills_global(&mut vm, &loaded);
816
817 let argv_values: Vec<harn_vm::VmValue> = script_argv
820 .iter()
821 .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
822 .collect();
823 vm.set_global(
824 "argv",
825 harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
826 );
827
828 let extensions = package::load_runtime_extensions(Path::new(path));
829 package::install_runtime_extensions(&extensions);
830 if let Some(manifest) = extensions.root_manifest.as_ref() {
831 if !manifest.mcp.is_empty() {
832 connect_mcp_servers(&manifest.mcp, &mut vm).await;
833 }
834 }
835 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
836 stderr.push_str(&format!(
837 "error: failed to install manifest triggers: {error}\n"
838 ));
839 return RunOutcome {
840 stdout,
841 stderr,
842 exit_code: 1,
843 };
844 }
845 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
846 stderr.push_str(&format!(
847 "error: failed to install manifest hooks: {error}\n"
848 ));
849 return RunOutcome {
850 stdout,
851 stderr,
852 exit_code: 1,
853 };
854 }
855
856 let local = tokio::task::LocalSet::new();
858 let execution = local
859 .run_until(async {
860 match vm.execute(&chunk).await {
861 Ok(value) => Ok((vm.output(), value)),
862 Err(e) => Err(vm.format_runtime_error(&e)),
863 }
864 })
865 .await;
866 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
867 stderr.push_str(&format!("error: {error}\n"));
868 return RunOutcome {
869 stdout,
870 stderr,
871 exit_code: 1,
872 };
873 }
874
875 let buffered_stderr = harn_vm::take_stderr_buffer();
877 stderr.push_str(&buffered_stderr);
878
879 let exit_code = match &execution {
880 Ok((_, return_value)) => exit_code_from_return_value(return_value),
881 Err(_) => 1,
882 };
883
884 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
885 if let Err(error) = emit_run_attestation(
886 log,
887 path,
888 store_base,
889 attestation_started_at_ms,
890 exit_code,
891 options,
892 &mut stderr,
893 )
894 .await
895 {
896 stderr.push_str(&format!(
897 "error: failed to emit provenance receipt: {error}\n"
898 ));
899 return RunOutcome {
900 stdout,
901 stderr,
902 exit_code: 1,
903 };
904 }
905 harn_vm::event_log::reset_active_event_log();
906 }
907
908 match execution {
909 Ok((output, return_value)) => {
910 stdout.push_str(output);
911 if trace {
912 stderr.push_str(&render_trace_summary());
913 }
914 if profile.is_enabled() {
915 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
916 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
917 }
918 }
919 if exit_code != 0 {
920 stderr.push_str(&render_return_value_error(&return_value));
921 }
922 RunOutcome {
923 stdout,
924 stderr,
925 exit_code,
926 }
927 }
928 Err(rendered_error) => {
929 stderr.push_str(&rendered_error);
930 if profile.is_enabled() {
931 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
932 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
933 }
934 }
935 RunOutcome {
936 stdout,
937 stderr,
938 exit_code: 1,
939 }
940 }
941 }
942}
943
944fn render_and_persist_profile(
945 options: &RunProfileOptions,
946 stderr: &mut String,
947) -> Result<(), String> {
948 let spans = harn_vm::tracing::peek_spans();
949 let profile = harn_vm::profile::build(&spans);
950 if options.text {
951 stderr.push_str(&harn_vm::profile::render(&profile));
952 }
953 if let Some(path) = options.json_path.as_ref() {
954 if let Some(parent) = path.parent() {
955 if !parent.as_os_str().is_empty() {
956 fs::create_dir_all(parent)
957 .map_err(|error| format!("create {}: {error}", parent.display()))?;
958 }
959 }
960 let json = serde_json::to_string_pretty(&profile)
961 .map_err(|error| format!("serialize profile: {error}"))?;
962 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
963 }
964 Ok(())
965}
966
967async fn append_run_provenance_event(
968 log: &Arc<harn_vm::event_log::AnyEventLog>,
969 kind: &str,
970 payload: serde_json::Value,
971) {
972 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
973 return;
974 };
975 let _ = log
976 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
977 .await;
978}
979
980async fn emit_run_attestation(
981 log: &Arc<harn_vm::event_log::AnyEventLog>,
982 path: &str,
983 store_base: &Path,
984 started_at_ms: i64,
985 exit_code: i32,
986 options: &RunAttestationOptions,
987 stderr: &mut String,
988) -> Result<(), String> {
989 let finished_at_ms = now_ms();
990 let status = if exit_code == 0 { "success" } else { "failure" };
991 append_run_provenance_event(
992 log,
993 "finished",
994 serde_json::json!({
995 "pipeline": path,
996 "status": status,
997 "exit_code": exit_code,
998 }),
999 )
1000 .await;
1001 log.flush()
1002 .await
1003 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1004 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1005 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1006 let (signing_key, key_id) =
1007 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1008 .await
1009 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1010 let receipt = harn_vm::build_signed_receipt(
1011 log,
1012 harn_vm::ReceiptBuildOptions {
1013 pipeline: path.to_string(),
1014 status: status.to_string(),
1015 started_at_ms,
1016 finished_at_ms,
1017 exit_code,
1018 producer_name: "harn-cli".to_string(),
1019 producer_version: env!("CARGO_PKG_VERSION").to_string(),
1020 },
1021 &signing_key,
1022 key_id,
1023 )
1024 .await
1025 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1026 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1027 if let Some(parent) = receipt_path.parent() {
1028 fs::create_dir_all(parent)
1029 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1030 }
1031 let encoded = serde_json::to_vec_pretty(&receipt)
1032 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1033 fs::write(&receipt_path, encoded)
1034 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1035 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1036 Ok(())
1037}
1038
1039fn receipt_output_path(
1040 store_base: &Path,
1041 options: &RunAttestationOptions,
1042 receipt_id: &str,
1043) -> PathBuf {
1044 if let Some(path) = options.receipt_out.as_ref() {
1045 return path.clone();
1046 }
1047 harn_vm::runtime_paths::state_root(store_base)
1048 .join("receipts")
1049 .join(format!("{receipt_id}.json"))
1050}
1051
1052fn now_ms() -> i64 {
1053 std::time::SystemTime::now()
1054 .duration_since(std::time::UNIX_EPOCH)
1055 .map(|duration| duration.as_millis() as i64)
1056 .unwrap_or(0)
1057}
1058
1059fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1066 use harn_vm::VmValue;
1067 match value {
1068 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1069 VmValue::EnumVariant {
1070 enum_name,
1071 variant,
1072 fields,
1073 } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
1074 _ => 0,
1075 }
1076}
1077
1078fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1079 let harn_vm::VmValue::EnumVariant {
1080 enum_name,
1081 variant,
1082 fields,
1083 } = value
1084 else {
1085 return String::new();
1086 };
1087 if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
1088 return String::new();
1089 }
1090 let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
1091 if rendered.is_empty() {
1092 "error\n".to_string()
1093 } else if rendered.ends_with('\n') {
1094 rendered
1095 } else {
1096 format!("{rendered}\n")
1097 }
1098}
1099
1100pub(crate) async fn connect_mcp_servers(
1109 servers: &[package::McpServerConfig],
1110 vm: &mut harn_vm::Vm,
1111) {
1112 use std::collections::BTreeMap;
1113 use std::rc::Rc;
1114 use std::time::Duration;
1115
1116 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1117 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1118
1119 for server in servers {
1120 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1121 Ok(resolution) => resolution,
1122 Err(error) => {
1123 eprintln!(
1124 "warning: mcp: failed to load auth for '{}': {}",
1125 server.name, error
1126 );
1127 AuthResolution::None
1128 }
1129 };
1130 let spec = serde_json::json!({
1131 "name": server.name,
1132 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1133 "command": server.command,
1134 "args": server.args,
1135 "env": server.env,
1136 "url": server.url,
1137 "auth_token": match resolved_auth {
1138 AuthResolution::Bearer(token) => Some(token),
1139 AuthResolution::None => server.auth_token.clone(),
1140 },
1141 "protocol_version": server.protocol_version,
1142 "proxy_server_name": server.proxy_server_name,
1143 });
1144
1145 registrations.push(harn_vm::RegisteredMcpServer {
1148 name: server.name.clone(),
1149 spec: spec.clone(),
1150 lazy: server.lazy,
1151 card: server.card.clone(),
1152 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1153 });
1154
1155 if server.lazy {
1156 eprintln!(
1157 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1158 server.name
1159 );
1160 continue;
1161 }
1162
1163 match harn_vm::connect_mcp_server_from_json(&spec).await {
1164 Ok(handle) => {
1165 eprintln!("[harn] mcp: connected to '{}'", server.name);
1166 harn_vm::mcp_install_active(&server.name, handle.clone());
1167 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
1168 }
1169 Err(e) => {
1170 eprintln!(
1171 "warning: mcp: failed to connect to '{}': {}",
1172 server.name, e
1173 );
1174 }
1175 }
1176 }
1177
1178 harn_vm::mcp_register_servers(registrations);
1181
1182 if !mcp_dict.is_empty() {
1183 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1184 }
1185}
1186
1187fn render_trace_summary() -> String {
1188 use std::fmt::Write;
1189 let entries = harn_vm::llm::take_trace();
1190 if entries.is_empty() {
1191 return String::new();
1192 }
1193 let mut out = String::new();
1194 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1195 let mut total_input = 0i64;
1196 let mut total_output = 0i64;
1197 let mut total_ms = 0u64;
1198 for (i, entry) in entries.iter().enumerate() {
1199 let _ = writeln!(
1200 out,
1201 " #{}: {} | {} in + {} out tokens | {} ms",
1202 i + 1,
1203 entry.model,
1204 entry.input_tokens,
1205 entry.output_tokens,
1206 entry.duration_ms,
1207 );
1208 total_input += entry.input_tokens;
1209 total_output += entry.output_tokens;
1210 total_ms += entry.duration_ms;
1211 }
1212 let total_tokens = total_input + total_output;
1213 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1215 let _ = writeln!(
1216 out,
1217 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1218 entries.len(),
1219 if entries.len() == 1 { "" } else { "s" },
1220 total_tokens,
1221 total_input,
1222 total_output,
1223 total_ms,
1224 cost,
1225 );
1226 out
1227}
1228
1229pub(crate) async fn run_file_mcp_serve(
1243 path: &str,
1244 card_source: Option<&str>,
1245 mode: RunFileMcpServeMode,
1246) {
1247 let (source, program) = crate::parse_source_file(path);
1248
1249 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
1250 for diag in &type_diagnostics {
1251 match diag.severity {
1252 DiagnosticSeverity::Error => {
1253 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1254 eprint!("{rendered}");
1255 process::exit(1);
1256 }
1257 DiagnosticSeverity::Warning => {
1258 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1259 eprint!("{rendered}");
1260 }
1261 }
1262 }
1263
1264 let chunk = match harn_vm::Compiler::new().compile(&program) {
1265 Ok(c) => c,
1266 Err(e) => {
1267 eprintln!("error: compile error: {e}");
1268 process::exit(1);
1269 }
1270 };
1271
1272 let mut vm = harn_vm::Vm::new();
1273 harn_vm::register_vm_stdlib(&mut vm);
1274 crate::install_default_hostlib(&mut vm);
1275 let source_parent = std::path::Path::new(path)
1276 .parent()
1277 .unwrap_or(std::path::Path::new("."));
1278 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1279 let store_base = project_root.as_deref().unwrap_or(source_parent);
1280 harn_vm::register_store_builtins(&mut vm, store_base);
1281 harn_vm::register_metadata_builtins(&mut vm, store_base);
1282 let pipeline_name = std::path::Path::new(path)
1283 .file_stem()
1284 .and_then(|s| s.to_str())
1285 .unwrap_or("default");
1286 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1287 vm.set_source_info(path, &source);
1288 if let Some(ref root) = project_root {
1289 vm.set_project_root(root);
1290 }
1291 if let Some(p) = std::path::Path::new(path).parent() {
1292 if !p.as_os_str().is_empty() {
1293 vm.set_source_dir(p);
1294 }
1295 }
1296
1297 let loaded = load_skills(&SkillLoaderInputs {
1299 cli_dirs: Vec::new(),
1300 source_path: Some(std::path::PathBuf::from(path)),
1301 });
1302 emit_loader_warnings(&loaded.loader_warnings);
1303 install_skills_global(&mut vm, &loaded);
1304
1305 let extensions = package::load_runtime_extensions(Path::new(path));
1306 package::install_runtime_extensions(&extensions);
1307 if let Some(manifest) = extensions.root_manifest.as_ref() {
1308 if !manifest.mcp.is_empty() {
1309 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1310 }
1311 }
1312 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1313 eprintln!("error: failed to install manifest triggers: {error}");
1314 process::exit(1);
1315 }
1316 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1317 eprintln!("error: failed to install manifest hooks: {error}");
1318 process::exit(1);
1319 }
1320
1321 let local = tokio::task::LocalSet::new();
1322 local
1323 .run_until(async {
1324 match vm.execute(&chunk).await {
1325 Ok(_) => {}
1326 Err(e) => {
1327 eprint!("{}", vm.format_runtime_error(&e));
1328 process::exit(1);
1329 }
1330 }
1331
1332 let output = vm.output();
1334 if !output.is_empty() {
1335 eprint!("{output}");
1336 }
1337
1338 let registry = match harn_vm::take_mcp_serve_registry() {
1339 Some(r) => r,
1340 None => {
1341 eprintln!("error: pipeline did not call mcp_serve(registry)");
1342 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1343 process::exit(1);
1344 }
1345 };
1346
1347 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
1348 Ok(t) => t,
1349 Err(e) => {
1350 eprintln!("error: {e}");
1351 process::exit(1);
1352 }
1353 };
1354
1355 let resources = harn_vm::take_mcp_serve_resources();
1356 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1357 let prompts = harn_vm::take_mcp_serve_prompts();
1358
1359 let server_name = std::path::Path::new(path)
1360 .file_stem()
1361 .and_then(|s| s.to_str())
1362 .unwrap_or("harn")
1363 .to_string();
1364
1365 let mut caps = Vec::new();
1366 if !tools.is_empty() {
1367 caps.push(format!(
1368 "{} tool{}",
1369 tools.len(),
1370 if tools.len() == 1 { "" } else { "s" }
1371 ));
1372 }
1373 let total_resources = resources.len() + resource_templates.len();
1374 if total_resources > 0 {
1375 caps.push(format!(
1376 "{total_resources} resource{}",
1377 if total_resources == 1 { "" } else { "s" }
1378 ));
1379 }
1380 if !prompts.is_empty() {
1381 caps.push(format!(
1382 "{} prompt{}",
1383 prompts.len(),
1384 if prompts.len() == 1 { "" } else { "s" }
1385 ));
1386 }
1387 eprintln!(
1388 "[harn] serve mcp: serving {} as '{server_name}'",
1389 caps.join(", ")
1390 );
1391
1392 let mut server =
1393 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1394 if let Some(source) = card_source {
1395 match resolve_card_source(source) {
1396 Ok(card) => server = server.with_server_card(card),
1397 Err(e) => {
1398 eprintln!("error: --card: {e}");
1399 process::exit(1);
1400 }
1401 }
1402 }
1403 match mode {
1404 RunFileMcpServeMode::Stdio => {
1405 if let Err(e) = server.run(&mut vm).await {
1406 eprintln!("error: MCP server error: {e}");
1407 process::exit(1);
1408 }
1409 }
1410 RunFileMcpServeMode::Http {
1411 options,
1412 auth_policy,
1413 } => {
1414 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1415 server,
1416 vm,
1417 options,
1418 auth_policy,
1419 )
1420 .await
1421 {
1422 eprintln!("error: MCP server error: {e}");
1423 process::exit(1);
1424 }
1425 }
1426 }
1427 })
1428 .await;
1429}
1430
1431pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1436 let trimmed = source.trim_start();
1437 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1438 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1439 }
1440 let path = std::path::Path::new(source);
1441 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1442}
1443
1444pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1445 use notify::{Event, EventKind, RecursiveMode, Watcher};
1446
1447 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1448 eprintln!("Error: {e}");
1449 process::exit(1);
1450 });
1451 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1452
1453 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1454 run_file(
1455 path,
1456 false,
1457 denied_builtins.clone(),
1458 Vec::new(),
1459 CliLlmMockMode::Off,
1460 None,
1461 RunProfileOptions::default(),
1462 )
1463 .await;
1464
1465 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1466 let _watcher = {
1467 let tx = tx.clone();
1468 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1469 if let Ok(event) = res {
1470 if matches!(
1471 event.kind,
1472 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1473 ) {
1474 let has_harn = event
1475 .paths
1476 .iter()
1477 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1478 if has_harn {
1479 let _ = tx.blocking_send(());
1480 }
1481 }
1482 }
1483 })
1484 .unwrap_or_else(|e| {
1485 eprintln!("Error setting up file watcher: {e}");
1486 process::exit(1);
1487 });
1488 watcher
1489 .watch(watch_dir, RecursiveMode::Recursive)
1490 .unwrap_or_else(|e| {
1491 eprintln!("Error watching directory: {e}");
1492 process::exit(1);
1493 });
1494 watcher };
1496
1497 eprintln!(
1498 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1499 watch_dir.display()
1500 );
1501
1502 loop {
1503 rx.recv().await;
1504 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1506 while rx.try_recv().is_ok() {}
1507
1508 eprintln!();
1509 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1510 run_file(
1511 path,
1512 false,
1513 denied_builtins.clone(),
1514 Vec::new(),
1515 CliLlmMockMode::Off,
1516 None,
1517 RunProfileOptions::default(),
1518 )
1519 .await;
1520 }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::{
1526 execute_run, parse_cli_llm_mock_value, serialize_cli_llm_mock, split_eval_header,
1527 CliLlmMockMode, RunProfileOptions, StdoutPassthroughGuard,
1528 };
1529 use std::collections::HashSet;
1530
1531 #[test]
1532 fn split_eval_header_no_imports_returns_full_body() {
1533 let (header, body) = split_eval_header("println(1 + 2)");
1534 assert_eq!(header, "");
1535 assert_eq!(body, "println(1 + 2)");
1536 }
1537
1538 #[test]
1539 fn split_eval_header_lifts_leading_imports() {
1540 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1541 let (header, body) = split_eval_header(code);
1542 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1543 assert_eq!(body, "println(x)");
1544 }
1545
1546 #[test]
1547 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1548 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1549 let (header, body) = split_eval_header(code);
1550 assert_eq!(
1551 header,
1552 "// header comment\npub import { y } from \"./lib\"\n"
1553 );
1554 assert_eq!(body, "foo()");
1555 }
1556
1557 #[test]
1558 fn split_eval_header_does_not_lift_imports_after_other_statements() {
1559 let code = "let a = 1\nimport \"./lib\"";
1560 let (header, body) = split_eval_header(code);
1561 assert_eq!(header, "");
1562 assert_eq!(body, "let a = 1\nimport \"./lib\"");
1563 }
1564
1565 #[test]
1566 fn cli_llm_mock_roundtrips_logprobs() {
1567 let mock = parse_cli_llm_mock_value(&serde_json::json!({
1568 "text": "visible",
1569 "logprobs": [{"token": "visible", "logprob": 0.0}]
1570 }))
1571 .expect("parse mock");
1572 assert_eq!(mock.logprobs.len(), 1);
1573
1574 let line = serialize_cli_llm_mock(mock).expect("serialize mock");
1575 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1576 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1577
1578 let reparsed = parse_cli_llm_mock_value(&value).expect("reparse mock");
1579 assert_eq!(reparsed.logprobs.len(), 1);
1580 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1581 }
1582
1583 #[test]
1584 fn stdout_passthrough_guard_restores_previous_state() {
1585 let original = harn_vm::set_stdout_passthrough(false);
1586 {
1587 let _guard = StdoutPassthroughGuard::enable();
1588 assert!(harn_vm::set_stdout_passthrough(true));
1589 }
1590 assert!(!harn_vm::set_stdout_passthrough(original));
1591 }
1592
1593 #[cfg(feature = "hostlib")]
1594 #[tokio::test]
1595 async fn execute_run_installs_hostlib_gate() {
1596 let temp = tempfile::NamedTempFile::new().expect("temp file");
1597 std::fs::write(
1598 temp.path(),
1599 r#"
1600pipeline main() {
1601 let _ = hostlib_enable("tools:deterministic")
1602 println("enabled")
1603}
1604"#,
1605 )
1606 .expect("write script");
1607
1608 let outcome = execute_run(
1609 &temp.path().to_string_lossy(),
1610 false,
1611 HashSet::new(),
1612 Vec::new(),
1613 Vec::new(),
1614 CliLlmMockMode::Off,
1615 None,
1616 RunProfileOptions::default(),
1617 )
1618 .await;
1619
1620 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1621 assert_eq!(outcome.stdout.trim(), "enabled");
1622 }
1623
1624 #[cfg(all(feature = "hostlib", unix))]
1625 #[tokio::test]
1626 async fn execute_run_can_read_hostlib_command_artifacts() {
1627 let temp = tempfile::NamedTempFile::new().expect("temp file");
1628 std::fs::write(
1629 temp.path(),
1630 r#"
1631pipeline main() {
1632 let _ = hostlib_enable("tools:deterministic")
1633 let result = hostlib_tools_run_command({
1634 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1635 capture: {max_inline_bytes: 8},
1636 timeout_ms: 5000,
1637 })
1638 println(starts_with(result.command_id, "cmd_"))
1639 println(len(result.stdout))
1640 println(result.byte_count)
1641 let window = hostlib_tools_read_command_output({
1642 command_id: result.command_id,
1643 offset: 1990,
1644 length: 20,
1645 })
1646 println(len(window.content))
1647 println(window.eof)
1648}
1649"#,
1650 )
1651 .expect("write script");
1652
1653 let outcome = execute_run(
1654 &temp.path().to_string_lossy(),
1655 false,
1656 HashSet::new(),
1657 Vec::new(),
1658 Vec::new(),
1659 CliLlmMockMode::Off,
1660 None,
1661 RunProfileOptions::default(),
1662 )
1663 .await;
1664
1665 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1666 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1667 }
1668}