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, Mutex};
8use std::time::Instant;
9
10use harn_parser::DiagnosticSeverity;
11use harn_vm::event_log::EventLog;
12
13use crate::commands::mcp::{self, AuthResolution};
14use crate::commands::time::RunTiming;
15use crate::package;
16use crate::parse_source_file;
17use crate::skill_loader::{
18    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
19    SkillLoaderInputs,
20};
21
22mod explain_cost;
23pub mod harnpack;
24pub mod json_events;
25
26use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
27use self::json_events::NdjsonEmitter;
28
29/// JSON event-stream configuration for `--json` runs.
30#[derive(Clone, Default)]
31pub struct RunJsonOptions {
32    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
33    /// persona, and the terminal result/error events still flow.
34    pub quiet: bool,
35}
36
37pub(crate) enum RunFileMcpServeMode {
38    Stdio,
39    Http {
40        options: harn_serve::McpHttpServeOptions,
41        auth_policy: harn_serve::AuthPolicy,
42    },
43}
44
45/// Core builtins that are never denied, even when using `--allow`.
46const CORE_BUILTINS: &[&str] = &[
47    "println",
48    "print",
49    "log",
50    "type_of",
51    "to_string",
52    "to_int",
53    "to_float",
54    "len",
55    "assert",
56    "assert_eq",
57    "assert_ne",
58    "json_parse",
59    "json_stringify",
60    "runtime_context",
61    "task_current",
62    "runtime_context_values",
63    "runtime_context_get",
64    "runtime_context_set",
65    "runtime_context_clear",
66];
67
68/// Build the set of denied builtin names from `--deny` or `--allow` flags.
69///
70/// - `--deny a,b,c` denies exactly those names.
71/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
72pub(crate) fn build_denied_builtins(
73    deny_csv: Option<&str>,
74    allow_csv: Option<&str>,
75) -> HashSet<String> {
76    if let Some(csv) = deny_csv {
77        csv.split(',')
78            .map(|s| s.trim().to_string())
79            .filter(|s| !s.is_empty())
80            .collect()
81    } else if let Some(csv) = allow_csv {
82        // With --allow, we mark every registered stdlib builtin as denied
83        // *except* those in the allow list and the core builtins.
84        let allowed: HashSet<String> = csv
85            .split(',')
86            .map(|s| s.trim().to_string())
87            .filter(|s| !s.is_empty())
88            .collect();
89        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
90
91        // Create a temporary VM with stdlib registered to enumerate all builtin names.
92        let mut tmp = harn_vm::Vm::new();
93        harn_vm::register_vm_stdlib(&mut tmp);
94        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
95        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
96
97        tmp.builtin_names()
98            .into_iter()
99            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
100            .collect()
101    } else {
102        HashSet::new()
103    }
104}
105
106/// Result of [`compile_or_load_chunk_for_run`]. Failures propagate as
107/// diagnostic text on the run path so callers map them straight to a
108/// non-zero exit code without bespoke error types.
109pub(crate) struct LoadedChunk {
110    pub(crate) source: String,
111    pub(crate) chunk: harn_vm::Chunk,
112}
113
114/// Load the entry pipeline as a runnable [`harn_vm::Chunk`], using the
115/// content-addressed bytecode cache when its key matches. On a cache miss
116/// we read, parse, type-check, and compile, then persist the chunk.
117/// On a hit we skip parse/typecheck/compile entirely — the cache invariant
118/// is that a stored chunk passed those phases on the writer's harn build,
119/// and the key includes every transitively-imported user file so any
120/// change re-runs the full path.
121///
122/// `stderr` receives any diagnostic output. Returns `None` when a fatal
123/// type or compile error blocks execution; the caller maps that to
124/// exit-code 1.
125pub(crate) fn compile_or_load_chunk_for_run(
126    path: &str,
127    stderr: &mut String,
128) -> Option<LoadedChunk> {
129    compile_or_load_chunk_with_timing(path, stderr, None)
130}
131
132/// Like [`compile_or_load_chunk_for_run`] but lets the caller observe
133/// per-phase wall-clock timings (parse, typecheck, bytecode compile +
134/// cache hit/miss). Used by `harn time run` to drive the same code
135/// path as `harn run` while reporting phase-level timing.
136//
137// The `as_deref_mut` calls reborrow the inner `&mut RunTiming` so each
138// phase can mutate it independently. Clippy's `needless_option_as_deref`
139// is correct that the surface types match — that's exactly the
140// reborrow we want.
141#[allow(clippy::needless_option_as_deref)]
142pub(crate) fn compile_or_load_chunk_with_timing(
143    path: &str,
144    stderr: &mut String,
145    mut timing: Option<&mut RunTiming>,
146) -> Option<LoadedChunk> {
147    let source = match fs::read_to_string(path) {
148        Ok(s) => s,
149        Err(e) => {
150            stderr.push_str(&format!("Error reading {path}: {e}\n"));
151            return None;
152        }
153    };
154    if let Some(t) = timing.as_deref_mut() {
155        t.input_bytes = source.len() as u64;
156    }
157
158    let compile_phase_start = Instant::now();
159    let lookup = harn_vm::bytecode_cache::load(Path::new(path), &source);
160    if let Some(chunk) = lookup.chunk {
161        if let Some(t) = timing.as_deref_mut() {
162            t.cache_hit = true;
163            t.bytecode_compile = compile_phase_start.elapsed();
164        }
165        return Some(LoadedChunk { source, chunk });
166    }
167    if let Some(t) = timing.as_deref_mut() {
168        t.cache_hit = false;
169    }
170
171    let parse_start = Instant::now();
172    let (parsed_source, program) = parse_source_file(path);
173    debug_assert_eq!(parsed_source, source, "parse_source_file re-read drifted");
174    if let Some(t) = timing.as_deref_mut() {
175        t.parse = parse_start.elapsed();
176    }
177
178    let typecheck_start = Instant::now();
179    let mut had_type_error = false;
180    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
181    for diag in &type_diagnostics {
182        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
183        if matches!(diag.severity, DiagnosticSeverity::Error) {
184            had_type_error = true;
185        }
186        stderr.push_str(&rendered);
187    }
188    if let Some(t) = timing.as_deref_mut() {
189        t.typecheck = typecheck_start.elapsed();
190    }
191    if had_type_error {
192        return None;
193    }
194
195    let compile_step_start = Instant::now();
196    let chunk = match harn_vm::Compiler::new().compile(&program) {
197        Ok(c) => c,
198        Err(e) => {
199            stderr.push_str(&format!("error: compile error: {e}\n"));
200            return None;
201        }
202    };
203
204    // Cache misses are best-effort — read-only homedirs, full disks, and
205    // sandboxes are common in CI environments. Surface the failure as a
206    // single-line warning when explicitly requested via the audit hook;
207    // otherwise stay quiet to avoid bloating happy-path output.
208    if let Err(err) = harn_vm::bytecode_cache::store(&lookup.key, &chunk) {
209        if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
210            eprintln!("[harn] bytecode cache write skipped: {err}");
211        }
212    }
213    if let Some(t) = timing.as_deref_mut() {
214        t.bytecode_compile = compile_step_start.elapsed();
215    }
216
217    Some(LoadedChunk { source, chunk })
218}
219
220/// Run the static type checker against `program` with cross-module
221/// import-aware call resolution when the file's imports all resolve. Used
222/// by `run_file` and the MCP server entry so `harn run` catches undefined
223/// cross-module calls before the VM starts.
224fn typecheck_with_imports(
225    program: &[harn_parser::SNode],
226    path: &Path,
227    source: &str,
228) -> Vec<harn_parser::TypeDiagnostic> {
229    if let Err(error) = package::ensure_dependencies_materialized(path) {
230        eprintln!("error: {error}");
231        process::exit(1);
232    }
233    let graph = harn_modules::build(&[path.to_path_buf()]);
234    let mut checker = harn_parser::TypeChecker::new();
235    if let Some(imported) = graph.imported_names_for_file(path) {
236        checker = checker.with_imported_names(imported);
237    }
238    if let Some(imported) = graph.imported_type_declarations_for_file(path) {
239        checker = checker.with_imported_type_decls(imported);
240    }
241    if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
242        checker = checker.with_imported_callable_decls(imported);
243    }
244    checker.check_with_source(program, source)
245}
246
247/// Build the wrapped source and temp file backing a `harn run -e` invocation.
248///
249/// `import` is a top-level declaration in Harn, so the leading prefix of
250/// import lines (with surrounding blanks/comments) is hoisted out of the
251/// `pipeline main(task) { ... }` wrapper. The temp file is created in the
252/// current working directory so relative imports (`import "./lib"`) and
253/// `harn.toml` discovery resolve against the user's project, not the
254/// system temp dir. If the CWD is unwritable we fall back to the system
255/// temp dir with a stderr warning — pure-expression `-e` still works,
256/// but relative imports will fail to resolve.
257pub(crate) fn prepare_eval_temp_file(
258    code: &str,
259) -> Result<(String, tempfile::NamedTempFile), String> {
260    let (header, body) = split_eval_header(code);
261    let wrapped = if header.is_empty() {
262        format!("pipeline main(task) {{\n{body}\n}}")
263    } else {
264        format!("{header}\npipeline main(task) {{\n{body}\n}}")
265    };
266
267    let tmp = create_eval_temp_file()?;
268    Ok((wrapped, tmp))
269}
270
271/// Try to place the `-e` temp file in the current working directory so
272/// relative imports and `harn.toml` discovery resolve against the user's
273/// project. Fall back to the system temp dir on failure (with a warning),
274/// so pure-expression `-e` keeps working in read-only contexts.
275fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
276    if let Some(dir) = std::env::current_dir().ok().as_deref() {
277        // Hidden prefix on Unix so editors / tree-walkers are less likely
278        // to pick the file up during its short lifetime.
279        match tempfile::Builder::new()
280            .prefix(".harn-eval-")
281            .suffix(".harn")
282            .tempfile_in(dir)
283        {
284            Ok(tmp) => return Ok(tmp),
285            Err(error) => eprintln!(
286                "warning: harn run -e: could not create temp file in {}: {error}; \
287                 relative imports will not resolve",
288                dir.display()
289            ),
290        }
291    }
292    tempfile::Builder::new()
293        .prefix("harn-eval-")
294        .suffix(".harn")
295        .tempfile()
296        .map_err(|e| format!("failed to create temp file for -e: {e}"))
297}
298
299/// Split the `-e` input into a header (top-level imports + leading
300/// blanks/comments) and a body (everything else, to be wrapped in
301/// `pipeline main(task)`). The header may be empty.
302///
303/// Lines whose first non-whitespace token is `import` or `pub import`
304/// are treated as imports. Scanning stops at the first non-blank,
305/// non-comment, non-import line.
306fn split_eval_header(code: &str) -> (String, String) {
307    let mut header_end = 0usize;
308    let mut last_kept = 0usize;
309    for (idx, line) in code.lines().enumerate() {
310        let trimmed = line.trim_start();
311        if trimmed.is_empty() || trimmed.starts_with("//") {
312            header_end = idx + 1;
313            continue;
314        }
315        let is_import = trimmed.starts_with("import ")
316            || trimmed.starts_with("import\t")
317            || trimmed.starts_with("import\"")
318            || trimmed.starts_with("pub import ")
319            || trimmed.starts_with("pub import\t");
320        if is_import {
321            header_end = idx + 1;
322            last_kept = idx + 1;
323        } else {
324            break;
325        }
326    }
327    if last_kept == 0 {
328        return (String::new(), code.to_string());
329    }
330    let mut header_lines: Vec<&str> = Vec::new();
331    let mut body_lines: Vec<&str> = Vec::new();
332    for (idx, line) in code.lines().enumerate() {
333        if idx < header_end {
334            header_lines.push(line);
335        } else {
336            body_lines.push(line);
337        }
338    }
339    (header_lines.join("\n"), body_lines.join("\n"))
340}
341
342#[derive(Clone, Debug, Default, PartialEq, Eq)]
343pub enum CliLlmMockMode {
344    #[default]
345    Off,
346    Replay {
347        fixture_path: PathBuf,
348    },
349    Record {
350        fixture_path: PathBuf,
351    },
352}
353
354#[derive(Clone, Debug, Default, PartialEq, Eq)]
355pub struct RunAttestationOptions {
356    pub receipt_out: Option<PathBuf>,
357    pub agent_id: Option<String>,
358}
359
360/// Opt-in profiling. When `text` is true the run prints a categorical
361/// breakdown to stderr after execution; when `json_path` is set the same
362/// rollup is serialized to that path. Either flag enables span tracing
363/// (i.e. `harn_vm::tracing::set_tracing_enabled(true)`).
364#[derive(Clone, Debug, Default, PartialEq, Eq)]
365pub struct RunProfileOptions {
366    pub text: bool,
367    pub json_path: Option<PathBuf>,
368}
369
370impl RunProfileOptions {
371    pub fn is_enabled(&self) -> bool {
372        self.text || self.json_path.is_some()
373    }
374}
375
376#[derive(Clone, Debug, PartialEq, Eq)]
377pub struct RunSandboxOptions {
378    /// Install the default `harn run` sandbox for this invocation.
379    pub enabled: bool,
380    /// Override the workspace root used by the default sandbox. This is
381    /// intended for host-generated scripts whose source file lives outside
382    /// the workspace they operate on.
383    pub workspace_root: Option<PathBuf>,
384}
385
386impl Default for RunSandboxOptions {
387    fn default() -> Self {
388        Self {
389            enabled: true,
390            workspace_root: None,
391        }
392    }
393}
394
395impl RunSandboxOptions {
396    /// Disable the default direct-run sandbox and egress guard.
397    pub fn disabled() -> Self {
398        Self {
399            enabled: false,
400            workspace_root: None,
401        }
402    }
403
404    /// Constrain the default sandbox to an explicit workspace root.
405    pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
406        self.workspace_root = Some(workspace_root.into());
407        self
408    }
409}
410
411#[derive(Clone)]
412pub struct RunInterruptTokens {
413    pub cancel_token: Arc<AtomicBool>,
414    pub signal_token: Arc<Mutex<Option<String>>>,
415}
416
417struct ExecuteRunInputs<'a> {
418    path: &'a str,
419    trace: bool,
420    denied_builtins: HashSet<String>,
421    script_argv: Vec<String>,
422    skill_dirs_raw: Vec<String>,
423    llm_mock_mode: CliLlmMockMode,
424    attestation: Option<RunAttestationOptions>,
425    profile: RunProfileOptions,
426    sandbox: RunSandboxOptions,
427    interrupt_tokens: Option<RunInterruptTokens>,
428    json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
429    timing: Option<&'a mut RunTiming>,
430    harnpack: HarnpackRunOptions,
431}
432
433/// Captured outcome of an in-process `execute_run` invocation. Tests use this
434/// instead of spawning the `harn` binary; the binary entry point translates
435/// it into real stdout/stderr writes + `process::exit`.
436#[derive(Clone, Debug, Default)]
437pub struct RunOutcome {
438    pub stdout: String,
439    pub stderr: String,
440    pub exit_code: i32,
441}
442
443pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
444    harn_vm::llm::clear_cli_llm_mock_mode();
445    match mode {
446        CliLlmMockMode::Off => Ok(()),
447        CliLlmMockMode::Replay { fixture_path } => {
448            let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
449            harn_vm::llm::install_cli_llm_mocks(mocks);
450            Ok(())
451        }
452        CliLlmMockMode::Record { .. } => {
453            harn_vm::llm::enable_cli_llm_mock_recording();
454            Ok(())
455        }
456    }
457}
458
459pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
460    let CliLlmMockMode::Record { fixture_path } = mode else {
461        return Ok(());
462    };
463    if let Some(parent) = fixture_path.parent() {
464        if !parent.as_os_str().is_empty() {
465            fs::create_dir_all(parent).map_err(|error| {
466                format!(
467                    "failed to create fixture directory {}: {error}",
468                    parent.display()
469                )
470            })?;
471        }
472    }
473
474    let lines = harn_vm::llm::take_cli_llm_recordings()
475        .into_iter()
476        .map(harn_vm::llm::serialize_llm_mock)
477        .collect::<Result<Vec<_>, _>>()?;
478    let body = if lines.is_empty() {
479        String::new()
480    } else {
481        format!("{}\n", lines.join("\n"))
482    };
483    fs::write(fixture_path, body)
484        .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
485}
486
487pub(crate) async fn run_file(
488    path: &str,
489    trace: bool,
490    denied_builtins: HashSet<String>,
491    script_argv: Vec<String>,
492    llm_mock_mode: CliLlmMockMode,
493    attestation: Option<RunAttestationOptions>,
494    profile: RunProfileOptions,
495) {
496    run_file_with_skill_dirs(
497        path,
498        trace,
499        denied_builtins,
500        script_argv,
501        Vec::new(),
502        llm_mock_mode,
503        attestation,
504        profile,
505        RunSandboxOptions::default(),
506        None,
507        HarnpackRunOptions::default(),
508    )
509    .await;
510}
511
512pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
513    let outcome = execute_explain_cost(path);
514    if !outcome.stderr.is_empty() {
515        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
516    }
517    if !outcome.stdout.is_empty() {
518        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
519    }
520    if outcome.exit_code != 0 {
521        process::exit(outcome.exit_code);
522    }
523}
524
525#[allow(clippy::too_many_arguments)]
526pub(crate) async fn run_file_with_skill_dirs(
527    path: &str,
528    trace: bool,
529    denied_builtins: HashSet<String>,
530    script_argv: Vec<String>,
531    skill_dirs_raw: Vec<String>,
532    llm_mock_mode: CliLlmMockMode,
533    attestation: Option<RunAttestationOptions>,
534    profile: RunProfileOptions,
535    sandbox: RunSandboxOptions,
536    json: Option<RunJsonOptions>,
537    harnpack: HarnpackRunOptions,
538) {
539    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
540    let interrupt_tokens = install_signal_shutdown_handler();
541
542    let _stdout_passthrough = StdoutPassthroughGuard::enable();
543    let json_with_stdout =
544        json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
545    let outcome = execute_run_inner(ExecuteRunInputs {
546        path,
547        trace,
548        denied_builtins,
549        script_argv,
550        skill_dirs_raw,
551        llm_mock_mode,
552        attestation,
553        profile,
554        sandbox,
555        interrupt_tokens: Some(interrupt_tokens.clone()),
556        json: json_with_stdout,
557        timing: None,
558        harnpack,
559    })
560    .await;
561
562    // `harn run` streams normal program stdout during execution. Any stdout
563    // left here came from older capture paths, so flush it after diagnostics.
564    if !outcome.stderr.is_empty() {
565        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
566    }
567    if !outcome.stdout.is_empty() {
568        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
569    }
570
571    let mut exit_code = outcome.exit_code;
572    if exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst) {
573        exit_code = 124;
574    }
575    if exit_code != 0 {
576        process::exit(exit_code);
577    }
578}
579
580#[allow(clippy::too_many_arguments)]
581pub(crate) async fn run_resume_with_skill_dirs(
582    target: &str,
583    trace: bool,
584    denied_builtins: HashSet<String>,
585    resume_argv: Vec<String>,
586    skill_dirs_raw: Vec<String>,
587    llm_mock_mode: CliLlmMockMode,
588    attestation: Option<RunAttestationOptions>,
589    profile: RunProfileOptions,
590    sandbox: RunSandboxOptions,
591    json: Option<RunJsonOptions>,
592) {
593    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
594
595pipeline main(task) {
596  let input = if len(argv) > 1 {
597    argv[1]
598  } else {
599    nil
600  }
601  let handle = resume_agent(argv[0], input, true)
602  return wait_agent(handle)
603}
604"#;
605    let tmp = create_eval_temp_file().unwrap_or_else(|e| {
606        eprintln!("error: {e}");
607        process::exit(1);
608    });
609    let tmp_path = tmp.path().to_path_buf();
610    if let Err(error) = fs::write(&tmp_path, source) {
611        eprintln!("error: failed to write temp file for --resume: {error}");
612        process::exit(1);
613    }
614    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
615    argv.push(target.to_string());
616    argv.extend(resume_argv);
617    let tmp_str = tmp_path.to_string_lossy().into_owned();
618    run_file_with_skill_dirs(
619        &tmp_str,
620        trace,
621        denied_builtins,
622        argv,
623        skill_dirs_raw,
624        llm_mock_mode,
625        attestation,
626        profile,
627        sandbox,
628        json,
629        HarnpackRunOptions::default(),
630    )
631    .await;
632}
633
634pub fn execute_explain_cost(path: &str) -> RunOutcome {
635    let stdout = String::new();
636    let mut stderr = String::new();
637
638    let (source, program) = parse_source_file(path);
639
640    let mut had_type_error = false;
641    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
642    for diag in &type_diagnostics {
643        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
644        if matches!(diag.severity, DiagnosticSeverity::Error) {
645            had_type_error = true;
646        }
647        stderr.push_str(&rendered);
648    }
649    if had_type_error {
650        return RunOutcome {
651            stdout,
652            stderr,
653            exit_code: 1,
654        };
655    }
656
657    let extensions = package::load_runtime_extensions(Path::new(path));
658    package::install_runtime_extensions(&extensions);
659    RunOutcome {
660        stdout: explain_cost::render_explain_cost(path, &program),
661        stderr,
662        exit_code: 0,
663    }
664}
665
666pub(crate) struct StdoutPassthroughGuard {
667    previous: bool,
668}
669
670impl StdoutPassthroughGuard {
671    pub(crate) fn enable() -> Self {
672        Self {
673            previous: harn_vm::set_stdout_passthrough(true),
674        }
675    }
676}
677
678impl Drop for StdoutPassthroughGuard {
679    fn drop(&mut self) {
680        harn_vm::set_stdout_passthrough(self.previous);
681    }
682}
683
684struct ExecutionPolicyGuard;
685
686impl Drop for ExecutionPolicyGuard {
687    fn drop(&mut self) {
688        harn_vm::orchestration::pop_execution_policy();
689    }
690}
691
692struct RunSandboxScope {
693    _execution_policy: Option<ExecutionPolicyGuard>,
694    _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
695}
696
697impl RunSandboxScope {
698    fn disabled() -> Self {
699        Self {
700            _execution_policy: None,
701            _egress_policy: None,
702        }
703    }
704}
705
706fn install_run_sandbox_scope(
707    options: &RunSandboxOptions,
708    workspace_root: &Path,
709    stderr: &mut String,
710) -> RunSandboxScope {
711    if !options.enabled {
712        stderr.push_str(
713            "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
714        );
715        return RunSandboxScope::disabled();
716    }
717
718    let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
719        harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
720            workspace_root,
721        ));
722        Some(ExecutionPolicyGuard)
723    } else {
724        None
725    };
726    let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
727
728    RunSandboxScope {
729        _execution_policy: execution_policy,
730        _egress_policy: egress_policy,
731    }
732}
733
734fn default_run_capability_policy(
735    workspace_root: &Path,
736) -> harn_vm::orchestration::CapabilityPolicy {
737    harn_vm::orchestration::CapabilityPolicy {
738        workspace_roots: vec![normalize_run_workspace_root(workspace_root)
739            .display()
740            .to_string()],
741        side_effect_level: Some("process_exec".to_string()),
742        sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
743        ..harn_vm::orchestration::CapabilityPolicy::default()
744    }
745}
746
747fn normalize_run_workspace_root(path: &Path) -> PathBuf {
748    if path.is_absolute() {
749        return path.to_path_buf();
750    }
751    std::env::current_dir()
752        .map(|cwd| cwd.join(path))
753        .unwrap_or_else(|_| path.to_path_buf())
754}
755
756fn default_run_workspace_root(project_root: Option<&Path>, source_parent: &Path) -> PathBuf {
757    project_root
758        .map(Path::to_path_buf)
759        .or_else(|| std::env::current_dir().ok())
760        .unwrap_or_else(|| source_parent.to_path_buf())
761}
762
763fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
764    let active_policy = harn_vm::orchestration::current_execution_policy();
765    let active = active_policy.is_some();
766    let workspace_roots = active_policy
767        .as_ref()
768        .map(|policy| policy.workspace_roots.clone())
769        .unwrap_or_default();
770    let profile = active_policy
771        .as_ref()
772        .map(|policy| policy.sandbox_profile.as_str())
773        .unwrap_or("unrestricted");
774    let egress = if sandbox.enabled {
775        "explicit_policy_required"
776    } else if active {
777        "host_policy"
778    } else {
779        "unrestricted"
780    };
781
782    serde_json::json!({
783        "run_default_enabled": sandbox.enabled,
784        "active": active,
785        "workspace_roots": workspace_roots,
786        "profile": profile,
787        "egress": egress,
788    })
789}
790
791// User-facing copy on Ctrl-C. We want the operator to know that a brief
792// pause after the first signal is expected (the VM rewinds the active
793// instruction, drops in-flight async ops like a hanging Ollama request,
794// and unwinds frames before the runtime exits) so they don't reflexively
795// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
796// again to force-exit" hint is load-bearing — earlier runs of harn
797// released to the fleet showed operators routinely double-tapping the
798// shortcut and losing the chance to inspect the error trace.
799const FIRST_SIGNAL_MESSAGE: &str =
800    "[harn] signal received, interrupting VM (give it a moment to unwind in-flight async ops; Ctrl-C again to force-exit)...";
801
802fn install_signal_shutdown_handler() -> RunInterruptTokens {
803    let tokens = RunInterruptTokens {
804        cancel_token: Arc::new(AtomicBool::new(false)),
805        signal_token: Arc::new(Mutex::new(None)),
806    };
807    let tokens_clone = tokens.clone();
808    tokio::spawn(async move {
809        #[cfg(unix)]
810        {
811            use tokio::signal::unix::{signal, SignalKind};
812            let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
813            let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
814            let mut sighup = signal(SignalKind::hangup()).expect("SIGHUP handler");
815            let mut seen_signal = false;
816            loop {
817                let signal_name = tokio::select! {
818                    _ = sigterm.recv() => "SIGTERM",
819                    _ = sigint.recv() => "SIGINT",
820                    _ = sighup.recv() => "SIGHUP",
821                };
822                if seen_signal {
823                    eprintln!("[harn] second signal received, terminating");
824                    process::exit(124);
825                }
826                seen_signal = true;
827                request_vm_interrupt(&tokens_clone, signal_name);
828                eprintln!("{FIRST_SIGNAL_MESSAGE}");
829            }
830        }
831        #[cfg(not(unix))]
832        {
833            let mut seen_signal = false;
834            loop {
835                let _ = tokio::signal::ctrl_c().await;
836                if seen_signal {
837                    eprintln!("[harn] second signal received, terminating");
838                    process::exit(124);
839                }
840                seen_signal = true;
841                request_vm_interrupt(&tokens_clone, "SIGINT");
842                eprintln!("{FIRST_SIGNAL_MESSAGE}");
843            }
844        }
845    });
846    tokens
847}
848
849fn request_vm_interrupt(tokens: &RunInterruptTokens, signal_name: &str) {
850    if let Ok(mut signal) = tokens.signal_token.lock() {
851        *signal = Some(signal_name.to_string());
852    }
853    tokens.cancel_token.store(true, Ordering::SeqCst);
854}
855
856/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
857/// stdout, stderr, and what exit code the binary entry would have used,
858/// instead of writing to real stdout/stderr or calling `process::exit`.
859///
860/// Tests should call this directly. The `harn run` binary path wraps it.
861pub async fn execute_run(
862    path: &str,
863    trace: bool,
864    denied_builtins: HashSet<String>,
865    script_argv: Vec<String>,
866    skill_dirs_raw: Vec<String>,
867    llm_mock_mode: CliLlmMockMode,
868    attestation: Option<RunAttestationOptions>,
869    profile: RunProfileOptions,
870) -> RunOutcome {
871    execute_run_with_harnpack_and_sandbox_options(
872        path,
873        trace,
874        denied_builtins,
875        script_argv,
876        skill_dirs_raw,
877        llm_mock_mode,
878        attestation,
879        profile,
880        RunSandboxOptions::default(),
881        HarnpackRunOptions::default(),
882    )
883    .await
884}
885
886/// [`execute_run`] with an explicit sandbox policy override for in-process
887/// callers whose source path is intentionally outside the workspace they
888/// operate on.
889#[allow(clippy::too_many_arguments)]
890pub async fn execute_run_with_sandbox_options(
891    path: &str,
892    trace: bool,
893    denied_builtins: HashSet<String>,
894    script_argv: Vec<String>,
895    skill_dirs_raw: Vec<String>,
896    llm_mock_mode: CliLlmMockMode,
897    attestation: Option<RunAttestationOptions>,
898    profile: RunProfileOptions,
899    sandbox: RunSandboxOptions,
900) -> RunOutcome {
901    execute_run_with_harnpack_and_sandbox_options(
902        path,
903        trace,
904        denied_builtins,
905        script_argv,
906        skill_dirs_raw,
907        llm_mock_mode,
908        attestation,
909        profile,
910        sandbox,
911        HarnpackRunOptions::default(),
912    )
913    .await
914}
915
916/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
917/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
918/// integration tests and by the binary entry once it has parsed the
919/// `--allow-unsigned` / `--dry-run-verify` flags.
920#[allow(clippy::too_many_arguments)]
921pub async fn execute_run_with_harnpack_options(
922    path: &str,
923    trace: bool,
924    denied_builtins: HashSet<String>,
925    script_argv: Vec<String>,
926    skill_dirs_raw: Vec<String>,
927    llm_mock_mode: CliLlmMockMode,
928    attestation: Option<RunAttestationOptions>,
929    profile: RunProfileOptions,
930    harnpack: HarnpackRunOptions,
931) -> RunOutcome {
932    execute_run_with_harnpack_and_sandbox_options(
933        path,
934        trace,
935        denied_builtins,
936        script_argv,
937        skill_dirs_raw,
938        llm_mock_mode,
939        attestation,
940        profile,
941        RunSandboxOptions::default(),
942        harnpack,
943    )
944    .await
945}
946
947#[allow(clippy::too_many_arguments)]
948async fn execute_run_with_harnpack_and_sandbox_options(
949    path: &str,
950    trace: bool,
951    denied_builtins: HashSet<String>,
952    script_argv: Vec<String>,
953    skill_dirs_raw: Vec<String>,
954    llm_mock_mode: CliLlmMockMode,
955    attestation: Option<RunAttestationOptions>,
956    profile: RunProfileOptions,
957    sandbox: RunSandboxOptions,
958    harnpack: HarnpackRunOptions,
959) -> RunOutcome {
960    execute_run_inner(ExecuteRunInputs {
961        path,
962        trace,
963        denied_builtins,
964        script_argv,
965        skill_dirs_raw,
966        llm_mock_mode,
967        attestation,
968        profile,
969        sandbox,
970        interrupt_tokens: None,
971        json: None,
972        timing: None,
973        harnpack,
974    })
975    .await
976}
977
978/// `execute_run` variant for `--json` mode. Returns once the run is
979/// complete; the NDJSON event stream — including the terminal `result`
980/// or `error` event — has already been written to `out` and flushed.
981/// `out` must be `Send` because the run-event sink may be called from
982/// any worker thread the VM spawns.
983#[allow(clippy::too_many_arguments)]
984pub async fn execute_run_json(
985    path: &str,
986    trace: bool,
987    denied_builtins: HashSet<String>,
988    script_argv: Vec<String>,
989    skill_dirs_raw: Vec<String>,
990    llm_mock_mode: CliLlmMockMode,
991    attestation: Option<RunAttestationOptions>,
992    profile: RunProfileOptions,
993    out: Box<dyn io::Write + Send>,
994    options: RunJsonOptions,
995) -> RunOutcome {
996    execute_run_inner(ExecuteRunInputs {
997        path,
998        trace,
999        denied_builtins,
1000        script_argv,
1001        skill_dirs_raw,
1002        llm_mock_mode,
1003        attestation,
1004        profile,
1005        sandbox: RunSandboxOptions::default(),
1006        interrupt_tokens: None,
1007        json: Some((options, out)),
1008        timing: None,
1009        harnpack: HarnpackRunOptions::default(),
1010    })
1011    .await
1012}
1013
1014/// Run a `.harn` file with the default builtin/argv set and record
1015/// phase timings into `timing`. Used by `harn time run` so the
1016/// instrumented run shares the exact code path as plain `harn run`.
1017pub(crate) async fn execute_run_with_timing(
1018    path: &str,
1019    script_argv: Vec<String>,
1020    timing: Option<&mut RunTiming>,
1021    sandbox: RunSandboxOptions,
1022) -> RunOutcome {
1023    execute_run_inner(ExecuteRunInputs {
1024        path,
1025        trace: false,
1026        denied_builtins: HashSet::new(),
1027        script_argv,
1028        skill_dirs_raw: Vec::new(),
1029        llm_mock_mode: CliLlmMockMode::Off,
1030        attestation: None,
1031        profile: RunProfileOptions::default(),
1032        sandbox,
1033        interrupt_tokens: None,
1034        json: None,
1035        timing,
1036        harnpack: HarnpackRunOptions::default(),
1037    })
1038    .await
1039}
1040
1041// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
1042// the intentional reborrow pattern here.
1043#[allow(clippy::needless_option_as_deref)]
1044async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
1045    let ExecuteRunInputs {
1046        path,
1047        trace,
1048        denied_builtins,
1049        script_argv,
1050        skill_dirs_raw,
1051        llm_mock_mode,
1052        attestation,
1053        profile,
1054        sandbox,
1055        interrupt_tokens,
1056        json,
1057        mut timing,
1058        harnpack,
1059    } = inputs;
1060
1061    // `--json` installs an in-process sink that diverts every
1062    // observable VM event (stdout, stderr, transcript, tool, hook,
1063    // persona) into a single NDJSON stream on `out`. The sink stays
1064    // active until we drop the guard below — fatal errors emit a
1065    // terminal `error` event on the same stream before bailing.
1066    let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
1067
1068    let mut stderr = String::new();
1069    let mut stdout = String::new();
1070
1071    // `.harnpack` preflight: verify signature + replay archive into the
1072    // content-addressed cache before we touch the chunk loader. The
1073    // outcome path (entrypoint inside the unpacked tree) replaces the
1074    // CLI-supplied `path` for everything below.
1075    let owned_run_path: String;
1076    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
1077        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
1078            Ok(prepared) => prepared,
1079            Err(err) => return finalize_harnpack_error(stderr, json_session, err),
1080        };
1081        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
1082            bundle_hash: outcome.bundle_hash.clone(),
1083            signature_verified: outcome.signature_verified,
1084            key_id: outcome.key_id.clone(),
1085            cache_hit: outcome.cache_hit,
1086            dry_run_verify: harnpack.dry_run_verify,
1087        });
1088        if harnpack.dry_run_verify {
1089            return finalize_harnpack_dry_run(stderr, json_session, &outcome);
1090        }
1091        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
1092        owned_run_path.as_str()
1093    } else {
1094        path
1095    };
1096
1097    let Some(LoadedChunk { source, chunk }) =
1098        compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
1099    else {
1100        if let Some(session) = json_session {
1101            return session.finalize_error("compile_error", stderr, 1);
1102        }
1103        return RunOutcome {
1104            stdout,
1105            stderr,
1106            exit_code: 1,
1107        };
1108    };
1109    let path = resolved_path;
1110
1111    // Bracket the VM-setup phase explicitly. `run_setup` covers
1112    // everything between the bytecode compile and the first VM
1113    // instruction; `run_main` covers `vm.execute` proper.
1114    let setup_start = Instant::now();
1115
1116    if trace {
1117        harn_vm::llm::enable_tracing();
1118    }
1119    if profile.is_enabled() {
1120        harn_vm::tracing::set_tracing_enabled(true);
1121    }
1122    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
1123        stderr.push_str(&format!("error: {error}\n"));
1124        if let Some(session) = json_session {
1125            return session.finalize_error("llm_mock_install", error, 1);
1126        }
1127        return RunOutcome {
1128            stdout,
1129            stderr,
1130            exit_code: 1,
1131        };
1132    }
1133
1134    let mut vm = harn_vm::Vm::new();
1135    if let Some(interrupt_tokens) = interrupt_tokens {
1136        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
1137        vm.install_cancel_token(interrupt_tokens.cancel_token);
1138    }
1139    harn_vm::register_vm_stdlib_with_deferred_llm(&mut vm);
1140    crate::install_default_hostlib(&mut vm);
1141    let source_parent = std::path::Path::new(path)
1142        .parent()
1143        .unwrap_or(std::path::Path::new("."));
1144    // Metadata/store rooted at harn.toml when present; source dir otherwise.
1145    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1146    let store_base = project_root.as_deref().unwrap_or(source_parent);
1147    let sandbox_root = sandbox
1148        .workspace_root
1149        .clone()
1150        .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
1151    let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
1152    let attestation_started_at_ms = now_ms();
1153    let attestation_log = if attestation.is_some() {
1154        Some(harn_vm::event_log::install_memory_for_current_thread(256))
1155    } else {
1156        None
1157    };
1158    if let Some(log) = attestation_log.as_ref() {
1159        append_run_provenance_event(
1160            log,
1161            "started",
1162            serde_json::json!({
1163                "pipeline": path,
1164                "argv": &script_argv,
1165                "project_root": store_base.display().to_string(),
1166                "sandbox": run_sandbox_attestation(&sandbox),
1167            }),
1168        )
1169        .await;
1170    }
1171    harn_vm::register_store_builtins(&mut vm, store_base);
1172    harn_vm::register_metadata_builtins(&mut vm, store_base);
1173    let pipeline_name = std::path::Path::new(path)
1174        .file_stem()
1175        .and_then(|s| s.to_str())
1176        .unwrap_or("default");
1177    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1178    vm.set_source_info(path, &source);
1179    if !denied_builtins.is_empty() {
1180        vm.set_denied_builtins(denied_builtins);
1181    }
1182    if let Some(ref root) = project_root {
1183        vm.set_project_root(root);
1184    }
1185
1186    if let Some(p) = std::path::Path::new(path).parent() {
1187        if !p.as_os_str().is_empty() {
1188            vm.set_source_dir(p);
1189        }
1190    }
1191
1192    // Load filesystem + manifest skills before the pipeline runs so
1193    // `skills` is populated with a pre-discovered registry (see #73).
1194    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
1195    let loaded = load_skills(&SkillLoaderInputs {
1196        cli_dirs,
1197        source_path: Some(std::path::PathBuf::from(path)),
1198    });
1199    emit_loader_warnings(&loaded.loader_warnings);
1200    install_skills_global(&mut vm, &loaded);
1201
1202    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
1203    // Always set so scripts can rely on `len(argv)`.
1204    let argv_values: Vec<harn_vm::VmValue> = script_argv
1205        .iter()
1206        .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
1207        .collect();
1208    vm.set_global(
1209        "argv",
1210        harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
1211    );
1212
1213    // Install the script's `Harness` capability handle so the auto-call
1214    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
1215    // entrypoints can read it.
1216    vm.set_harness(harn_vm::Harness::real());
1217
1218    let extensions = package::load_runtime_extensions(Path::new(path));
1219    package::install_runtime_extensions(&extensions);
1220    if let Some(manifest) = extensions.root_manifest.as_ref() {
1221        if !manifest.mcp.is_empty() {
1222            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1223        }
1224    }
1225    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1226        stderr.push_str(&format!(
1227            "error: failed to install manifest triggers: {error}\n"
1228        ));
1229        if let Some(session) = json_session {
1230            return session.finalize_error("manifest_triggers", error.to_string(), 1);
1231        }
1232        return RunOutcome {
1233            stdout,
1234            stderr,
1235            exit_code: 1,
1236        };
1237    }
1238    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1239        stderr.push_str(&format!(
1240            "error: failed to install manifest hooks: {error}\n"
1241        ));
1242        if let Some(session) = json_session {
1243            return session.finalize_error("manifest_hooks", error.to_string(), 1);
1244        }
1245        return RunOutcome {
1246            stdout,
1247            stderr,
1248            exit_code: 1,
1249        };
1250    }
1251
1252    // Run inside a LocalSet so spawn_local works for concurrency builtins.
1253    let local = tokio::task::LocalSet::new();
1254    if let Some(t) = timing.as_deref_mut() {
1255        t.run_setup = setup_start.elapsed();
1256    }
1257    let main_start = Instant::now();
1258    let execution = local
1259        .run_until(async {
1260            match vm.execute(&chunk).await {
1261                Ok(value) => Ok((vm.output(), value)),
1262                Err(e) => Err(vm.format_runtime_error(&e)),
1263            }
1264        })
1265        .await;
1266    if let Some(t) = timing.as_deref_mut() {
1267        t.run_main = main_start.elapsed();
1268    }
1269    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1270        stderr.push_str(&format!("error: {error}\n"));
1271        if let Some(session) = json_session {
1272            return session.finalize_error("llm_mock_record", error, 1);
1273        }
1274        return RunOutcome {
1275            stdout,
1276            stderr,
1277            exit_code: 1,
1278        };
1279    }
1280
1281    // Always drain any captured stderr accumulated during execution.
1282    let buffered_stderr = harn_vm::take_stderr_buffer();
1283    stderr.push_str(&buffered_stderr);
1284
1285    let exit_code = match &execution {
1286        Ok((_, return_value)) => exit_code_from_return_value(return_value),
1287        Err(_) => 1,
1288    };
1289
1290    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1291        if let Err(error) = emit_run_attestation(
1292            log,
1293            path,
1294            store_base,
1295            attestation_started_at_ms,
1296            exit_code,
1297            options,
1298            &mut stderr,
1299        )
1300        .await
1301        {
1302            stderr.push_str(&format!(
1303                "error: failed to emit provenance receipt: {error}\n"
1304            ));
1305            if let Some(session) = json_session {
1306                return session.finalize_error("attestation", error.to_string(), 1);
1307            }
1308            return RunOutcome {
1309                stdout,
1310                stderr,
1311                exit_code: 1,
1312            };
1313        }
1314        harn_vm::event_log::reset_active_event_log();
1315    }
1316
1317    match execution {
1318        Ok((output, return_value)) => {
1319            stdout.push_str(output);
1320            if trace {
1321                stderr.push_str(&render_trace_summary());
1322            }
1323            if profile.is_enabled() {
1324                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1325                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1326                }
1327            }
1328            if exit_code != 0 {
1329                stderr.push_str(&render_return_value_error(&return_value));
1330            }
1331            if let Some(session) = json_session {
1332                let value = harn_vm::llm::vm_value_to_json(&return_value);
1333                return session.finalize_result(value, exit_code);
1334            }
1335            RunOutcome {
1336                stdout,
1337                stderr,
1338                exit_code,
1339            }
1340        }
1341        Err(rendered_error) => {
1342            stderr.push_str(&rendered_error);
1343            if profile.is_enabled() {
1344                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1345                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1346                }
1347            }
1348            if let Some(session) = json_session {
1349                return session.finalize_error("runtime", rendered_error, 1);
1350            }
1351            RunOutcome {
1352                stdout,
1353                stderr,
1354                exit_code: 1,
1355            }
1356        }
1357    }
1358}
1359
1360fn render_and_persist_profile(
1361    options: &RunProfileOptions,
1362    stderr: &mut String,
1363) -> Result<(), String> {
1364    let spans = harn_vm::tracing::peek_spans();
1365    let profile = harn_vm::profile::build(&spans);
1366    if options.text {
1367        stderr.push_str(&harn_vm::profile::render(&profile));
1368    }
1369    if let Some(path) = options.json_path.as_ref() {
1370        if let Some(parent) = path.parent() {
1371            if !parent.as_os_str().is_empty() {
1372                fs::create_dir_all(parent)
1373                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
1374            }
1375        }
1376        let json = serde_json::to_string_pretty(&profile)
1377            .map_err(|error| format!("serialize profile: {error}"))?;
1378        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1379    }
1380    Ok(())
1381}
1382
1383async fn append_run_provenance_event(
1384    log: &Arc<harn_vm::event_log::AnyEventLog>,
1385    kind: &str,
1386    payload: serde_json::Value,
1387) {
1388    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1389        return;
1390    };
1391    let _ = log
1392        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1393        .await;
1394}
1395
1396async fn emit_run_attestation(
1397    log: &Arc<harn_vm::event_log::AnyEventLog>,
1398    path: &str,
1399    store_base: &Path,
1400    started_at_ms: i64,
1401    exit_code: i32,
1402    options: &RunAttestationOptions,
1403    stderr: &mut String,
1404) -> Result<(), String> {
1405    let finished_at_ms = now_ms();
1406    let status = if exit_code == 0 { "success" } else { "failure" };
1407    append_run_provenance_event(
1408        log,
1409        "finished",
1410        serde_json::json!({
1411            "pipeline": path,
1412            "status": status,
1413            "exit_code": exit_code,
1414        }),
1415    )
1416    .await;
1417    log.flush()
1418        .await
1419        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1420    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1421        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1422    let (signing_key, key_id) =
1423        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1424            .await
1425            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1426    let receipt = harn_vm::build_signed_receipt(
1427        log,
1428        harn_vm::ReceiptBuildOptions {
1429            pipeline: path.to_string(),
1430            status: status.to_string(),
1431            started_at_ms,
1432            finished_at_ms,
1433            exit_code,
1434            producer_name: "harn-cli".to_string(),
1435            producer_version: env!("CARGO_PKG_VERSION").to_string(),
1436        },
1437        &signing_key,
1438        key_id,
1439    )
1440    .await
1441    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1442    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1443    if let Some(parent) = receipt_path.parent() {
1444        fs::create_dir_all(parent)
1445            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1446    }
1447    let encoded = serde_json::to_vec_pretty(&receipt)
1448        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1449    fs::write(&receipt_path, encoded)
1450        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1451    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1452    Ok(())
1453}
1454
1455fn receipt_output_path(
1456    store_base: &Path,
1457    options: &RunAttestationOptions,
1458    receipt_id: &str,
1459) -> PathBuf {
1460    if let Some(path) = options.receipt_out.as_ref() {
1461        return path.clone();
1462    }
1463    harn_vm::runtime_paths::state_root(store_base)
1464        .join("receipts")
1465        .join(format!("{receipt_id}.json"))
1466}
1467
1468fn now_ms() -> i64 {
1469    std::time::SystemTime::now()
1470        .duration_since(std::time::UNIX_EPOCH)
1471        .map(|duration| duration.as_millis() as i64)
1472        .unwrap_or(0)
1473}
1474
1475/// Map a script's top-level return value to a process exit code.
1476///
1477/// - `int n`             → exit n (clamped to 0..=255)
1478/// - `Result::Ok(_)`     → exit 0
1479/// - `Result::Err(_)`    → exit 1
1480/// - anything else       → exit 0
1481fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1482    use harn_vm::VmValue;
1483    match value {
1484        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1485        VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
1486        _ => 0,
1487    }
1488}
1489
1490/// State for a single `harn run --json` invocation. Installs the
1491/// run-event sink in [`Self::install`] and removes it in [`Drop`], so
1492/// every exit path through `execute_run_inner` cleans up correctly
1493/// even if a panic unwinds out of the VM. Save-and-restore of any
1494/// previously installed sink keeps the helper safe to nest (rare, but
1495/// in-process embeddings can call into `harn run` from a host that
1496/// already had a sink wired).
1497///
1498/// `finalize_result` / `finalize_error` emit the terminal event and
1499/// build a [`RunOutcome`] whose stdout/stderr captured-buffer fields
1500/// stay **empty** — the canonical stream is on `out`.
1501/// `outcome.exit_code` still carries the process exit code so the
1502/// binary entry can `process::exit(...)`.
1503struct JsonRunSession {
1504    emitter: self::json_events::NdjsonEmitter,
1505    prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
1506}
1507
1508impl JsonRunSession {
1509    fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1510        let emitter = NdjsonEmitter::new(out, options.quiet);
1511        let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
1512        Self {
1513            emitter,
1514            prior_sink,
1515        }
1516    }
1517
1518    fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1519        self.emitter.emit_result(value, exit_code);
1520        RunOutcome {
1521            stdout: String::new(),
1522            stderr: String::new(),
1523            exit_code,
1524        }
1525    }
1526
1527    fn finalize_error(
1528        self,
1529        code: impl Into<String>,
1530        message: impl Into<String>,
1531        exit_code: i32,
1532    ) -> RunOutcome {
1533        self.emitter.emit_error(code, message);
1534        RunOutcome {
1535            stdout: String::new(),
1536            stderr: String::new(),
1537            exit_code,
1538        }
1539    }
1540}
1541
1542impl Drop for JsonRunSession {
1543    fn drop(&mut self) {
1544        match self.prior_sink.take() {
1545            Some(prior) => {
1546                harn_vm::run_events::install_sink(prior);
1547            }
1548            None => harn_vm::run_events::clear_sink(),
1549        }
1550    }
1551}
1552
1553/// Translate a preflight failure into either the `--json` error event
1554/// stream or a plain stderr message plus exit-code 1. Keeps the
1555/// `.harnpack` verify path's error reporting consistent with the rest
1556/// of `harn run`.
1557fn finalize_harnpack_error(
1558    mut stderr: String,
1559    json_session: Option<JsonRunSession>,
1560    err: HarnpackError,
1561) -> RunOutcome {
1562    stderr.push_str(&format!("error: {}\n", err.message));
1563    if let Some(session) = json_session {
1564        return session.finalize_error(err.code, err.message, 1);
1565    }
1566    RunOutcome {
1567        stdout: String::new(),
1568        stderr,
1569        exit_code: 1,
1570    }
1571}
1572
1573/// Successful `--dry-run-verify` path. Reports the bundle hash and
1574/// signature outcome on stderr (since stdout belongs to the script) and
1575/// emits a terminal `result` event when `--json` is active so consumers
1576/// see the run complete.
1577fn finalize_harnpack_dry_run(
1578    mut stderr: String,
1579    json_session: Option<JsonRunSession>,
1580    prepared: &PreparedHarnpack,
1581) -> RunOutcome {
1582    let summary = format!(
1583        "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1584        prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1585    );
1586    stderr.push_str(&summary);
1587    if let Some(session) = json_session {
1588        let value = serde_json::json!({
1589            "bundle_hash": prepared.bundle_hash,
1590            "signature_verified": prepared.signature_verified,
1591            "key_id": prepared.key_id,
1592            "cache_hit": prepared.cache_hit,
1593            "dry_run_verify": true,
1594        });
1595        return session.finalize_result(value, 0);
1596    }
1597    RunOutcome {
1598        stdout: String::new(),
1599        stderr,
1600        exit_code: 0,
1601    }
1602}
1603
1604fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1605    let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
1606        return String::new();
1607    };
1608    if !enum_variant.is_variant("Result", "Err") {
1609        return String::new();
1610    }
1611    let rendered = enum_variant
1612        .fields
1613        .first()
1614        .map(|p| p.display())
1615        .unwrap_or_default();
1616    if rendered.is_empty() {
1617        "error\n".to_string()
1618    } else if rendered.ends_with('\n') {
1619        rendered
1620    } else {
1621        format!("{rendered}\n")
1622    }
1623}
1624
1625/// Connect to MCP servers declared in `harn.toml` and register them as
1626/// `mcp.<name>` globals on the VM. Connection failures are warned but do
1627/// not abort execution.
1628///
1629/// Servers with `lazy = true` are registered with the VM-side MCP
1630/// registry but NOT booted — their processes start the first time a
1631/// skill's `requires_mcp` list names them or user code calls
1632/// `mcp_ensure_active("name")` / `mcp_call(mcp.<name>, ...)`.
1633pub(crate) async fn connect_mcp_servers(
1634    servers: &[package::McpServerConfig],
1635    vm: &mut harn_vm::Vm,
1636) {
1637    use std::collections::BTreeMap;
1638    use std::rc::Rc;
1639    use std::time::Duration;
1640
1641    let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1642    let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1643
1644    for server in servers {
1645        let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1646            Ok(resolution) => resolution,
1647            Err(error) => {
1648                eprintln!(
1649                    "warning: mcp: failed to load auth for '{}': {}",
1650                    server.name, error
1651                );
1652                AuthResolution::None
1653            }
1654        };
1655        let spec = serde_json::json!({
1656            "name": server.name,
1657            "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1658            "command": server.command,
1659            "args": server.args,
1660            "env": server.env,
1661            "url": server.url,
1662            "auth_token": match resolved_auth {
1663                AuthResolution::Bearer(token) => Some(token),
1664                AuthResolution::None => server.auth_token.clone(),
1665            },
1666            "protocol_version": server.protocol_version,
1667            "protocol_mode": server.protocol_mode,
1668            "proxy_server_name": server.proxy_server_name,
1669        });
1670
1671        // Register with the VM-side registry regardless of lazy flag —
1672        // skill activation and `mcp_ensure_active` look up specs there.
1673        registrations.push(harn_vm::RegisteredMcpServer {
1674            name: server.name.clone(),
1675            spec: spec.clone(),
1676            lazy: server.lazy,
1677            card: server.card.clone(),
1678            keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1679        });
1680
1681        if server.lazy {
1682            eprintln!(
1683                "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1684                server.name
1685            );
1686            continue;
1687        }
1688
1689        match harn_vm::connect_mcp_server_from_json(&spec).await {
1690            Ok(handle) => {
1691                eprintln!("[harn] mcp: connected to '{}'", server.name);
1692                harn_vm::mcp_install_active(&server.name, handle.clone());
1693                mcp_dict.insert(server.name.clone(), harn_vm::VmValue::mcp_client(handle));
1694            }
1695            Err(e) => {
1696                eprintln!(
1697                    "warning: mcp: failed to connect to '{}': {}",
1698                    server.name, e
1699                );
1700            }
1701        }
1702    }
1703
1704    // Install registrations AFTER eager connects so `install_active`
1705    // above doesn't get overwritten.
1706    harn_vm::mcp_register_servers(registrations);
1707
1708    if !mcp_dict.is_empty() {
1709        vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1710    }
1711}
1712
1713pub(crate) fn render_trace_summary() -> String {
1714    use std::fmt::Write;
1715    let entries = harn_vm::llm::take_trace();
1716    if entries.is_empty() {
1717        return String::new();
1718    }
1719    let mut out = String::new();
1720    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1721    let mut total_input = 0i64;
1722    let mut total_output = 0i64;
1723    let mut total_ms = 0u64;
1724    for (i, entry) in entries.iter().enumerate() {
1725        let _ = writeln!(
1726            out,
1727            "  #{}: {} | {} in + {} out tokens | {} ms",
1728            i + 1,
1729            entry.model,
1730            entry.input_tokens,
1731            entry.output_tokens,
1732            entry.duration_ms,
1733        );
1734        total_input += entry.input_tokens;
1735        total_output += entry.output_tokens;
1736        total_ms += entry.duration_ms;
1737    }
1738    let total_tokens = total_input + total_output;
1739    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
1740    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1741    let _ = writeln!(
1742        out,
1743        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1744        entries.len(),
1745        if entries.len() == 1 { "" } else { "s" },
1746        total_tokens,
1747        total_input,
1748        total_output,
1749        total_ms,
1750        cost,
1751    );
1752    out
1753}
1754
1755/// Run a .harn file as an MCP server using the script-driven surface.
1756/// The pipeline must call `mcp_tools(registry)` (or the alias
1757/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
1758/// register additional resources/prompts via `mcp_resource(...)` /
1759/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
1760///
1761/// Dispatched into by `harn serve mcp <file>` when the script does not
1762/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
1763///
1764/// `card_source` — optional `--card` argument. Accepts either a path to
1765/// a JSON file or an inline JSON string. When present, the card is
1766/// embedded in the `initialize` response and exposed as the
1767/// `well-known://mcp-card` resource.
1768pub(crate) async fn run_file_mcp_serve(
1769    path: &str,
1770    card_source: Option<&str>,
1771    mode: RunFileMcpServeMode,
1772) {
1773    let mut diagnostics = String::new();
1774    let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1775    else {
1776        eprint!("{diagnostics}");
1777        process::exit(1);
1778    };
1779    if !diagnostics.is_empty() {
1780        eprint!("{diagnostics}");
1781    }
1782
1783    let mut vm = harn_vm::Vm::new();
1784    harn_vm::register_vm_stdlib(&mut vm);
1785    crate::install_default_hostlib(&mut vm);
1786    let source_parent = std::path::Path::new(path)
1787        .parent()
1788        .unwrap_or(std::path::Path::new("."));
1789    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1790    let store_base = project_root.as_deref().unwrap_or(source_parent);
1791    harn_vm::register_store_builtins(&mut vm, store_base);
1792    harn_vm::register_metadata_builtins(&mut vm, store_base);
1793    let pipeline_name = std::path::Path::new(path)
1794        .file_stem()
1795        .and_then(|s| s.to_str())
1796        .unwrap_or("default");
1797    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1798    vm.set_source_info(path, &source);
1799    if let Some(ref root) = project_root {
1800        vm.set_project_root(root);
1801    }
1802    if let Some(p) = std::path::Path::new(path).parent() {
1803        if !p.as_os_str().is_empty() {
1804            vm.set_source_dir(p);
1805        }
1806    }
1807
1808    // Same skill discovery as `harn run` — see comment there.
1809    let loaded = load_skills(&SkillLoaderInputs {
1810        cli_dirs: Vec::new(),
1811        source_path: Some(std::path::PathBuf::from(path)),
1812    });
1813    emit_loader_warnings(&loaded.loader_warnings);
1814    install_skills_global(&mut vm, &loaded);
1815
1816    let extensions = package::load_runtime_extensions(Path::new(path));
1817    package::install_runtime_extensions(&extensions);
1818    if let Some(manifest) = extensions.root_manifest.as_ref() {
1819        if !manifest.mcp.is_empty() {
1820            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1821        }
1822    }
1823    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1824        eprintln!("error: failed to install manifest triggers: {error}");
1825        process::exit(1);
1826    }
1827    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1828        eprintln!("error: failed to install manifest hooks: {error}");
1829        process::exit(1);
1830    }
1831
1832    let local = tokio::task::LocalSet::new();
1833    local
1834        .run_until(async {
1835            match vm.execute(&chunk).await {
1836                Ok(_) => {}
1837                Err(e) => {
1838                    eprint!("{}", vm.format_runtime_error(&e));
1839                    process::exit(1);
1840                }
1841            }
1842
1843            // Pipeline output goes to stderr — stdout is the MCP transport.
1844            let output = vm.output();
1845            if !output.is_empty() {
1846                eprint!("{output}");
1847            }
1848
1849            let registry = match harn_vm::take_mcp_serve_registry() {
1850                Some(r) => r,
1851                None => {
1852                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1853                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1854                    process::exit(1);
1855                }
1856            };
1857
1858            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1859                Ok(t) => t,
1860                Err(e) => {
1861                    eprintln!("error: {e}");
1862                    process::exit(1);
1863                }
1864            };
1865
1866            let resources = harn_vm::take_mcp_serve_resources();
1867            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1868            let prompts = harn_vm::take_mcp_serve_prompts();
1869
1870            let server_name = std::path::Path::new(path)
1871                .file_stem()
1872                .and_then(|s| s.to_str())
1873                .unwrap_or("harn")
1874                .to_string();
1875
1876            let mut caps = Vec::new();
1877            if !tools.is_empty() {
1878                caps.push(format!(
1879                    "{} tool{}",
1880                    tools.len(),
1881                    if tools.len() == 1 { "" } else { "s" }
1882                ));
1883            }
1884            let total_resources = resources.len() + resource_templates.len();
1885            if total_resources > 0 {
1886                caps.push(format!(
1887                    "{total_resources} resource{}",
1888                    if total_resources == 1 { "" } else { "s" }
1889                ));
1890            }
1891            if !prompts.is_empty() {
1892                caps.push(format!(
1893                    "{} prompt{}",
1894                    prompts.len(),
1895                    if prompts.len() == 1 { "" } else { "s" }
1896                ));
1897            }
1898            eprintln!(
1899                "[harn] serve mcp: serving {} as '{server_name}'",
1900                caps.join(", ")
1901            );
1902
1903            let mut server =
1904                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1905            if let Some(source) = card_source {
1906                match resolve_card_source(source) {
1907                    Ok(card) => server = server.with_server_card(card),
1908                    Err(e) => {
1909                        eprintln!("error: --card: {e}");
1910                        process::exit(1);
1911                    }
1912                }
1913            }
1914            match mode {
1915                RunFileMcpServeMode::Stdio => {
1916                    if let Err(e) = server.run(&mut vm).await {
1917                        eprintln!("error: MCP server error: {e}");
1918                        process::exit(1);
1919                    }
1920                }
1921                RunFileMcpServeMode::Http {
1922                    options,
1923                    auth_policy,
1924                } => {
1925                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1926                        server,
1927                        vm,
1928                        options,
1929                        auth_policy,
1930                    )
1931                    .await
1932                    {
1933                        eprintln!("error: MCP server error: {e}");
1934                        process::exit(1);
1935                    }
1936                }
1937            }
1938        })
1939        .await;
1940}
1941
1942/// Accept either a path to a JSON file or an inline JSON blob and
1943/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
1944/// by peeking at the first non-whitespace character: `{` → inline JSON,
1945/// anything else → path.
1946pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1947    let trimmed = source.trim_start();
1948    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1949        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1950    }
1951    let path = std::path::Path::new(source);
1952    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1953}
1954
1955pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1956    use notify::{Event, EventKind, RecursiveMode, Watcher};
1957
1958    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1959        eprintln!("Error: {e}");
1960        process::exit(1);
1961    });
1962    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1963
1964    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1965    run_file(
1966        path,
1967        false,
1968        denied_builtins.clone(),
1969        Vec::new(),
1970        CliLlmMockMode::Off,
1971        None,
1972        RunProfileOptions::default(),
1973    )
1974    .await;
1975
1976    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1977    let _watcher = {
1978        let tx = tx.clone();
1979        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1980            if let Ok(event) = res {
1981                if matches!(
1982                    event.kind,
1983                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1984                ) {
1985                    let has_harn = event
1986                        .paths
1987                        .iter()
1988                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1989                    if has_harn {
1990                        let _ = tx.blocking_send(());
1991                    }
1992                }
1993            }
1994        })
1995        .unwrap_or_else(|e| {
1996            eprintln!("Error setting up file watcher: {e}");
1997            process::exit(1);
1998        });
1999        watcher
2000            .watch(watch_dir, RecursiveMode::Recursive)
2001            .unwrap_or_else(|e| {
2002                eprintln!("Error watching directory: {e}");
2003                process::exit(1);
2004            });
2005        watcher // keep alive
2006    };
2007
2008    eprintln!(
2009        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
2010        watch_dir.display()
2011    );
2012
2013    loop {
2014        rx.recv().await;
2015        // Debounce: let bursts of events settle for 200ms before re-running.
2016        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2017        while rx.try_recv().is_ok() {}
2018
2019        eprintln!();
2020        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
2021        run_file(
2022            path,
2023            false,
2024            denied_builtins.clone(),
2025            Vec::new(),
2026            CliLlmMockMode::Off,
2027            None,
2028            RunProfileOptions::default(),
2029        )
2030        .await;
2031    }
2032}
2033
2034#[cfg(test)]
2035mod tests {
2036    use super::harnpack::HarnpackRunOptions;
2037    use super::{
2038        default_run_workspace_root, execute_explain_cost, execute_run,
2039        execute_run_with_harnpack_and_sandbox_options, run_sandbox_attestation, split_eval_header,
2040        CliLlmMockMode, RunProfileOptions, RunSandboxOptions, StdoutPassthroughGuard,
2041    };
2042    use std::collections::HashSet;
2043    use std::path::Path;
2044
2045    #[test]
2046    fn split_eval_header_no_imports_returns_full_body() {
2047        let (header, body) = split_eval_header("log(1 + 2)");
2048        assert_eq!(header, "");
2049        assert_eq!(body, "log(1 + 2)");
2050    }
2051
2052    #[test]
2053    fn split_eval_header_lifts_leading_imports() {
2054        let code = "import \"./lib\"\nimport { x } from \"std/math\"\nlog(x)";
2055        let (header, body) = split_eval_header(code);
2056        assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
2057        assert_eq!(body, "log(x)");
2058    }
2059
2060    #[test]
2061    fn split_eval_header_keeps_pub_import_and_comments_in_header() {
2062        let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
2063        let (header, body) = split_eval_header(code);
2064        assert_eq!(
2065            header,
2066            "// header comment\npub import { y } from \"./lib\"\n"
2067        );
2068        assert_eq!(body, "foo()");
2069    }
2070
2071    #[test]
2072    fn split_eval_header_does_not_lift_imports_after_other_statements() {
2073        let code = "let a = 1\nimport \"./lib\"";
2074        let (header, body) = split_eval_header(code);
2075        assert_eq!(header, "");
2076        assert_eq!(body, "let a = 1\nimport \"./lib\"");
2077    }
2078
2079    #[test]
2080    fn cli_llm_mock_roundtrips_logprobs() {
2081        let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
2082            "text": "visible",
2083            "logprobs": [{"token": "visible", "logprob": 0.0}]
2084        }))
2085        .expect("parse mock");
2086        assert_eq!(mock.logprobs.len(), 1);
2087
2088        let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
2089        let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
2090        assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
2091
2092        let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
2093        assert_eq!(reparsed.logprobs.len(), 1);
2094        assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
2095    }
2096
2097    #[test]
2098    fn stdout_passthrough_guard_restores_previous_state() {
2099        let original = harn_vm::set_stdout_passthrough(false);
2100        {
2101            let _guard = StdoutPassthroughGuard::enable();
2102            assert!(harn_vm::set_stdout_passthrough(true));
2103        }
2104        assert!(!harn_vm::set_stdout_passthrough(original));
2105    }
2106
2107    #[test]
2108    fn execute_explain_cost_does_not_execute_script() {
2109        let temp = tempfile::TempDir::new().expect("temp dir");
2110        let script = temp.path().join("main.harn");
2111        std::fs::write(
2112            &script,
2113            r#"
2114pipeline main() {
2115  write_file("executed.txt", "bad")
2116  llm_call("hello", nil, {provider: "mock", model: "mock"})
2117}
2118"#,
2119        )
2120        .expect("write script");
2121
2122        let outcome = execute_explain_cost(&script.to_string_lossy());
2123
2124        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2125        assert!(outcome.stdout.contains("LLM cost estimate"));
2126        assert!(
2127            !temp.path().join("executed.txt").exists(),
2128            "--explain-cost must not execute pipeline side effects"
2129        );
2130    }
2131
2132    #[test]
2133    fn default_run_workspace_root_prefers_manifest_root_then_cwd() {
2134        let project = tempfile::TempDir::new().expect("project");
2135        let source_parent = project.path().join("scripts");
2136        let cwd = std::env::current_dir().expect("cwd");
2137
2138        assert_eq!(
2139            default_run_workspace_root(Some(project.path()), &source_parent),
2140            project.path()
2141        );
2142        assert_eq!(default_run_workspace_root(None, Path::new("scripts")), cwd);
2143    }
2144
2145    #[test]
2146    fn run_sandbox_attestation_reports_effective_policy() {
2147        harn_vm::reset_thread_local_state();
2148        let policy = harn_vm::orchestration::CapabilityPolicy {
2149            workspace_roots: vec!["/tmp/workspace".to_string()],
2150            sandbox_profile: harn_vm::orchestration::SandboxProfile::OsHardened,
2151            ..harn_vm::orchestration::CapabilityPolicy::default()
2152        };
2153        harn_vm::orchestration::push_execution_policy(policy);
2154
2155        let metadata = run_sandbox_attestation(&RunSandboxOptions::disabled());
2156
2157        assert_eq!(metadata["run_default_enabled"], false);
2158        assert_eq!(metadata["active"], true);
2159        assert_eq!(metadata["workspace_roots"][0], "/tmp/workspace");
2160        assert_eq!(metadata["profile"], "os_hardened");
2161        assert_eq!(metadata["egress"], "host_policy");
2162        harn_vm::reset_thread_local_state();
2163    }
2164
2165    #[tokio::test]
2166    async fn execute_run_default_sandbox_reports_worktree_profile() {
2167        harn_vm::reset_thread_local_state();
2168        let temp = tempfile::TempDir::new().expect("temp dir");
2169        let script = temp.path().join("main.harn");
2170        std::fs::write(
2171            &script,
2172            r#"
2173pipeline main() {
2174  __io_println(sandbox_active_profile())
2175}
2176"#,
2177        )
2178        .expect("write script");
2179
2180        let outcome = execute_run(
2181            &script.to_string_lossy(),
2182            false,
2183            HashSet::new(),
2184            Vec::new(),
2185            Vec::new(),
2186            CliLlmMockMode::Off,
2187            None,
2188            RunProfileOptions::default(),
2189        )
2190        .await;
2191
2192        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2193        assert_eq!(outcome.stdout.trim(), "worktree");
2194        harn_vm::reset_thread_local_state();
2195    }
2196
2197    #[tokio::test]
2198    async fn execute_run_default_sandbox_blocks_outside_workspace_read() {
2199        harn_vm::reset_thread_local_state();
2200        let temp = tempfile::TempDir::new().expect("temp dir");
2201        let project = temp.path().join("project");
2202        let outside = temp.path().join("outside.txt");
2203        std::fs::create_dir(&project).expect("create project");
2204        std::fs::write(project.join("harn.toml"), "").expect("write manifest");
2205        std::fs::write(&outside, "secret").expect("write outside");
2206        let script = project.join("main.harn");
2207        let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2208        std::fs::write(
2209            &script,
2210            format!(
2211                r#"
2212pipeline main() {{
2213  __io_println(sandbox_active_profile())
2214  let _ = read_file("{}")
2215}}
2216"#,
2217                outside_literal
2218            ),
2219        )
2220        .expect("write script");
2221
2222        let outcome = execute_run(
2223            &script.to_string_lossy(),
2224            false,
2225            HashSet::new(),
2226            Vec::new(),
2227            Vec::new(),
2228            CliLlmMockMode::Off,
2229            None,
2230            RunProfileOptions::default(),
2231        )
2232        .await;
2233
2234        assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2235        assert!(
2236            outcome.stderr.contains("sandbox violation"),
2237            "stderr:\n{}",
2238            outcome.stderr
2239        );
2240        harn_vm::reset_thread_local_state();
2241    }
2242
2243    #[tokio::test]
2244    async fn execute_run_no_sandbox_allows_outside_workspace_read() {
2245        harn_vm::reset_thread_local_state();
2246        let temp = tempfile::TempDir::new().expect("temp dir");
2247        let project = temp.path().join("project");
2248        let outside = temp.path().join("outside.txt");
2249        std::fs::create_dir(&project).expect("create project");
2250        std::fs::write(&outside, "secret").expect("write outside");
2251        let script = project.join("main.harn");
2252        let outside_literal = outside.to_string_lossy().replace('\\', "\\\\");
2253        std::fs::write(
2254            &script,
2255            format!(
2256                r#"
2257pipeline main() {{
2258  __io_println(sandbox_active_profile())
2259  __io_println(read_file("{}"))
2260}}
2261"#,
2262                outside_literal
2263            ),
2264        )
2265        .expect("write script");
2266
2267        let outcome = execute_run_with_harnpack_and_sandbox_options(
2268            &script.to_string_lossy(),
2269            false,
2270            HashSet::new(),
2271            Vec::new(),
2272            Vec::new(),
2273            CliLlmMockMode::Off,
2274            None,
2275            RunProfileOptions::default(),
2276            RunSandboxOptions::disabled(),
2277            HarnpackRunOptions::default(),
2278        )
2279        .await;
2280
2281        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2282        assert_eq!(outcome.stdout.trim(), "unrestricted\nsecret");
2283        assert!(outcome.stderr.contains("--no-sandbox"));
2284        harn_vm::reset_thread_local_state();
2285    }
2286
2287    #[tokio::test]
2288    async fn execute_run_denies_network_by_default() {
2289        harn_vm::reset_thread_local_state();
2290        let temp = tempfile::TempDir::new().expect("temp dir");
2291        let script = temp.path().join("main.harn");
2292        std::fs::write(
2293            &script,
2294            r#"
2295pipeline main() {
2296  let _ = http_get("https://example.com/")
2297}
2298"#,
2299        )
2300        .expect("write script");
2301
2302        let outcome = execute_run(
2303            &script.to_string_lossy(),
2304            false,
2305            HashSet::new(),
2306            Vec::new(),
2307            Vec::new(),
2308            CliLlmMockMode::Off,
2309            None,
2310            RunProfileOptions::default(),
2311        )
2312        .await;
2313
2314        assert_eq!(outcome.exit_code, 1, "stdout:\n{}", outcome.stdout);
2315        assert!(
2316            outcome.stderr.contains("exceeds network ceiling"),
2317            "stderr:\n{}",
2318            outcome.stderr
2319        );
2320        harn_vm::reset_thread_local_state();
2321    }
2322
2323    #[cfg(feature = "hostlib")]
2324    #[tokio::test]
2325    async fn execute_run_installs_hostlib_gate() {
2326        let temp = tempfile::NamedTempFile::new().expect("temp file");
2327        std::fs::write(
2328            temp.path(),
2329            r#"
2330pipeline main() {
2331  let _ = hostlib_enable("tools:deterministic")
2332  __io_println("enabled")
2333}
2334"#,
2335        )
2336        .expect("write script");
2337
2338        let outcome = execute_run(
2339            &temp.path().to_string_lossy(),
2340            false,
2341            HashSet::new(),
2342            Vec::new(),
2343            Vec::new(),
2344            CliLlmMockMode::Off,
2345            None,
2346            RunProfileOptions::default(),
2347        )
2348        .await;
2349
2350        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2351        assert_eq!(outcome.stdout.trim(), "enabled");
2352    }
2353
2354    #[cfg(all(feature = "hostlib", unix))]
2355    #[tokio::test]
2356    async fn execute_run_can_read_hostlib_command_artifacts() {
2357        let temp = tempfile::NamedTempFile::new().expect("temp file");
2358        std::fs::write(
2359            temp.path(),
2360            r#"
2361pipeline main() {
2362  let _ = hostlib_enable("tools:deterministic")
2363  let result = hostlib_tools_run_command({
2364    argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
2365    capture: {max_inline_bytes: 8},
2366    timeout_ms: 5000,
2367  })
2368  __io_println(starts_with(result.command_id, "cmd_"))
2369  __io_println(len(result.stdout))
2370  __io_println(result.byte_count)
2371  let window = hostlib_tools_read_command_output({
2372    command_id: result.command_id,
2373    offset: 1990,
2374    length: 20,
2375  })
2376  __io_println(len(window.content))
2377  __io_println(window.eof)
2378}
2379"#,
2380        )
2381        .expect("write script");
2382
2383        let outcome = execute_run_with_harnpack_and_sandbox_options(
2384            &temp.path().to_string_lossy(),
2385            false,
2386            HashSet::new(),
2387            Vec::new(),
2388            Vec::new(),
2389            CliLlmMockMode::Off,
2390            None,
2391            RunProfileOptions::default(),
2392            RunSandboxOptions::disabled(),
2393            HarnpackRunOptions::default(),
2394        )
2395        .await;
2396
2397        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
2398        assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
2399    }
2400}