Skip to main content

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