Skip to main content

harn_cli/commands/
run.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
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
20mod explain_cost;
21
22pub(crate) enum RunFileMcpServeMode {
23    Stdio,
24    Http {
25        options: harn_serve::McpHttpServeOptions,
26        auth_policy: harn_serve::AuthPolicy,
27    },
28}
29
30/// Core builtins that are never denied, even when using `--allow`.
31const CORE_BUILTINS: &[&str] = &[
32    "println",
33    "print",
34    "log",
35    "type_of",
36    "to_string",
37    "to_int",
38    "to_float",
39    "len",
40    "assert",
41    "assert_eq",
42    "assert_ne",
43    "json_parse",
44    "json_stringify",
45    "runtime_context",
46    "task_current",
47    "runtime_context_values",
48    "runtime_context_get",
49    "runtime_context_set",
50    "runtime_context_clear",
51];
52
53/// Build the set of denied builtin names from `--deny` or `--allow` flags.
54///
55/// - `--deny a,b,c` denies exactly those names.
56/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
57pub(crate) fn build_denied_builtins(
58    deny_csv: Option<&str>,
59    allow_csv: Option<&str>,
60) -> HashSet<String> {
61    if let Some(csv) = deny_csv {
62        csv.split(',')
63            .map(|s| s.trim().to_string())
64            .filter(|s| !s.is_empty())
65            .collect()
66    } else if let Some(csv) = allow_csv {
67        // With --allow, we mark every registered stdlib builtin as denied
68        // *except* those in the allow list and the core builtins.
69        let allowed: HashSet<String> = csv
70            .split(',')
71            .map(|s| s.trim().to_string())
72            .filter(|s| !s.is_empty())
73            .collect();
74        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
75
76        // Create a temporary VM with stdlib registered to enumerate all builtin names.
77        let mut tmp = harn_vm::Vm::new();
78        harn_vm::register_vm_stdlib(&mut tmp);
79        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
80        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
81
82        tmp.builtin_names()
83            .into_iter()
84            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
85            .collect()
86    } else {
87        HashSet::new()
88    }
89}
90
91/// Run the static type checker against `program` with cross-module
92/// import-aware call resolution when the file's imports all resolve. Used
93/// by `run_file` and the MCP server entry so `harn run` catches undefined
94/// cross-module calls before the VM starts.
95fn typecheck_with_imports(
96    program: &[harn_parser::SNode],
97    path: &Path,
98    source: &str,
99) -> Vec<harn_parser::TypeDiagnostic> {
100    if let Err(error) = package::ensure_dependencies_materialized(path) {
101        eprintln!("error: {error}");
102        process::exit(1);
103    }
104    let graph = harn_modules::build(&[path.to_path_buf()]);
105    let mut checker = harn_parser::TypeChecker::new();
106    if let Some(imported) = graph.imported_names_for_file(path) {
107        checker = checker.with_imported_names(imported);
108    }
109    if let Some(imported) = graph.imported_type_declarations_for_file(path) {
110        checker = checker.with_imported_type_decls(imported);
111    }
112    checker.check_with_source(program, source)
113}
114
115/// Build the wrapped source and temp file backing a `harn run -e` invocation.
116///
117/// `import` is a top-level declaration in Harn, so the leading prefix of
118/// import lines (with surrounding blanks/comments) is hoisted out of the
119/// `pipeline main(task) { ... }` wrapper. The temp file is created in the
120/// current working directory so relative imports (`import "./lib"`) and
121/// `harn.toml` discovery resolve against the user's project, not the
122/// system temp dir. If the CWD is unwritable we fall back to the system
123/// temp dir with a stderr warning — pure-expression `-e` still works,
124/// but relative imports will fail to resolve.
125pub(crate) fn prepare_eval_temp_file(
126    code: &str,
127) -> Result<(String, tempfile::NamedTempFile), String> {
128    let (header, body) = split_eval_header(code);
129    let wrapped = if header.is_empty() {
130        format!("pipeline main(task) {{\n{body}\n}}")
131    } else {
132        format!("{header}\npipeline main(task) {{\n{body}\n}}")
133    };
134
135    let tmp = create_eval_temp_file()?;
136    Ok((wrapped, tmp))
137}
138
139/// Try to place the `-e` temp file in the current working directory so
140/// relative imports and `harn.toml` discovery resolve against the user's
141/// project. Fall back to the system temp dir on failure (with a warning),
142/// so pure-expression `-e` keeps working in read-only contexts.
143fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
144    if let Some(dir) = std::env::current_dir().ok().as_deref() {
145        // Hidden prefix on Unix so editors / tree-walkers are less likely
146        // to pick the file up during its short lifetime.
147        match tempfile::Builder::new()
148            .prefix(".harn-eval-")
149            .suffix(".harn")
150            .tempfile_in(dir)
151        {
152            Ok(tmp) => return Ok(tmp),
153            Err(error) => eprintln!(
154                "warning: harn run -e: could not create temp file in {}: {error}; \
155                 relative imports will not resolve",
156                dir.display()
157            ),
158        }
159    }
160    tempfile::Builder::new()
161        .prefix("harn-eval-")
162        .suffix(".harn")
163        .tempfile()
164        .map_err(|e| format!("failed to create temp file for -e: {e}"))
165}
166
167/// Split the `-e` input into a header (top-level imports + leading
168/// blanks/comments) and a body (everything else, to be wrapped in
169/// `pipeline main(task)`). The header may be empty.
170///
171/// Lines whose first non-whitespace token is `import` or `pub import`
172/// are treated as imports. Scanning stops at the first non-blank,
173/// non-comment, non-import line.
174fn split_eval_header(code: &str) -> (String, String) {
175    let mut header_end = 0usize;
176    let mut last_kept = 0usize;
177    for (idx, line) in code.lines().enumerate() {
178        let trimmed = line.trim_start();
179        if trimmed.is_empty() || trimmed.starts_with("//") {
180            header_end = idx + 1;
181            continue;
182        }
183        let is_import = trimmed.starts_with("import ")
184            || trimmed.starts_with("import\t")
185            || trimmed.starts_with("import\"")
186            || trimmed.starts_with("pub import ")
187            || trimmed.starts_with("pub import\t");
188        if is_import {
189            header_end = idx + 1;
190            last_kept = idx + 1;
191        } else {
192            break;
193        }
194    }
195    if last_kept == 0 {
196        return (String::new(), code.to_string());
197    }
198    let mut header_lines: Vec<&str> = Vec::new();
199    let mut body_lines: Vec<&str> = Vec::new();
200    for (idx, line) in code.lines().enumerate() {
201        if idx < header_end {
202            header_lines.push(line);
203        } else {
204            body_lines.push(line);
205        }
206    }
207    (header_lines.join("\n"), body_lines.join("\n"))
208}
209
210#[derive(Clone, Debug, Default, PartialEq, Eq)]
211pub enum CliLlmMockMode {
212    #[default]
213    Off,
214    Replay {
215        fixture_path: PathBuf,
216    },
217    Record {
218        fixture_path: PathBuf,
219    },
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq)]
223pub struct RunAttestationOptions {
224    pub receipt_out: Option<PathBuf>,
225    pub agent_id: Option<String>,
226}
227
228/// Opt-in profiling. When `text` is true the run prints a categorical
229/// breakdown to stderr after execution; when `json_path` is set the same
230/// rollup is serialized to that path. Either flag enables span tracing
231/// (i.e. `harn_vm::tracing::set_tracing_enabled(true)`).
232#[derive(Clone, Debug, Default, PartialEq, Eq)]
233pub struct RunProfileOptions {
234    pub text: bool,
235    pub json_path: Option<PathBuf>,
236}
237
238impl RunProfileOptions {
239    pub fn is_enabled(&self) -> bool {
240        self.text || self.json_path.is_some()
241    }
242}
243
244/// Captured outcome of an in-process `execute_run` invocation. Tests use this
245/// instead of spawning the `harn` binary; the binary entry point translates
246/// it into real stdout/stderr writes + `process::exit`.
247#[derive(Clone, Debug, Default)]
248pub struct RunOutcome {
249    pub stdout: String,
250    pub stderr: String,
251    pub exit_code: i32,
252}
253
254pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
255    harn_vm::llm::clear_cli_llm_mock_mode();
256    match mode {
257        CliLlmMockMode::Off => Ok(()),
258        CliLlmMockMode::Replay { fixture_path } => {
259            let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
260            harn_vm::llm::install_cli_llm_mocks(mocks);
261            Ok(())
262        }
263        CliLlmMockMode::Record { .. } => {
264            harn_vm::llm::enable_cli_llm_mock_recording();
265            Ok(())
266        }
267    }
268}
269
270pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
271    let CliLlmMockMode::Record { fixture_path } = mode else {
272        return Ok(());
273    };
274    if let Some(parent) = fixture_path.parent() {
275        if !parent.as_os_str().is_empty() {
276            fs::create_dir_all(parent).map_err(|error| {
277                format!(
278                    "failed to create fixture directory {}: {error}",
279                    parent.display()
280                )
281            })?;
282        }
283    }
284
285    let lines = harn_vm::llm::take_cli_llm_recordings()
286        .into_iter()
287        .map(harn_vm::llm::serialize_llm_mock)
288        .collect::<Result<Vec<_>, _>>()?;
289    let body = if lines.is_empty() {
290        String::new()
291    } else {
292        format!("{}\n", lines.join("\n"))
293    };
294    fs::write(fixture_path, body)
295        .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
296}
297
298pub(crate) async fn run_file(
299    path: &str,
300    trace: bool,
301    denied_builtins: HashSet<String>,
302    script_argv: Vec<String>,
303    llm_mock_mode: CliLlmMockMode,
304    attestation: Option<RunAttestationOptions>,
305    profile: RunProfileOptions,
306) {
307    run_file_with_skill_dirs(
308        path,
309        trace,
310        denied_builtins,
311        script_argv,
312        Vec::new(),
313        llm_mock_mode,
314        attestation,
315        profile,
316    )
317    .await;
318}
319
320pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
321    let outcome = execute_explain_cost(path);
322    if !outcome.stderr.is_empty() {
323        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
324    }
325    if !outcome.stdout.is_empty() {
326        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
327    }
328    if outcome.exit_code != 0 {
329        process::exit(outcome.exit_code);
330    }
331}
332
333pub(crate) async fn run_file_with_skill_dirs(
334    path: &str,
335    trace: bool,
336    denied_builtins: HashSet<String>,
337    script_argv: Vec<String>,
338    skill_dirs_raw: Vec<String>,
339    llm_mock_mode: CliLlmMockMode,
340    attestation: Option<RunAttestationOptions>,
341    profile: RunProfileOptions,
342) {
343    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
344    let cancelled = install_signal_shutdown_handler();
345
346    let _stdout_passthrough = StdoutPassthroughGuard::enable();
347    let outcome = execute_run(
348        path,
349        trace,
350        denied_builtins,
351        script_argv,
352        skill_dirs_raw,
353        llm_mock_mode,
354        attestation,
355        profile,
356    )
357    .await;
358
359    // `harn run` streams normal program stdout during execution. Any stdout
360    // left here came from older capture paths, so flush it after diagnostics.
361    if !outcome.stderr.is_empty() {
362        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
363    }
364    if !outcome.stdout.is_empty() {
365        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
366    }
367
368    let mut exit_code = outcome.exit_code;
369    if exit_code != 0 && cancelled.load(Ordering::SeqCst) {
370        exit_code = 124;
371    }
372    if exit_code != 0 {
373        process::exit(exit_code);
374    }
375}
376
377pub fn execute_explain_cost(path: &str) -> RunOutcome {
378    let stdout = String::new();
379    let mut stderr = String::new();
380
381    let (source, program) = parse_source_file(path);
382
383    let mut had_type_error = false;
384    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
385    for diag in &type_diagnostics {
386        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
387        if matches!(diag.severity, DiagnosticSeverity::Error) {
388            had_type_error = true;
389        }
390        stderr.push_str(&rendered);
391    }
392    if had_type_error {
393        return RunOutcome {
394            stdout,
395            stderr,
396            exit_code: 1,
397        };
398    }
399
400    let extensions = package::load_runtime_extensions(Path::new(path));
401    package::install_runtime_extensions(&extensions);
402    RunOutcome {
403        stdout: explain_cost::render_explain_cost(path, &program),
404        stderr,
405        exit_code: 0,
406    }
407}
408
409struct StdoutPassthroughGuard {
410    previous: bool,
411}
412
413impl StdoutPassthroughGuard {
414    fn enable() -> Self {
415        Self {
416            previous: harn_vm::set_stdout_passthrough(true),
417        }
418    }
419}
420
421impl Drop for StdoutPassthroughGuard {
422    fn drop(&mut self) {
423        harn_vm::set_stdout_passthrough(self.previous);
424    }
425}
426
427fn install_signal_shutdown_handler() -> Arc<AtomicBool> {
428    let cancelled = Arc::new(AtomicBool::new(false));
429    let cancelled_clone = cancelled.clone();
430    tokio::spawn(async move {
431        #[cfg(unix)]
432        {
433            use tokio::signal::unix::{signal, SignalKind};
434            let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
435            let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
436            tokio::select! {
437                _ = sigterm.recv() => {},
438                _ = sigint.recv() => {},
439            }
440            cancelled_clone.store(true, Ordering::SeqCst);
441            eprintln!("[harn] signal received, flushing state...");
442            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
443            process::exit(124);
444        }
445        #[cfg(not(unix))]
446        {
447            let _ = tokio::signal::ctrl_c().await;
448            cancelled_clone.store(true, Ordering::SeqCst);
449            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
450            process::exit(124);
451        }
452    });
453    cancelled
454}
455
456/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
457/// stdout, stderr, and what exit code the binary entry would have used,
458/// instead of writing to real stdout/stderr or calling `process::exit`.
459///
460/// Tests should call this directly. The `harn run` binary path wraps it.
461pub async fn execute_run(
462    path: &str,
463    trace: bool,
464    denied_builtins: HashSet<String>,
465    script_argv: Vec<String>,
466    skill_dirs_raw: Vec<String>,
467    llm_mock_mode: CliLlmMockMode,
468    attestation: Option<RunAttestationOptions>,
469    profile: RunProfileOptions,
470) -> RunOutcome {
471    let mut stderr = String::new();
472    let mut stdout = String::new();
473
474    let (source, program) = parse_source_file(path);
475
476    let mut had_type_error = false;
477    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
478    for diag in &type_diagnostics {
479        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
480        if matches!(diag.severity, DiagnosticSeverity::Error) {
481            had_type_error = true;
482        }
483        stderr.push_str(&rendered);
484    }
485    if had_type_error {
486        return RunOutcome {
487            stdout,
488            stderr,
489            exit_code: 1,
490        };
491    }
492
493    let chunk = match harn_vm::Compiler::new().compile(&program) {
494        Ok(c) => c,
495        Err(e) => {
496            stderr.push_str(&format!("error: compile error: {e}\n"));
497            return RunOutcome {
498                stdout,
499                stderr,
500                exit_code: 1,
501            };
502        }
503    };
504
505    if trace {
506        harn_vm::llm::enable_tracing();
507    }
508    if profile.is_enabled() {
509        harn_vm::tracing::set_tracing_enabled(true);
510    }
511    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
512        stderr.push_str(&format!("error: {error}\n"));
513        return RunOutcome {
514            stdout,
515            stderr,
516            exit_code: 1,
517        };
518    }
519
520    let mut vm = harn_vm::Vm::new();
521    harn_vm::register_vm_stdlib(&mut vm);
522    crate::install_default_hostlib(&mut vm);
523    let source_parent = std::path::Path::new(path)
524        .parent()
525        .unwrap_or(std::path::Path::new("."));
526    // Metadata/store rooted at harn.toml when present; source dir otherwise.
527    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
528    let store_base = project_root.as_deref().unwrap_or(source_parent);
529    let attestation_started_at_ms = now_ms();
530    let attestation_log = if attestation.is_some() {
531        Some(harn_vm::event_log::install_memory_for_current_thread(256))
532    } else {
533        None
534    };
535    if let Some(log) = attestation_log.as_ref() {
536        append_run_provenance_event(
537            log,
538            "started",
539            serde_json::json!({
540                "pipeline": path,
541                "argv": &script_argv,
542                "project_root": store_base.display().to_string(),
543            }),
544        )
545        .await;
546    }
547    harn_vm::register_store_builtins(&mut vm, store_base);
548    harn_vm::register_metadata_builtins(&mut vm, store_base);
549    let pipeline_name = std::path::Path::new(path)
550        .file_stem()
551        .and_then(|s| s.to_str())
552        .unwrap_or("default");
553    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
554    vm.set_source_info(path, &source);
555    if !denied_builtins.is_empty() {
556        vm.set_denied_builtins(denied_builtins);
557    }
558    if let Some(ref root) = project_root {
559        vm.set_project_root(root);
560    }
561
562    if let Some(p) = std::path::Path::new(path).parent() {
563        if !p.as_os_str().is_empty() {
564            vm.set_source_dir(p);
565        }
566    }
567
568    // Load filesystem + manifest skills before the pipeline runs so
569    // `skills` is populated with a pre-discovered registry (see #73).
570    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
571    let loaded = load_skills(&SkillLoaderInputs {
572        cli_dirs,
573        source_path: Some(std::path::PathBuf::from(path)),
574    });
575    emit_loader_warnings(&loaded.loader_warnings);
576    install_skills_global(&mut vm, &loaded);
577
578    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
579    // Always set so scripts can rely on `len(argv)`.
580    let argv_values: Vec<harn_vm::VmValue> = script_argv
581        .iter()
582        .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
583        .collect();
584    vm.set_global(
585        "argv",
586        harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
587    );
588
589    let extensions = package::load_runtime_extensions(Path::new(path));
590    package::install_runtime_extensions(&extensions);
591    if let Some(manifest) = extensions.root_manifest.as_ref() {
592        if !manifest.mcp.is_empty() {
593            connect_mcp_servers(&manifest.mcp, &mut vm).await;
594        }
595    }
596    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
597        stderr.push_str(&format!(
598            "error: failed to install manifest triggers: {error}\n"
599        ));
600        return RunOutcome {
601            stdout,
602            stderr,
603            exit_code: 1,
604        };
605    }
606    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
607        stderr.push_str(&format!(
608            "error: failed to install manifest hooks: {error}\n"
609        ));
610        return RunOutcome {
611            stdout,
612            stderr,
613            exit_code: 1,
614        };
615    }
616
617    // Run inside a LocalSet so spawn_local works for concurrency builtins.
618    let local = tokio::task::LocalSet::new();
619    let execution = local
620        .run_until(async {
621            match vm.execute(&chunk).await {
622                Ok(value) => Ok((vm.output(), value)),
623                Err(e) => Err(vm.format_runtime_error(&e)),
624            }
625        })
626        .await;
627    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
628        stderr.push_str(&format!("error: {error}\n"));
629        return RunOutcome {
630            stdout,
631            stderr,
632            exit_code: 1,
633        };
634    }
635
636    // Always drain any captured stderr accumulated during execution.
637    let buffered_stderr = harn_vm::take_stderr_buffer();
638    stderr.push_str(&buffered_stderr);
639
640    let exit_code = match &execution {
641        Ok((_, return_value)) => exit_code_from_return_value(return_value),
642        Err(_) => 1,
643    };
644
645    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
646        if let Err(error) = emit_run_attestation(
647            log,
648            path,
649            store_base,
650            attestation_started_at_ms,
651            exit_code,
652            options,
653            &mut stderr,
654        )
655        .await
656        {
657            stderr.push_str(&format!(
658                "error: failed to emit provenance receipt: {error}\n"
659            ));
660            return RunOutcome {
661                stdout,
662                stderr,
663                exit_code: 1,
664            };
665        }
666        harn_vm::event_log::reset_active_event_log();
667    }
668
669    match execution {
670        Ok((output, return_value)) => {
671            stdout.push_str(output);
672            if trace {
673                stderr.push_str(&render_trace_summary());
674            }
675            if profile.is_enabled() {
676                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
677                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
678                }
679            }
680            if exit_code != 0 {
681                stderr.push_str(&render_return_value_error(&return_value));
682            }
683            RunOutcome {
684                stdout,
685                stderr,
686                exit_code,
687            }
688        }
689        Err(rendered_error) => {
690            stderr.push_str(&rendered_error);
691            if profile.is_enabled() {
692                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
693                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
694                }
695            }
696            RunOutcome {
697                stdout,
698                stderr,
699                exit_code: 1,
700            }
701        }
702    }
703}
704
705fn render_and_persist_profile(
706    options: &RunProfileOptions,
707    stderr: &mut String,
708) -> Result<(), String> {
709    let spans = harn_vm::tracing::peek_spans();
710    let profile = harn_vm::profile::build(&spans);
711    if options.text {
712        stderr.push_str(&harn_vm::profile::render(&profile));
713    }
714    if let Some(path) = options.json_path.as_ref() {
715        if let Some(parent) = path.parent() {
716            if !parent.as_os_str().is_empty() {
717                fs::create_dir_all(parent)
718                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
719            }
720        }
721        let json = serde_json::to_string_pretty(&profile)
722            .map_err(|error| format!("serialize profile: {error}"))?;
723        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
724    }
725    Ok(())
726}
727
728async fn append_run_provenance_event(
729    log: &Arc<harn_vm::event_log::AnyEventLog>,
730    kind: &str,
731    payload: serde_json::Value,
732) {
733    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
734        return;
735    };
736    let _ = log
737        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
738        .await;
739}
740
741async fn emit_run_attestation(
742    log: &Arc<harn_vm::event_log::AnyEventLog>,
743    path: &str,
744    store_base: &Path,
745    started_at_ms: i64,
746    exit_code: i32,
747    options: &RunAttestationOptions,
748    stderr: &mut String,
749) -> Result<(), String> {
750    let finished_at_ms = now_ms();
751    let status = if exit_code == 0 { "success" } else { "failure" };
752    append_run_provenance_event(
753        log,
754        "finished",
755        serde_json::json!({
756            "pipeline": path,
757            "status": status,
758            "exit_code": exit_code,
759        }),
760    )
761    .await;
762    log.flush()
763        .await
764        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
765    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
766        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
767    let (signing_key, key_id) =
768        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
769            .await
770            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
771    let receipt = harn_vm::build_signed_receipt(
772        log,
773        harn_vm::ReceiptBuildOptions {
774            pipeline: path.to_string(),
775            status: status.to_string(),
776            started_at_ms,
777            finished_at_ms,
778            exit_code,
779            producer_name: "harn-cli".to_string(),
780            producer_version: env!("CARGO_PKG_VERSION").to_string(),
781        },
782        &signing_key,
783        key_id,
784    )
785    .await
786    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
787    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
788    if let Some(parent) = receipt_path.parent() {
789        fs::create_dir_all(parent)
790            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
791    }
792    let encoded = serde_json::to_vec_pretty(&receipt)
793        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
794    fs::write(&receipt_path, encoded)
795        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
796    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
797    Ok(())
798}
799
800fn receipt_output_path(
801    store_base: &Path,
802    options: &RunAttestationOptions,
803    receipt_id: &str,
804) -> PathBuf {
805    if let Some(path) = options.receipt_out.as_ref() {
806        return path.clone();
807    }
808    harn_vm::runtime_paths::state_root(store_base)
809        .join("receipts")
810        .join(format!("{receipt_id}.json"))
811}
812
813fn now_ms() -> i64 {
814    std::time::SystemTime::now()
815        .duration_since(std::time::UNIX_EPOCH)
816        .map(|duration| duration.as_millis() as i64)
817        .unwrap_or(0)
818}
819
820/// Map a script's top-level return value to a process exit code.
821///
822/// - `int n`             → exit n (clamped to 0..=255)
823/// - `Result::Ok(_)`     → exit 0
824/// - `Result::Err(_)`    → exit 1
825/// - anything else       → exit 0
826fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
827    use harn_vm::VmValue;
828    match value {
829        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
830        VmValue::EnumVariant {
831            enum_name,
832            variant,
833            fields,
834        } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
835        _ => 0,
836    }
837}
838
839fn render_return_value_error(value: &harn_vm::VmValue) -> String {
840    let harn_vm::VmValue::EnumVariant {
841        enum_name,
842        variant,
843        fields,
844    } = value
845    else {
846        return String::new();
847    };
848    if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
849        return String::new();
850    }
851    let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
852    if rendered.is_empty() {
853        "error\n".to_string()
854    } else if rendered.ends_with('\n') {
855        rendered
856    } else {
857        format!("{rendered}\n")
858    }
859}
860
861/// Connect to MCP servers declared in `harn.toml` and register them as
862/// `mcp.<name>` globals on the VM. Connection failures are warned but do
863/// not abort execution.
864///
865/// Servers with `lazy = true` are registered with the VM-side MCP
866/// registry but NOT booted — their processes start the first time a
867/// skill's `requires_mcp` list names them or user code calls
868/// `mcp_ensure_active("name")` / `mcp_call(mcp.<name>, ...)`.
869pub(crate) async fn connect_mcp_servers(
870    servers: &[package::McpServerConfig],
871    vm: &mut harn_vm::Vm,
872) {
873    use std::collections::BTreeMap;
874    use std::rc::Rc;
875    use std::time::Duration;
876
877    let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
878    let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
879
880    for server in servers {
881        let resolved_auth = match mcp::resolve_auth_for_server(server).await {
882            Ok(resolution) => resolution,
883            Err(error) => {
884                eprintln!(
885                    "warning: mcp: failed to load auth for '{}': {}",
886                    server.name, error
887                );
888                AuthResolution::None
889            }
890        };
891        let spec = serde_json::json!({
892            "name": server.name,
893            "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
894            "command": server.command,
895            "args": server.args,
896            "env": server.env,
897            "url": server.url,
898            "auth_token": match resolved_auth {
899                AuthResolution::Bearer(token) => Some(token),
900                AuthResolution::None => server.auth_token.clone(),
901            },
902            "protocol_version": server.protocol_version,
903            "proxy_server_name": server.proxy_server_name,
904        });
905
906        // Register with the VM-side registry regardless of lazy flag —
907        // skill activation and `mcp_ensure_active` look up specs there.
908        registrations.push(harn_vm::RegisteredMcpServer {
909            name: server.name.clone(),
910            spec: spec.clone(),
911            lazy: server.lazy,
912            card: server.card.clone(),
913            keep_alive: server.keep_alive_ms.map(Duration::from_millis),
914        });
915
916        if server.lazy {
917            eprintln!(
918                "[harn] mcp: deferred '{}' (lazy, boots on first use)",
919                server.name
920            );
921            continue;
922        }
923
924        match harn_vm::connect_mcp_server_from_json(&spec).await {
925            Ok(handle) => {
926                eprintln!("[harn] mcp: connected to '{}'", server.name);
927                harn_vm::mcp_install_active(&server.name, handle.clone());
928                mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
929            }
930            Err(e) => {
931                eprintln!(
932                    "warning: mcp: failed to connect to '{}': {}",
933                    server.name, e
934                );
935            }
936        }
937    }
938
939    // Install registrations AFTER eager connects so `install_active`
940    // above doesn't get overwritten.
941    harn_vm::mcp_register_servers(registrations);
942
943    if !mcp_dict.is_empty() {
944        vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
945    }
946}
947
948fn render_trace_summary() -> String {
949    use std::fmt::Write;
950    let entries = harn_vm::llm::take_trace();
951    if entries.is_empty() {
952        return String::new();
953    }
954    let mut out = String::new();
955    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
956    let mut total_input = 0i64;
957    let mut total_output = 0i64;
958    let mut total_ms = 0u64;
959    for (i, entry) in entries.iter().enumerate() {
960        let _ = writeln!(
961            out,
962            "  #{}: {} | {} in + {} out tokens | {} ms",
963            i + 1,
964            entry.model,
965            entry.input_tokens,
966            entry.output_tokens,
967            entry.duration_ms,
968        );
969        total_input += entry.input_tokens;
970        total_output += entry.output_tokens;
971        total_ms += entry.duration_ms;
972    }
973    let total_tokens = total_input + total_output;
974    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
975    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
976    let _ = writeln!(
977        out,
978        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
979        entries.len(),
980        if entries.len() == 1 { "" } else { "s" },
981        total_tokens,
982        total_input,
983        total_output,
984        total_ms,
985        cost,
986    );
987    out
988}
989
990/// Run a .harn file as an MCP server using the script-driven surface.
991/// The pipeline must call `mcp_tools(registry)` (or the alias
992/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
993/// register additional resources/prompts via `mcp_resource(...)` /
994/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
995///
996/// Dispatched into by `harn serve mcp <file>` when the script does not
997/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
998///
999/// `card_source` — optional `--card` argument. Accepts either a path to
1000/// a JSON file or an inline JSON string. When present, the card is
1001/// embedded in the `initialize` response and exposed as the
1002/// `well-known://mcp-card` resource.
1003pub(crate) async fn run_file_mcp_serve(
1004    path: &str,
1005    card_source: Option<&str>,
1006    mode: RunFileMcpServeMode,
1007) {
1008    let (source, program) = crate::parse_source_file(path);
1009
1010    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
1011    for diag in &type_diagnostics {
1012        match diag.severity {
1013            DiagnosticSeverity::Error => {
1014                let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1015                eprint!("{rendered}");
1016                process::exit(1);
1017            }
1018            DiagnosticSeverity::Warning => {
1019                let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1020                eprint!("{rendered}");
1021            }
1022        }
1023    }
1024
1025    let chunk = match harn_vm::Compiler::new().compile(&program) {
1026        Ok(c) => c,
1027        Err(e) => {
1028            eprintln!("error: compile error: {e}");
1029            process::exit(1);
1030        }
1031    };
1032
1033    let mut vm = harn_vm::Vm::new();
1034    harn_vm::register_vm_stdlib(&mut vm);
1035    crate::install_default_hostlib(&mut vm);
1036    let source_parent = std::path::Path::new(path)
1037        .parent()
1038        .unwrap_or(std::path::Path::new("."));
1039    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1040    let store_base = project_root.as_deref().unwrap_or(source_parent);
1041    harn_vm::register_store_builtins(&mut vm, store_base);
1042    harn_vm::register_metadata_builtins(&mut vm, store_base);
1043    let pipeline_name = std::path::Path::new(path)
1044        .file_stem()
1045        .and_then(|s| s.to_str())
1046        .unwrap_or("default");
1047    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1048    vm.set_source_info(path, &source);
1049    if let Some(ref root) = project_root {
1050        vm.set_project_root(root);
1051    }
1052    if let Some(p) = std::path::Path::new(path).parent() {
1053        if !p.as_os_str().is_empty() {
1054            vm.set_source_dir(p);
1055        }
1056    }
1057
1058    // Same skill discovery as `harn run` — see comment there.
1059    let loaded = load_skills(&SkillLoaderInputs {
1060        cli_dirs: Vec::new(),
1061        source_path: Some(std::path::PathBuf::from(path)),
1062    });
1063    emit_loader_warnings(&loaded.loader_warnings);
1064    install_skills_global(&mut vm, &loaded);
1065
1066    let extensions = package::load_runtime_extensions(Path::new(path));
1067    package::install_runtime_extensions(&extensions);
1068    if let Some(manifest) = extensions.root_manifest.as_ref() {
1069        if !manifest.mcp.is_empty() {
1070            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1071        }
1072    }
1073    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1074        eprintln!("error: failed to install manifest triggers: {error}");
1075        process::exit(1);
1076    }
1077    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1078        eprintln!("error: failed to install manifest hooks: {error}");
1079        process::exit(1);
1080    }
1081
1082    let local = tokio::task::LocalSet::new();
1083    local
1084        .run_until(async {
1085            match vm.execute(&chunk).await {
1086                Ok(_) => {}
1087                Err(e) => {
1088                    eprint!("{}", vm.format_runtime_error(&e));
1089                    process::exit(1);
1090                }
1091            }
1092
1093            // Pipeline output goes to stderr — stdout is the MCP transport.
1094            let output = vm.output();
1095            if !output.is_empty() {
1096                eprint!("{output}");
1097            }
1098
1099            let registry = match harn_vm::take_mcp_serve_registry() {
1100                Some(r) => r,
1101                None => {
1102                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1103                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1104                    process::exit(1);
1105                }
1106            };
1107
1108            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1109                Ok(t) => t,
1110                Err(e) => {
1111                    eprintln!("error: {e}");
1112                    process::exit(1);
1113                }
1114            };
1115
1116            let resources = harn_vm::take_mcp_serve_resources();
1117            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1118            let prompts = harn_vm::take_mcp_serve_prompts();
1119
1120            let server_name = std::path::Path::new(path)
1121                .file_stem()
1122                .and_then(|s| s.to_str())
1123                .unwrap_or("harn")
1124                .to_string();
1125
1126            let mut caps = Vec::new();
1127            if !tools.is_empty() {
1128                caps.push(format!(
1129                    "{} tool{}",
1130                    tools.len(),
1131                    if tools.len() == 1 { "" } else { "s" }
1132                ));
1133            }
1134            let total_resources = resources.len() + resource_templates.len();
1135            if total_resources > 0 {
1136                caps.push(format!(
1137                    "{total_resources} resource{}",
1138                    if total_resources == 1 { "" } else { "s" }
1139                ));
1140            }
1141            if !prompts.is_empty() {
1142                caps.push(format!(
1143                    "{} prompt{}",
1144                    prompts.len(),
1145                    if prompts.len() == 1 { "" } else { "s" }
1146                ));
1147            }
1148            eprintln!(
1149                "[harn] serve mcp: serving {} as '{server_name}'",
1150                caps.join(", ")
1151            );
1152
1153            let mut server =
1154                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1155            if let Some(source) = card_source {
1156                match resolve_card_source(source) {
1157                    Ok(card) => server = server.with_server_card(card),
1158                    Err(e) => {
1159                        eprintln!("error: --card: {e}");
1160                        process::exit(1);
1161                    }
1162                }
1163            }
1164            match mode {
1165                RunFileMcpServeMode::Stdio => {
1166                    if let Err(e) = server.run(&mut vm).await {
1167                        eprintln!("error: MCP server error: {e}");
1168                        process::exit(1);
1169                    }
1170                }
1171                RunFileMcpServeMode::Http {
1172                    options,
1173                    auth_policy,
1174                } => {
1175                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1176                        server,
1177                        vm,
1178                        options,
1179                        auth_policy,
1180                    )
1181                    .await
1182                    {
1183                        eprintln!("error: MCP server error: {e}");
1184                        process::exit(1);
1185                    }
1186                }
1187            }
1188        })
1189        .await;
1190}
1191
1192/// Accept either a path to a JSON file or an inline JSON blob and
1193/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
1194/// by peeking at the first non-whitespace character: `{` → inline JSON,
1195/// anything else → path.
1196pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1197    let trimmed = source.trim_start();
1198    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1199        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1200    }
1201    let path = std::path::Path::new(source);
1202    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1203}
1204
1205pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1206    use notify::{Event, EventKind, RecursiveMode, Watcher};
1207
1208    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1209        eprintln!("Error: {e}");
1210        process::exit(1);
1211    });
1212    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1213
1214    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1215    run_file(
1216        path,
1217        false,
1218        denied_builtins.clone(),
1219        Vec::new(),
1220        CliLlmMockMode::Off,
1221        None,
1222        RunProfileOptions::default(),
1223    )
1224    .await;
1225
1226    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1227    let _watcher = {
1228        let tx = tx.clone();
1229        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1230            if let Ok(event) = res {
1231                if matches!(
1232                    event.kind,
1233                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1234                ) {
1235                    let has_harn = event
1236                        .paths
1237                        .iter()
1238                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1239                    if has_harn {
1240                        let _ = tx.blocking_send(());
1241                    }
1242                }
1243            }
1244        })
1245        .unwrap_or_else(|e| {
1246            eprintln!("Error setting up file watcher: {e}");
1247            process::exit(1);
1248        });
1249        watcher
1250            .watch(watch_dir, RecursiveMode::Recursive)
1251            .unwrap_or_else(|e| {
1252                eprintln!("Error watching directory: {e}");
1253                process::exit(1);
1254            });
1255        watcher // keep alive
1256    };
1257
1258    eprintln!(
1259        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1260        watch_dir.display()
1261    );
1262
1263    loop {
1264        rx.recv().await;
1265        // Debounce: let bursts of events settle for 200ms before re-running.
1266        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1267        while rx.try_recv().is_ok() {}
1268
1269        eprintln!();
1270        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1271        run_file(
1272            path,
1273            false,
1274            denied_builtins.clone(),
1275            Vec::new(),
1276            CliLlmMockMode::Off,
1277            None,
1278            RunProfileOptions::default(),
1279        )
1280        .await;
1281    }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::{
1287        execute_explain_cost, execute_run, split_eval_header, CliLlmMockMode, RunProfileOptions,
1288        StdoutPassthroughGuard,
1289    };
1290    use std::collections::HashSet;
1291
1292    #[test]
1293    fn split_eval_header_no_imports_returns_full_body() {
1294        let (header, body) = split_eval_header("println(1 + 2)");
1295        assert_eq!(header, "");
1296        assert_eq!(body, "println(1 + 2)");
1297    }
1298
1299    #[test]
1300    fn split_eval_header_lifts_leading_imports() {
1301        let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1302        let (header, body) = split_eval_header(code);
1303        assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1304        assert_eq!(body, "println(x)");
1305    }
1306
1307    #[test]
1308    fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1309        let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1310        let (header, body) = split_eval_header(code);
1311        assert_eq!(
1312            header,
1313            "// header comment\npub import { y } from \"./lib\"\n"
1314        );
1315        assert_eq!(body, "foo()");
1316    }
1317
1318    #[test]
1319    fn split_eval_header_does_not_lift_imports_after_other_statements() {
1320        let code = "let a = 1\nimport \"./lib\"";
1321        let (header, body) = split_eval_header(code);
1322        assert_eq!(header, "");
1323        assert_eq!(body, "let a = 1\nimport \"./lib\"");
1324    }
1325
1326    #[test]
1327    fn cli_llm_mock_roundtrips_logprobs() {
1328        let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
1329            "text": "visible",
1330            "logprobs": [{"token": "visible", "logprob": 0.0}]
1331        }))
1332        .expect("parse mock");
1333        assert_eq!(mock.logprobs.len(), 1);
1334
1335        let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
1336        let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1337        assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1338
1339        let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
1340        assert_eq!(reparsed.logprobs.len(), 1);
1341        assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1342    }
1343
1344    #[test]
1345    fn stdout_passthrough_guard_restores_previous_state() {
1346        let original = harn_vm::set_stdout_passthrough(false);
1347        {
1348            let _guard = StdoutPassthroughGuard::enable();
1349            assert!(harn_vm::set_stdout_passthrough(true));
1350        }
1351        assert!(!harn_vm::set_stdout_passthrough(original));
1352    }
1353
1354    #[test]
1355    fn execute_explain_cost_does_not_execute_script() {
1356        let temp = tempfile::TempDir::new().expect("temp dir");
1357        let script = temp.path().join("main.harn");
1358        std::fs::write(
1359            &script,
1360            r#"
1361pipeline main() {
1362  write_file("executed.txt", "bad")
1363  llm_call("hello", nil, {provider: "mock", model: "mock"})
1364}
1365"#,
1366        )
1367        .expect("write script");
1368
1369        let outcome = execute_explain_cost(&script.to_string_lossy());
1370
1371        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1372        assert!(outcome.stdout.contains("LLM cost estimate"));
1373        assert!(
1374            !temp.path().join("executed.txt").exists(),
1375            "--explain-cost must not execute pipeline side effects"
1376        );
1377    }
1378
1379    #[cfg(feature = "hostlib")]
1380    #[tokio::test]
1381    async fn execute_run_installs_hostlib_gate() {
1382        let temp = tempfile::NamedTempFile::new().expect("temp file");
1383        std::fs::write(
1384            temp.path(),
1385            r#"
1386pipeline main() {
1387  let _ = hostlib_enable("tools:deterministic")
1388  println("enabled")
1389}
1390"#,
1391        )
1392        .expect("write script");
1393
1394        let outcome = execute_run(
1395            &temp.path().to_string_lossy(),
1396            false,
1397            HashSet::new(),
1398            Vec::new(),
1399            Vec::new(),
1400            CliLlmMockMode::Off,
1401            None,
1402            RunProfileOptions::default(),
1403        )
1404        .await;
1405
1406        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1407        assert_eq!(outcome.stdout.trim(), "enabled");
1408    }
1409
1410    #[cfg(all(feature = "hostlib", unix))]
1411    #[tokio::test]
1412    async fn execute_run_can_read_hostlib_command_artifacts() {
1413        let temp = tempfile::NamedTempFile::new().expect("temp file");
1414        std::fs::write(
1415            temp.path(),
1416            r#"
1417pipeline main() {
1418  let _ = hostlib_enable("tools:deterministic")
1419  let result = hostlib_tools_run_command({
1420    argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1421    capture: {max_inline_bytes: 8},
1422    timeout_ms: 5000,
1423  })
1424  println(starts_with(result.command_id, "cmd_"))
1425  println(len(result.stdout))
1426  println(result.byte_count)
1427  let window = hostlib_tools_read_command_output({
1428    command_id: result.command_id,
1429    offset: 1990,
1430    length: 20,
1431  })
1432  println(len(window.content))
1433  println(window.eof)
1434}
1435"#,
1436        )
1437        .expect("write script");
1438
1439        let outcome = execute_run(
1440            &temp.path().to_string_lossy(),
1441            false,
1442            HashSet::new(),
1443            Vec::new(),
1444            Vec::new(),
1445            CliLlmMockMode::Off,
1446            None,
1447            RunProfileOptions::default(),
1448        )
1449        .await;
1450
1451        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1452        assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1453    }
1454}