Skip to main content

harn_cli/commands/run/
mod.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use crate::commands::time::{self, RunTiming};
11use crate::package;
12use crate::skill_loader::{
13    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
14    SkillLoaderInputs,
15};
16use harn_parser::DiagnosticSeverity;
17
18mod chunk_loading;
19pub(crate) mod environment;
20mod eval_source;
21mod explain_cost;
22pub mod harnpack;
23mod interrupts;
24pub mod json_events;
25mod lifecycle;
26mod llm_mock;
27mod manifest_runtime;
28mod mcp_serve;
29mod outcome;
30mod reporting;
31
32use outcome::{
33    finalize_harnpack_dry_run, finalize_harnpack_error, finalize_run_error,
34    render_return_value_error, JsonRunSession,
35};
36pub(crate) mod sandbox;
37
38pub(crate) use self::chunk_loading::{
39    compile_or_load_chunk_for_run, compile_or_load_chunk_with_timing, LoadedChunk,
40};
41use self::chunk_loading::{parse_source_for_run, typecheck_with_imports};
42pub(crate) use self::environment::{EnvironmentPolicyArg, EnvironmentPolicyConfig};
43use self::eval_source::create_eval_temp_file;
44pub(crate) use self::eval_source::prepare_eval_temp_file;
45#[cfg(test)]
46use self::eval_source::{eval_source_for_code, split_eval_header};
47use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
48use self::interrupts::{
49    install_signal_shutdown_handler, start_run_deadline_watchdog, RunDeadlineGuard,
50};
51use self::json_events::NdjsonEmitter;
52pub use self::lifecycle::RunProfileOptions;
53use self::lifecycle::{RunExecution, TerminalRun};
54pub use self::llm_mock::*;
55pub(crate) use self::manifest_runtime::connect_mcp_servers;
56pub(crate) use self::mcp_serve::{
57    resolve_card_source, run_file_mcp_serve, RunFileAppServe, RunFileMcpServeHttp,
58    RunFileMcpServeMode,
59};
60use self::reporting::{
61    append_run_provenance_event, emit_run_attestation, emit_run_aux_for_exit,
62    exit_code_from_return_value, now_ms, render_and_persist_profile_rollup,
63    run_summary_llm_snapshot,
64};
65pub(crate) use self::reporting::{
66    render_trace_summary, run_aux_options_from_args, run_control_options_from_args,
67};
68pub use self::reporting::{
69    RunAuxOptions, RunControlOptions, RunJsonOptions, RunJsonSink, RunJsonSinkTarget,
70    RunPhaseOptions, RunRusageOptions, RunSummaryOptions, RUN_PHASE_SCHEMA_VERSION,
71    RUN_RUSAGE_SCHEMA_VERSION, RUN_SUMMARY_SCHEMA_VERSION,
72};
73#[cfg(test)]
74use self::sandbox::default_run_capability_policy;
75pub use self::sandbox::RunSandboxOptions;
76use self::sandbox::{
77    default_run_workspace_root, install_run_sandbox_scope, run_sandbox_attestation,
78};
79
80/// Core builtins that are never denied, even when using `--allow`.
81const CORE_BUILTINS: &[&str] = &[
82    "println",
83    "print",
84    "log",
85    "type_of",
86    "to_string",
87    "to_int",
88    "to_float",
89    "len",
90    "assert",
91    "assert_eq",
92    "assert_ne",
93    "json_parse",
94    "json_stringify",
95    "runtime_context",
96    "task_current",
97    "runtime_context_values",
98    "runtime_context_get",
99    "runtime_context_set",
100    "runtime_context_clear",
101];
102
103/// Build the set of denied builtin names from `--deny` or `--allow` flags.
104///
105/// - `--deny a,b,c` denies exactly those names.
106/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
107pub(crate) fn build_denied_builtins(
108    deny_csv: Option<&str>,
109    allow_csv: Option<&str>,
110) -> HashSet<String> {
111    if let Some(csv) = deny_csv {
112        csv.split(',')
113            .map(|s| s.trim().to_string())
114            .filter(|s| !s.is_empty())
115            .collect()
116    } else if let Some(csv) = allow_csv {
117        // With --allow, we mark every registered stdlib builtin as denied
118        // *except* those in the allow list and the core builtins.
119        let allowed: HashSet<String> = csv
120            .split(',')
121            .map(|s| s.trim().to_string())
122            .filter(|s| !s.is_empty())
123            .collect();
124        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
125
126        // Create a temporary VM with stdlib registered to enumerate all builtin names.
127        let mut tmp = harn_vm::Vm::new();
128        harn_vm::register_vm_stdlib(&mut tmp);
129        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
130        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
131
132        tmp.builtin_names()
133            .into_iter()
134            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
135            .collect()
136    } else {
137        HashSet::new()
138    }
139}
140
141#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct RunAttestationOptions {
143    pub receipt_out: Option<PathBuf>,
144    pub agent_id: Option<String>,
145}
146
147#[derive(Clone)]
148pub struct RunInterruptTokens {
149    pub cancel_token: Arc<AtomicBool>,
150    pub signal_token: Arc<Mutex<Option<String>>>,
151}
152
153struct ExecuteRunInputs<'a> {
154    path: &'a str,
155    trace: bool,
156    denied_builtins: HashSet<String>,
157    script_argv: Vec<String>,
158    skill_dirs_raw: Vec<String>,
159    llm_mock_mode: CliLlmMockMode,
160    attestation: Option<RunAttestationOptions>,
161    profile: RunProfileOptions,
162    sandbox: RunSandboxOptions,
163    interrupt_tokens: Option<RunInterruptTokens>,
164    json: Option<JsonRunSession>,
165    aux: RunAuxOptions,
166    timing: Option<&'a mut RunTiming>,
167    harnpack: HarnpackRunOptions,
168    defer_project_handlers: bool,
169}
170
171/// Captured outcome of an in-process `execute_run` invocation. Tests use this
172/// instead of spawning the `harn` binary; the binary entry point translates
173/// it into real stdout/stderr writes + `process::exit`.
174#[derive(Clone, Debug, Default)]
175pub struct RunOutcome {
176    pub stdout: String,
177    pub stderr: String,
178    pub exit_code: i32,
179}
180
181pub(crate) async fn run_file(
182    path: &str,
183    trace: bool,
184    denied_builtins: HashSet<String>,
185    script_argv: Vec<String>,
186    llm_mock_mode: CliLlmMockMode,
187    attestation: Option<RunAttestationOptions>,
188    profile: RunProfileOptions,
189) {
190    let exit_code = run_file_with_skill_dirs(
191        path,
192        trace,
193        denied_builtins,
194        script_argv,
195        Vec::new(),
196        llm_mock_mode,
197        attestation,
198        profile,
199        RunSandboxOptions::default(),
200        None,
201        RunAuxOptions::default(),
202        RunControlOptions::default(),
203        HarnpackRunOptions::default(),
204    )
205    .await;
206    if exit_code != 0 {
207        process::exit(exit_code);
208    }
209}
210
211pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) -> i32 {
212    let outcome = execute_explain_cost(path);
213    if !outcome.stderr.is_empty() {
214        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
215    }
216    if !outcome.stdout.is_empty() {
217        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
218    }
219    outcome.exit_code
220}
221
222#[allow(clippy::too_many_arguments)]
223pub(crate) async fn run_file_with_skill_dirs(
224    path: &str,
225    trace: bool,
226    denied_builtins: HashSet<String>,
227    script_argv: Vec<String>,
228    skill_dirs_raw: Vec<String>,
229    llm_mock_mode: CliLlmMockMode,
230    attestation: Option<RunAttestationOptions>,
231    profile: RunProfileOptions,
232    sandbox: RunSandboxOptions,
233    json: Option<RunJsonOptions>,
234    aux: RunAuxOptions,
235    control: RunControlOptions,
236    harnpack: HarnpackRunOptions,
237) -> i32 {
238    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
239    let interrupt_tokens = install_signal_shutdown_handler();
240    let deadline_guard = control
241        .timeout
242        .map(|timeout| start_run_deadline_watchdog(timeout, interrupt_tokens.clone()));
243
244    let _stdout_passthrough = StdoutPassthroughGuard::enable();
245    let json_session = json.map(|options| {
246        JsonRunSession::new(options, Box::new(io::stdout()) as Box<dyn io::Write + Send>)
247    });
248    let outcome = execute_run_inner(ExecuteRunInputs {
249        path,
250        trace,
251        denied_builtins,
252        script_argv,
253        skill_dirs_raw,
254        llm_mock_mode,
255        attestation,
256        profile,
257        sandbox,
258        interrupt_tokens: Some(interrupt_tokens.clone()),
259        json: json_session,
260        aux,
261        timing: None,
262        harnpack,
263        defer_project_handlers: control.defer_project_handlers,
264    })
265    .await;
266    if let Some(guard) = &deadline_guard {
267        guard.finish();
268    }
269
270    // `harn run` streams normal program stdout during execution. Any stdout
271    // left here came from older capture paths, so flush it after diagnostics.
272    if !outcome.stderr.is_empty() {
273        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
274    }
275    if !outcome.stdout.is_empty() {
276        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
277    }
278
279    let mut exit_code = outcome.exit_code;
280    if deadline_guard
281        .as_ref()
282        .is_some_and(RunDeadlineGuard::timed_out)
283        || (exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst))
284    {
285        exit_code = 124;
286    }
287    exit_code
288}
289
290#[allow(clippy::too_many_arguments)]
291pub(crate) async fn run_resume_with_skill_dirs(
292    target: &str,
293    trace: bool,
294    denied_builtins: HashSet<String>,
295    resume_argv: Vec<String>,
296    skill_dirs_raw: Vec<String>,
297    llm_mock_mode: CliLlmMockMode,
298    attestation: Option<RunAttestationOptions>,
299    profile: RunProfileOptions,
300    sandbox: RunSandboxOptions,
301    json: Option<RunJsonOptions>,
302    aux: RunAuxOptions,
303    control: RunControlOptions,
304) -> i32 {
305    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
306
307pipeline main(harness: Harness) {
308  const input = if len(argv) > 1 {
309    argv[1]
310  } else {
311    nil
312  }
313  const handle = resume_agent(harness.agent, argv[0], input, true)
314  return wait_agent(harness.agent, handle)
315}
316"#;
317    let tmp = match create_eval_temp_file() {
318        Ok(tmp) => tmp,
319        Err(error) => {
320            eprintln!("error: {error}");
321            return 1;
322        }
323    };
324    let tmp_path = tmp.path().to_path_buf();
325    if let Err(error) = fs::write(&tmp_path, source) {
326        eprintln!("error: failed to write temp file for --resume: {error}");
327        return 1;
328    }
329    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
330    argv.push(target.to_string());
331    argv.extend(resume_argv);
332    let tmp_str = tmp_path.to_string_lossy().into_owned();
333    run_file_with_skill_dirs(
334        &tmp_str,
335        trace,
336        denied_builtins,
337        argv,
338        skill_dirs_raw,
339        llm_mock_mode,
340        attestation,
341        profile,
342        sandbox,
343        json,
344        aux,
345        control,
346        HarnpackRunOptions::default(),
347    )
348    .await
349}
350
351pub fn execute_explain_cost(path: &str) -> RunOutcome {
352    let stdout = String::new();
353    let mut stderr = String::new();
354
355    let source = match fs::read_to_string(path) {
356        Ok(source) => source,
357        Err(error) => {
358            stderr.push_str(&format!("Error reading {path}: {error}\n"));
359            return RunOutcome {
360                stdout,
361                stderr,
362                exit_code: 1,
363            };
364        }
365    };
366    let program = match parse_source_for_run(path, &source, &mut stderr) {
367        Some(program) => program,
368        None => {
369            return RunOutcome {
370                stdout,
371                stderr,
372                exit_code: 1,
373            };
374        }
375    };
376
377    let mut had_type_error = false;
378    let type_diagnostics = match typecheck_with_imports(&program, Path::new(path), &source) {
379        Ok(diagnostics) => diagnostics,
380        Err(error) => {
381            stderr.push_str(&format!("error: {error}\n"));
382            return RunOutcome {
383                stdout,
384                stderr,
385                exit_code: 1,
386            };
387        }
388    };
389    for diag in &type_diagnostics {
390        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
391        if matches!(diag.severity, DiagnosticSeverity::Error) {
392            had_type_error = true;
393        }
394        stderr.push_str(&rendered);
395    }
396    if had_type_error {
397        return RunOutcome {
398            stdout,
399            stderr,
400            exit_code: 1,
401        };
402    }
403
404    let extensions = package::load_runtime_extensions(Path::new(path));
405    package::install_runtime_extensions(&extensions);
406    RunOutcome {
407        stdout: explain_cost::render_explain_cost(path, &program),
408        stderr,
409        exit_code: 0,
410    }
411}
412
413pub(crate) struct StdoutPassthroughGuard {
414    previous: bool,
415}
416
417impl StdoutPassthroughGuard {
418    pub(crate) fn enable() -> Self {
419        Self {
420            previous: harn_vm::set_stdout_passthrough(true),
421        }
422    }
423}
424
425impl Drop for StdoutPassthroughGuard {
426    fn drop(&mut self) {
427        harn_vm::set_stdout_passthrough(self.previous);
428    }
429}
430
431// User-facing copy on Ctrl-C. We want the operator to know that a brief
432// pause after the first signal is expected (the VM rewinds the active
433// instruction, drops in-flight async ops like a hanging Ollama request,
434// and unwinds frames before the runtime exits) so they don't reflexively
435// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
436// again to force-exit" hint is load-bearing — earlier runs of harn
437// released to the fleet showed operators routinely double-tapping the
438// shortcut and losing the chance to inspect the error trace.
439/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
440/// stdout, stderr, and what exit code the binary entry would have used,
441/// instead of writing to real stdout/stderr or calling `process::exit`.
442///
443/// Tests should call this directly. The `harn run` binary path wraps it.
444pub async fn execute_run(
445    path: &str,
446    trace: bool,
447    denied_builtins: HashSet<String>,
448    script_argv: Vec<String>,
449    skill_dirs_raw: Vec<String>,
450    llm_mock_mode: CliLlmMockMode,
451    attestation: Option<RunAttestationOptions>,
452    profile: RunProfileOptions,
453) -> RunOutcome {
454    crate::ensure_builtin_signatures_installed();
455    execute_run_with_harnpack_and_sandbox_options(
456        path,
457        trace,
458        denied_builtins,
459        script_argv,
460        skill_dirs_raw,
461        llm_mock_mode,
462        attestation,
463        profile,
464        RunSandboxOptions::default(),
465        HarnpackRunOptions::default(),
466    )
467    .await
468}
469
470/// [`execute_run`] with an explicit sandbox policy override for in-process
471/// callers whose source path is intentionally outside the workspace they
472/// operate on.
473#[allow(clippy::too_many_arguments)]
474pub async fn execute_run_with_sandbox_options(
475    path: &str,
476    trace: bool,
477    denied_builtins: HashSet<String>,
478    script_argv: Vec<String>,
479    skill_dirs_raw: Vec<String>,
480    llm_mock_mode: CliLlmMockMode,
481    attestation: Option<RunAttestationOptions>,
482    profile: RunProfileOptions,
483    sandbox: RunSandboxOptions,
484) -> RunOutcome {
485    execute_run_with_harnpack_and_sandbox_options(
486        path,
487        trace,
488        denied_builtins,
489        script_argv,
490        skill_dirs_raw,
491        llm_mock_mode,
492        attestation,
493        profile,
494        sandbox,
495        HarnpackRunOptions::default(),
496    )
497    .await
498}
499
500/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
501/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
502/// integration tests and by the binary entry once it has parsed the
503/// `--allow-unsigned` / `--dry-run-verify` flags.
504#[allow(clippy::too_many_arguments)]
505pub async fn execute_run_with_harnpack_options(
506    path: &str,
507    trace: bool,
508    denied_builtins: HashSet<String>,
509    script_argv: Vec<String>,
510    skill_dirs_raw: Vec<String>,
511    llm_mock_mode: CliLlmMockMode,
512    attestation: Option<RunAttestationOptions>,
513    profile: RunProfileOptions,
514    harnpack: HarnpackRunOptions,
515) -> RunOutcome {
516    execute_run_with_harnpack_and_sandbox_options(
517        path,
518        trace,
519        denied_builtins,
520        script_argv,
521        skill_dirs_raw,
522        llm_mock_mode,
523        attestation,
524        profile,
525        RunSandboxOptions::default(),
526        harnpack,
527    )
528    .await
529}
530
531#[allow(clippy::too_many_arguments)]
532async fn execute_run_with_harnpack_and_sandbox_options(
533    path: &str,
534    trace: bool,
535    denied_builtins: HashSet<String>,
536    script_argv: Vec<String>,
537    skill_dirs_raw: Vec<String>,
538    llm_mock_mode: CliLlmMockMode,
539    attestation: Option<RunAttestationOptions>,
540    profile: RunProfileOptions,
541    sandbox: RunSandboxOptions,
542    harnpack: HarnpackRunOptions,
543) -> RunOutcome {
544    execute_run_inner(ExecuteRunInputs {
545        path,
546        trace,
547        denied_builtins,
548        script_argv,
549        skill_dirs_raw,
550        llm_mock_mode,
551        attestation,
552        profile,
553        sandbox,
554        interrupt_tokens: None,
555        json: None,
556        aux: RunAuxOptions::default(),
557        timing: None,
558        harnpack,
559        defer_project_handlers: false,
560    })
561    .await
562}
563
564/// `execute_run` variant for `--json` mode. Returns once the run is
565/// complete; the NDJSON event stream — including the terminal `result`
566/// or `error` event — has already been written to `out` and flushed.
567/// `out` must be `Send` because the run-event sink may be called from
568/// any worker thread the VM spawns.
569#[allow(clippy::too_many_arguments)]
570pub async fn execute_run_json(
571    path: &str,
572    trace: bool,
573    denied_builtins: HashSet<String>,
574    script_argv: Vec<String>,
575    skill_dirs_raw: Vec<String>,
576    llm_mock_mode: CliLlmMockMode,
577    attestation: Option<RunAttestationOptions>,
578    profile: RunProfileOptions,
579    out: Box<dyn io::Write + Send>,
580    options: RunJsonOptions,
581) -> RunOutcome {
582    execute_run_inner(ExecuteRunInputs {
583        path,
584        trace,
585        denied_builtins,
586        script_argv,
587        skill_dirs_raw,
588        llm_mock_mode,
589        attestation,
590        profile,
591        sandbox: RunSandboxOptions::default(),
592        interrupt_tokens: None,
593        json: Some(JsonRunSession::new(options, out)),
594        aux: RunAuxOptions::default(),
595        timing: None,
596        harnpack: HarnpackRunOptions::default(),
597        defer_project_handlers: false,
598    })
599    .await
600}
601
602/// Run a `.harn` file with the default builtin/argv set and record
603/// phase timings into `timing`. Used by `harn time run` so the
604/// instrumented run shares the exact code path as plain `harn run`.
605pub(crate) async fn execute_run_with_timing(
606    path: &str,
607    script_argv: Vec<String>,
608    timing: Option<&mut RunTiming>,
609    sandbox: RunSandboxOptions,
610) -> RunOutcome {
611    execute_run_inner(ExecuteRunInputs {
612        path,
613        trace: false,
614        denied_builtins: HashSet::new(),
615        script_argv,
616        skill_dirs_raw: Vec::new(),
617        llm_mock_mode: CliLlmMockMode::Off,
618        attestation: None,
619        profile: RunProfileOptions::default(),
620        sandbox,
621        interrupt_tokens: None,
622        json: None,
623        aux: RunAuxOptions::default(),
624        timing,
625        harnpack: HarnpackRunOptions::default(),
626        defer_project_handlers: false,
627    })
628    .await
629}
630
631/// Directory that anchors the entry script's source-relative and `@asset`
632/// resolution.
633///
634/// Returns the script's parent directory, or the current working directory
635/// when the path is a bare filename (empty parent) — e.g. `cd project &&
636/// harn run main.harn`. The old code skipped setting the source dir in that
637/// case, which left the resting thread-local source dir unset (`None`). A
638/// dependency provider-connector contract load during `harn run` startup then
639/// repointed the thread-local at a dependency generation's `src` and, because the
640/// restore-on-return path is a no-op over an unset baseline, left it there —
641/// so the entry pipeline's first `render("@alias/...")` resolved against the
642/// dependency's `harn.toml` instead of the project root. Always establishing
643/// the entry dir keeps that resolution anchored on the project.
644fn entry_source_dir(path: &str) -> std::path::PathBuf {
645    match std::path::Path::new(path).parent() {
646        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
647        _ => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
648    }
649}
650
651// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
652// the intentional reborrow pattern here.
653#[allow(clippy::needless_option_as_deref)]
654async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
655    let mut inputs = inputs;
656    let json_session = inputs.json.take();
657    let Some(json_session) = json_session else {
658        return execute_run_inner_scoped(inputs, None).await;
659    };
660    let sink = json_session.sink();
661    harn_vm::run_events::scope(sink, execute_run_inner_scoped(inputs, Some(json_session))).await
662}
663
664async fn execute_run_inner_scoped(
665    inputs: ExecuteRunInputs<'_>,
666    json_session: Option<JsonRunSession>,
667) -> RunOutcome {
668    let ExecuteRunInputs {
669        path,
670        trace,
671        denied_builtins,
672        script_argv,
673        skill_dirs_raw,
674        llm_mock_mode,
675        attestation,
676        profile,
677        sandbox,
678        interrupt_tokens,
679        json: _,
680        aux,
681        timing,
682        harnpack,
683        defer_project_handlers,
684    } = inputs;
685    let RunAuxOptions {
686        summary,
687        phase,
688        rusage,
689    } = aux;
690    let run_started = Instant::now();
691    let cpu_started_ms = rusage.as_ref().map(|_| time::cpu_ms());
692    let mut owned_timing = if timing.is_none() && (phase.is_some() || rusage.is_some()) {
693        Some(RunTiming::default())
694    } else {
695        None
696    };
697    let mut timing = timing.or(owned_timing.as_mut());
698
699    let mut stderr = String::new();
700    let mut stdout = String::new();
701
702    // `.harnpack` preflight: verify signature + replay archive into the
703    // content-addressed cache before we touch the chunk loader. The
704    // outcome path (entrypoint inside the unpacked tree) replaces the
705    // CLI-supplied `path` for everything below.
706    let owned_run_path: String;
707    let mut prepared_harnpack: Option<PreparedHarnpack> = None;
708    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
709        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
710            Ok(prepared) => prepared,
711            Err(err) => {
712                return finalize_harnpack_error(
713                    stderr,
714                    json_session,
715                    summary.as_ref(),
716                    phase.as_ref(),
717                    rusage.as_ref(),
718                    run_started,
719                    err,
720                );
721            }
722        };
723        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
724            bundle_hash: outcome.bundle_hash.clone(),
725            signature_verified: outcome.signature_verified,
726            key_id: outcome.key_id.clone(),
727            cache_hit: outcome.cache_hit,
728            dry_run_verify: harnpack.dry_run_verify,
729            execution_artifact_state: outcome.execution_artifact_state.to_string(),
730            fallback_reason: outcome.fallback_reason.clone(),
731            artifact_decode_ms: outcome.artifact_decode_elapsed.as_millis() as u64,
732        });
733        if harnpack.dry_run_verify {
734            return finalize_harnpack_dry_run(
735                stderr,
736                json_session,
737                summary.as_ref(),
738                phase.as_ref(),
739                rusage.as_ref(),
740                run_started,
741                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
742                &outcome,
743            );
744        }
745        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
746        prepared_harnpack = Some(outcome);
747        owned_run_path.as_str()
748    } else {
749        path
750    };
751
752    let mut linked_runtime = None;
753    let loaded = if let Some(linked) = prepared_harnpack
754        .as_mut()
755        .and_then(|prepared| prepared.linked_program.take())
756    {
757        let source = match std::fs::read_to_string(resolved_path) {
758            Ok(source) => source,
759            Err(error) => {
760                stderr.push_str(&format!("Error reading {resolved_path}: {error}\n"));
761                return finalize_run_error(
762                    stdout,
763                    stderr.clone(),
764                    json_session,
765                    summary.as_ref(),
766                    phase.as_ref(),
767                    rusage.as_ref(),
768                    run_started,
769                    None,
770                    timing.as_deref(),
771                    0,
772                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
773                    "linked_program_source",
774                    stderr,
775                );
776            }
777        };
778        let source_root = prepared_harnpack
779            .as_ref()
780            .expect("linked program came from a prepared pack")
781            .cache_dir
782            .join("sources");
783        let runtime = linked.into_runtime(&source_root);
784        let chunk = runtime.entry_chunk.clone();
785        linked_runtime = Some(runtime);
786        Some(LoadedChunk {
787            source,
788            chunk,
789            link_table: None,
790        })
791    } else {
792        compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
793    };
794    let Some(LoadedChunk {
795        source,
796        chunk,
797        link_table,
798    }) = loaded
799    else {
800        let message = stderr.clone();
801        return finalize_run_error(
802            stdout,
803            stderr,
804            json_session,
805            summary.as_ref(),
806            phase.as_ref(),
807            rusage.as_ref(),
808            run_started,
809            None,
810            timing.as_deref(),
811            0,
812            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
813            "compile_error",
814            message,
815        );
816    };
817    let path = resolved_path;
818
819    let setup_start = Instant::now();
820    if trace || summary.is_some() {
821        harn_vm::llm::enable_tracing();
822    }
823    if profile.is_enabled() || phase.is_some() {
824        harn_vm::tracing::set_tracing_enabled(true);
825    }
826    // Per-builtin recording is only paid for when a profile is asked for: the
827    // categorical buckets fold every non-LLM, non-tool builtin into
828    // `residual`, which cannot name what a slow run is waiting on. The guard
829    // lives for the rest of this function, so recording ends with the run on
830    // every exit path below.
831    let _builtin_profile_guard = profile.is_enabled().then(harn_vm::builtin_profile::enable);
832    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
833        stderr.push_str(&format!("error: {error}\n"));
834        time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
835        return finalize_run_error(
836            stdout,
837            stderr,
838            json_session,
839            summary.as_ref(),
840            phase.as_ref(),
841            rusage.as_ref(),
842            run_started,
843            None,
844            timing.as_deref(),
845            0,
846            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
847            "llm_mock_install",
848            error,
849        );
850    }
851
852    let mut vm = harn_vm::Vm::new();
853    vm.set_graph_link_table(link_table);
854    if let Some(runtime) = &linked_runtime {
855        vm.set_linked_program_runtime(runtime);
856    }
857    if let Some(timing) = timing.as_deref_mut() {
858        timing.module_phases = Some(vm.enable_module_phase_timing());
859    }
860    if let Some(interrupt_tokens) = interrupt_tokens {
861        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
862        vm.install_cancel_token(interrupt_tokens.cancel_token);
863    }
864    harn_vm::register_vm_stdlib(&mut vm);
865    crate::install_default_hostlib(&mut vm);
866    // A project that declares `[check].trusted_host_dispatch` has declared
867    // itself a privileged embedder. `check`, `lint`, and `test` all honor it;
868    // `run` did not, and it has no CLI flag to compensate, so the manifest's
869    // own trigger handlers were compiled without the authority and refused
870    // every `host_call` before the script body ever ran. Enable it here, ahead
871    // of the first import, so one declaration means one thing everywhere.
872    if crate::compiler_context::trusted_host_dispatch_for_source(std::path::Path::new(path)) {
873        if let Err(error) = vm.enable_trusted_host_dispatch() {
874            stderr.push_str(&format!(
875                "warning: failed to enable trusted host dispatch: {error}\n"
876            ));
877        }
878    }
879    let source_parent = std::path::Path::new(path)
880        .parent()
881        .unwrap_or(std::path::Path::new("."));
882    // Metadata/store rooted at harn.toml when present; source dir otherwise.
883    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
884    let store_base = project_root.as_deref().unwrap_or(source_parent);
885    let sandbox_root = sandbox
886        .workspace_root
887        .clone()
888        .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
889    let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
890
891    // Launch the session's environment policy so this run's
892    // subprocesses build their environment through the closed allowlist +
893    // grants resolver (harn#4992). A launch failure — a missing launcher
894    // variable, or a grant on an isolated policy — fails the run loudly rather
895    // than silently dropping the credential. Held for the run's duration; on
896    // drop the ambient environment policy is cleared.
897    let (_environment_scope, environment_policy, grant_receipts) =
898        match environment::launch_scope(&sandbox.environment, &mut stderr) {
899            Ok(launched) => launched,
900            Err(error) => {
901                stderr.push_str(&format!("error: {error}\n"));
902                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
903                let code = error.code();
904                return finalize_run_error(
905                    stdout,
906                    stderr,
907                    json_session,
908                    summary.as_ref(),
909                    phase.as_ref(),
910                    rusage.as_ref(),
911                    run_started,
912                    None,
913                    timing.as_deref(),
914                    0,
915                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
916                    code,
917                    error.to_string(),
918                );
919            }
920        };
921
922    let attestation_started_at_ms = now_ms();
923    let attestation_log = if attestation.is_some() {
924        Some(harn_vm::event_log::install_memory_for_current_thread(256))
925    } else {
926        None
927    };
928    if let Some(log) = attestation_log.as_ref() {
929        append_run_provenance_event(
930            log,
931            "started",
932            serde_json::json!({
933                "pipeline": path,
934                "argv": &script_argv,
935                "project_root": store_base.display().to_string(),
936                "sandbox": run_sandbox_attestation(&sandbox),
937                "environment_policy": environment_policy.as_str(),
938                // Non-secret grant receipts; never the granted value.
939                "environment_grants": &grant_receipts,
940            }),
941        )
942        .await;
943    }
944    harn_vm::register_store_builtins(&mut vm, store_base);
945    harn_vm::register_metadata_builtins(&mut vm, store_base);
946    let pipeline_name = std::path::Path::new(path)
947        .file_stem()
948        .and_then(|s| s.to_str())
949        .unwrap_or("default");
950    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
951    vm.set_source_info(path, &source);
952    let defer_manifest_handlers = defer_project_handlers || !denied_builtins.is_empty();
953    if !denied_builtins.is_empty() {
954        vm.set_denied_builtins(denied_builtins);
955    }
956    if let Some(ref root) = project_root {
957        vm.set_project_root(root);
958    }
959
960    // Establish the entry script's directory as the resting source dir. When
961    // `path` is a bare filename (empty parent) — e.g. `cd project && harn run
962    // main.harn` — anchor on the current working directory instead of skipping,
963    // so the resting source dir is never left unset. See `entry_source_dir`.
964    vm.set_source_dir(&entry_source_dir(path));
965
966    // Load filesystem + manifest skills before the pipeline runs so
967    // `skills` is populated with a pre-discovered registry (see #73).
968    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
969    let loaded = load_skills(&SkillLoaderInputs {
970        cli_dirs,
971        source_path: Some(std::path::PathBuf::from(path)),
972    });
973    emit_loader_warnings(&loaded.loader_warnings);
974    install_skills_global(&mut vm, &loaded);
975
976    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
977    // Always set so scripts can rely on `len(argv)`.
978    let argv_values: Vec<harn_vm::VmValue> = script_argv
979        .iter()
980        .map(|s| harn_vm::VmValue::String(arcstr::ArcStr::from(s.as_str())))
981        .collect();
982    vm.set_global(
983        "argv",
984        harn_vm::VmValue::List(std::sync::Arc::new(argv_values)),
985    );
986
987    // Install the script's `Harness` capability handle so the auto-call
988    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
989    // entrypoints can read it.
990    let runtime_harness =
991        match crate::default_harness_for_manifest_or_base_dir(Path::new(path), store_base) {
992            Ok(harness) => harness,
993            Err(error) => {
994                stderr.push_str(&format!(
995                    "error: failed to configure harness secret provider: {error}\n"
996                ));
997                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
998                return finalize_run_error(
999                    stdout,
1000                    stderr,
1001                    json_session,
1002                    summary.as_ref(),
1003                    phase.as_ref(),
1004                    rusage.as_ref(),
1005                    run_started,
1006                    None,
1007                    timing.as_deref(),
1008                    0,
1009                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1010                    "harness_secret_provider",
1011                    error,
1012                );
1013            }
1014        };
1015    vm.set_harness(runtime_harness);
1016
1017    // An explicit allow/deny policy belongs to the requested target. Defer
1018    // unrelated manifest handler graphs until they actually fire under this VM.
1019    let _manifest_runtime = match manifest_runtime::install_manifest_runtime(
1020        Path::new(path),
1021        store_base,
1022        &mut vm,
1023        defer_manifest_handlers,
1024    )
1025    .await
1026    {
1027        Ok(runtime) => runtime,
1028        Err(error) => {
1029            stderr.push_str(&format!(
1030                "error: failed to install {}: {error}\n",
1031                error.label()
1032            ));
1033            time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1034            return finalize_run_error(
1035                stdout,
1036                stderr,
1037                json_session,
1038                summary.as_ref(),
1039                phase.as_ref(),
1040                rusage.as_ref(),
1041                run_started,
1042                None,
1043                timing.as_deref(),
1044                0,
1045                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1046                error.phase(),
1047                error.to_string(),
1048            );
1049        }
1050    };
1051
1052    let local = tokio::task::LocalSet::new();
1053    time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1054    let main_start = Instant::now();
1055    // Re-anchor the entry source dir immediately before executing the entry
1056    // pipeline. The manifest/dependency setup above (provider-connector
1057    // contract loads, hook-handler module loads) transiently repoints the
1058    // thread-local source dir and does not guarantee it is restored to the
1059    // entry's dir — a dependency provider connector under
1060    // a dependency generation would otherwise leave the entry pipeline's first
1061    // `render("@alias/...")` resolving against the dependency's `harn.toml`.
1062    vm.set_source_dir(&entry_source_dir(path));
1063    let execution = local
1064        .run_until(async {
1065            match vm.execute(&chunk).await {
1066                Ok(value) => RunExecution::Terminal(TerminalRun::Returned(value)),
1067                Err(error) => match error.process_exit_code() {
1068                    Some(code) => RunExecution::Terminal(TerminalRun::ProcessExited(code)),
1069                    None => RunExecution::Failed(vm.format_runtime_error(&error)),
1070                },
1071            }
1072        })
1073        .await;
1074    let output = vm.output();
1075    if let Some(t) = timing.as_deref_mut() {
1076        t.run_main = main_start.elapsed();
1077    }
1078    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1079        stderr.push_str(&format!("error: {error}\n"));
1080        let profile_rollup = if profile.is_enabled() {
1081            Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1082        } else {
1083            None
1084        };
1085        return finalize_run_error(
1086            stdout,
1087            stderr,
1088            json_session,
1089            summary.as_ref(),
1090            phase.as_ref(),
1091            rusage.as_ref(),
1092            run_started,
1093            profile_rollup.as_ref(),
1094            timing.as_deref(),
1095            harn_vm::tracing::peek_spans().len() as u64,
1096            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1097            "llm_mock_record",
1098            error,
1099        );
1100    }
1101
1102    // Always drain any captured stderr accumulated during execution.
1103    let buffered_stderr = harn_vm::take_stderr_buffer();
1104    stderr.push_str(&buffered_stderr);
1105
1106    let exit_code = match &execution {
1107        RunExecution::Terminal(terminal) => terminal.exit_code(),
1108        RunExecution::Failed(_) => 1,
1109    };
1110
1111    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1112        if let Err(error) = emit_run_attestation(
1113            log,
1114            path,
1115            store_base,
1116            attestation_started_at_ms,
1117            exit_code,
1118            options,
1119            &mut stderr,
1120        )
1121        .await
1122        {
1123            stderr.push_str(&format!(
1124                "error: failed to emit provenance receipt: {error}\n"
1125            ));
1126            let profile_rollup = if profile.is_enabled() {
1127                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1128            } else {
1129                None
1130            };
1131            return finalize_run_error(
1132                stdout,
1133                stderr,
1134                json_session,
1135                summary.as_ref(),
1136                phase.as_ref(),
1137                rusage.as_ref(),
1138                run_started,
1139                profile_rollup.as_ref(),
1140                timing.as_deref(),
1141                harn_vm::tracing::peek_spans().len() as u64,
1142                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1143                "attestation",
1144                error,
1145            );
1146        }
1147        harn_vm::event_log::reset_active_event_log();
1148    }
1149
1150    match execution {
1151        RunExecution::Terminal(terminal) => {
1152            stdout.push_str(output);
1153            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1154            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1155            let profile_rollup = if profile.is_enabled() {
1156                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1157            } else {
1158                None
1159            };
1160            let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1161            if trace {
1162                stderr.push_str(&render_trace_summary());
1163            }
1164            if let Some(profile_rollup) = profile_rollup.as_ref() {
1165                if let Err(error) =
1166                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1167                {
1168                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1169                }
1170            }
1171            if let Some(diagnostic) = terminal.nonzero_return_diagnostic() {
1172                stderr.push_str(&diagnostic);
1173            }
1174            let aux_emission = emit_run_aux_for_exit(
1175                summary.as_ref(),
1176                phase.as_ref(),
1177                rusage.as_ref(),
1178                run_started,
1179                exit_code,
1180                profile_rollup.as_ref(),
1181                summary_llm,
1182                timing.as_deref(),
1183                main_events,
1184                cpu_ms_total,
1185                json_session.is_some(),
1186                &mut stderr,
1187            );
1188            if let Some(session) = json_session {
1189                if let Some(error) = aux_emission.error {
1190                    let mut outcome = session.finalize_error(
1191                        "run_aux",
1192                        format!("failed to emit auxiliary run JSON: {error}"),
1193                        1,
1194                    );
1195                    outcome.stderr = aux_emission.stderr;
1196                    return outcome;
1197                }
1198                let value = terminal.json_value();
1199                let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1200                outcome.stderr = aux_emission.stderr;
1201                return outcome;
1202            }
1203            RunOutcome {
1204                stdout,
1205                stderr,
1206                exit_code: aux_emission.exit_code,
1207            }
1208        }
1209        RunExecution::Failed(rendered_error) => {
1210            stderr.push_str(&rendered_error);
1211            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1212            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1213            let profile_rollup = if profile.is_enabled() {
1214                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1215            } else {
1216                None
1217            };
1218            if let Some(profile_rollup) = profile_rollup.as_ref() {
1219                if let Err(error) =
1220                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1221                {
1222                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1223                }
1224            }
1225            let aux_emission = emit_run_aux_for_exit(
1226                summary.as_ref(),
1227                phase.as_ref(),
1228                rusage.as_ref(),
1229                run_started,
1230                1,
1231                profile_rollup.as_ref(),
1232                None,
1233                timing.as_deref(),
1234                main_events,
1235                cpu_ms_total,
1236                json_session.is_some(),
1237                &mut stderr,
1238            );
1239            if let Some(session) = json_session {
1240                let mut outcome =
1241                    session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1242                outcome.stderr = aux_emission.stderr;
1243                return outcome;
1244            }
1245            RunOutcome {
1246                stdout,
1247                stderr,
1248                exit_code: aux_emission.exit_code,
1249            }
1250        }
1251    }
1252}
1253
1254pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1255    use notify::{Event, EventKind, RecursiveMode, Watcher};
1256
1257    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1258        eprintln!("Error: {e}");
1259        process::exit(1);
1260    });
1261    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1262
1263    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1264    run_file(
1265        path,
1266        false,
1267        denied_builtins.clone(),
1268        Vec::new(),
1269        CliLlmMockMode::Off,
1270        None,
1271        RunProfileOptions::default(),
1272    )
1273    .await;
1274
1275    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1276    let _watcher = {
1277        let tx = tx.clone();
1278        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1279            if let Ok(event) = res {
1280                if matches!(
1281                    event.kind,
1282                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1283                ) {
1284                    let has_harn = event
1285                        .paths
1286                        .iter()
1287                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1288                    if has_harn {
1289                        let _ = tx.blocking_send(());
1290                    }
1291                }
1292            }
1293        })
1294        .unwrap_or_else(|e| {
1295            eprintln!("Error setting up file watcher: {e}");
1296            process::exit(1);
1297        });
1298        watcher
1299            .watch(watch_dir, RecursiveMode::Recursive)
1300            .unwrap_or_else(|e| {
1301                eprintln!("Error watching directory: {e}");
1302                process::exit(1);
1303            });
1304        watcher // keep alive
1305    };
1306
1307    eprintln!(
1308        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1309        watch_dir.display()
1310    );
1311
1312    loop {
1313        rx.recv().await;
1314        // Debounce: let bursts of events settle for 200ms before re-running.
1315        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1316        while rx.try_recv().is_ok() {}
1317
1318        eprintln!();
1319        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1320        run_file(
1321            path,
1322            false,
1323            denied_builtins.clone(),
1324            Vec::new(),
1325            CliLlmMockMode::Off,
1326            None,
1327            RunProfileOptions::default(),
1328        )
1329        .await;
1330    }
1331}
1332
1333#[cfg(test)]
1334mod tests;