Skip to main content

harn_cli/commands/
run.rs

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