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    eager_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        eager_project_handlers: control.eager_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        eager_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        eager_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        eager_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        eager_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 let Err(error) = crate::compiler_context::enable_trusted_host_dispatch_for_source(
873        &mut vm,
874        std::path::Path::new(path),
875    ) {
876        stderr.push_str(&format!(
877            "warning: failed to enable trusted host dispatch: {error}\n"
878        ));
879    }
880    let source_parent = std::path::Path::new(path)
881        .parent()
882        .unwrap_or(std::path::Path::new("."));
883    // Metadata/store rooted at harn.toml when present; source dir otherwise.
884    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
885    let store_base = project_root.as_deref().unwrap_or(source_parent);
886    let sandbox_root = sandbox
887        .workspace_root
888        .clone()
889        .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
890    let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
891
892    // Launch the session's environment policy so this run's
893    // subprocesses build their environment through the closed allowlist +
894    // grants resolver (harn#4992). A launch failure — a missing launcher
895    // variable, or a grant on an isolated policy — fails the run loudly rather
896    // than silently dropping the credential. Held for the run's duration; on
897    // drop the ambient environment policy is cleared.
898    let (_environment_scope, environment_policy, grant_receipts) =
899        match environment::launch_scope(&sandbox.environment, &mut stderr) {
900            Ok(launched) => launched,
901            Err(error) => {
902                stderr.push_str(&format!("error: {error}\n"));
903                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
904                let code = error.code();
905                return finalize_run_error(
906                    stdout,
907                    stderr,
908                    json_session,
909                    summary.as_ref(),
910                    phase.as_ref(),
911                    rusage.as_ref(),
912                    run_started,
913                    None,
914                    timing.as_deref(),
915                    0,
916                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
917                    code,
918                    error.to_string(),
919                );
920            }
921        };
922
923    let attestation_started_at_ms = now_ms();
924    let attestation_log = if attestation.is_some() {
925        Some(harn_vm::event_log::install_memory_for_current_thread(256))
926    } else {
927        None
928    };
929    if let Some(log) = attestation_log.as_ref() {
930        append_run_provenance_event(
931            log,
932            "started",
933            serde_json::json!({
934                "pipeline": path,
935                "argv": &script_argv,
936                "project_root": store_base.display().to_string(),
937                "sandbox": run_sandbox_attestation(&sandbox),
938                "environment_policy": environment_policy.as_str(),
939                // Non-secret grant receipts; never the granted value.
940                "environment_grants": &grant_receipts,
941            }),
942        )
943        .await;
944    }
945    harn_vm::register_store_builtins(&mut vm, store_base);
946    harn_vm::register_metadata_builtins(&mut vm, store_base);
947    let pipeline_name = std::path::Path::new(path)
948        .file_stem()
949        .and_then(|s| s.to_str())
950        .unwrap_or("default");
951    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
952    vm.set_source_info(path, &source);
953    let handler_initialization = if eager_project_handlers {
954        package::ManifestHandlerInitialization::Eager
955    } else {
956        package::ManifestHandlerInitialization::OnDispatch
957    };
958    if !denied_builtins.is_empty() {
959        vm.set_denied_builtins(denied_builtins);
960    }
961    if let Some(ref root) = project_root {
962        vm.set_project_root(root);
963    }
964
965    // Establish the entry script's directory as the resting source dir. When
966    // `path` is a bare filename (empty parent) — e.g. `cd project && harn run
967    // main.harn` — anchor on the current working directory instead of skipping,
968    // so the resting source dir is never left unset. See `entry_source_dir`.
969    vm.set_source_dir(&entry_source_dir(path));
970
971    // Load filesystem + manifest skills before the pipeline runs so
972    // `skills` is populated with a pre-discovered registry (see #73).
973    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
974    let loaded = load_skills(&SkillLoaderInputs {
975        cli_dirs,
976        source_path: Some(std::path::PathBuf::from(path)),
977    });
978    emit_loader_warnings(&loaded.loader_warnings);
979    install_skills_global(&mut vm, &loaded);
980
981    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
982    // Always set so scripts can rely on `len(argv)`.
983    let argv_values: Vec<harn_vm::VmValue> = script_argv
984        .iter()
985        .map(|s| harn_vm::VmValue::String(arcstr::ArcStr::from(s.as_str())))
986        .collect();
987    vm.set_global(
988        "argv",
989        harn_vm::VmValue::List(std::sync::Arc::new(argv_values)),
990    );
991
992    // Install the script's `Harness` capability handle so the auto-call
993    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
994    // entrypoints can read it.
995    let runtime_harness =
996        match crate::default_harness_for_manifest_or_base_dir(Path::new(path), store_base) {
997            Ok(harness) => harness,
998            Err(error) => {
999                stderr.push_str(&format!(
1000                    "error: failed to configure harness secret provider: {error}\n"
1001                ));
1002                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1003                return finalize_run_error(
1004                    stdout,
1005                    stderr,
1006                    json_session,
1007                    summary.as_ref(),
1008                    phase.as_ref(),
1009                    rusage.as_ref(),
1010                    run_started,
1011                    None,
1012                    timing.as_deref(),
1013                    0,
1014                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1015                    "harness_secret_provider",
1016                    error,
1017                );
1018            }
1019        };
1020    vm.set_harness(runtime_harness);
1021
1022    // Declarations and callable signatures are validated during installation.
1023    // Handler module graphs initialize only when an event dispatches to them,
1024    // unless the operator explicitly requests fail-fast initialization.
1025    let _manifest_runtime = match manifest_runtime::install_manifest_runtime(
1026        Path::new(path),
1027        store_base,
1028        &mut vm,
1029        handler_initialization,
1030    )
1031    .await
1032    {
1033        Ok(runtime) => runtime,
1034        Err(error) => {
1035            stderr.push_str(&format!(
1036                "error: failed to install {}: {error}\n",
1037                error.label()
1038            ));
1039            time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1040            return finalize_run_error(
1041                stdout,
1042                stderr,
1043                json_session,
1044                summary.as_ref(),
1045                phase.as_ref(),
1046                rusage.as_ref(),
1047                run_started,
1048                None,
1049                timing.as_deref(),
1050                0,
1051                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1052                error.phase(),
1053                error.to_string(),
1054            );
1055        }
1056    };
1057
1058    let local = tokio::task::LocalSet::new();
1059    time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1060    let main_start = Instant::now();
1061    // Re-anchor the entry source dir immediately before executing the entry
1062    // pipeline. The manifest/dependency setup above (provider-connector
1063    // contract loads, hook-handler module loads) transiently repoints the
1064    // thread-local source dir and does not guarantee it is restored to the
1065    // entry's dir — a dependency provider connector under
1066    // a dependency generation would otherwise leave the entry pipeline's first
1067    // `render("@alias/...")` resolving against the dependency's `harn.toml`.
1068    vm.set_source_dir(&entry_source_dir(path));
1069    let execution = local
1070        .run_until(async {
1071            match vm.execute(&chunk).await {
1072                Ok(value) => RunExecution::Terminal(TerminalRun::Returned(value)),
1073                Err(error) => match error.process_exit_code() {
1074                    Some(code) => RunExecution::Terminal(TerminalRun::ProcessExited(code)),
1075                    None => RunExecution::Failed(vm.format_runtime_error(&error)),
1076                },
1077            }
1078        })
1079        .await;
1080    let output = vm.output();
1081    if let Some(t) = timing.as_deref_mut() {
1082        t.run_main = main_start.elapsed();
1083    }
1084    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1085        stderr.push_str(&format!("error: {error}\n"));
1086        let profile_rollup = if profile.is_enabled() {
1087            Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1088        } else {
1089            None
1090        };
1091        return finalize_run_error(
1092            stdout,
1093            stderr,
1094            json_session,
1095            summary.as_ref(),
1096            phase.as_ref(),
1097            rusage.as_ref(),
1098            run_started,
1099            profile_rollup.as_ref(),
1100            timing.as_deref(),
1101            harn_vm::tracing::peek_spans().len() as u64,
1102            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1103            "llm_mock_record",
1104            error,
1105        );
1106    }
1107
1108    // Always drain any captured stderr accumulated during execution.
1109    let buffered_stderr = harn_vm::take_stderr_buffer();
1110    stderr.push_str(&buffered_stderr);
1111
1112    let exit_code = match &execution {
1113        RunExecution::Terminal(terminal) => terminal.exit_code(),
1114        RunExecution::Failed(_) => 1,
1115    };
1116
1117    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1118        if let Err(error) = emit_run_attestation(
1119            log,
1120            path,
1121            store_base,
1122            attestation_started_at_ms,
1123            exit_code,
1124            options,
1125            &mut stderr,
1126        )
1127        .await
1128        {
1129            stderr.push_str(&format!(
1130                "error: failed to emit provenance receipt: {error}\n"
1131            ));
1132            let profile_rollup = if profile.is_enabled() {
1133                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1134            } else {
1135                None
1136            };
1137            return finalize_run_error(
1138                stdout,
1139                stderr,
1140                json_session,
1141                summary.as_ref(),
1142                phase.as_ref(),
1143                rusage.as_ref(),
1144                run_started,
1145                profile_rollup.as_ref(),
1146                timing.as_deref(),
1147                harn_vm::tracing::peek_spans().len() as u64,
1148                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1149                "attestation",
1150                error,
1151            );
1152        }
1153        harn_vm::event_log::reset_active_event_log();
1154    }
1155
1156    match execution {
1157        RunExecution::Terminal(terminal) => {
1158            stdout.push_str(output);
1159            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1160            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1161            let profile_rollup = if profile.is_enabled() {
1162                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1163            } else {
1164                None
1165            };
1166            let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1167            if trace {
1168                stderr.push_str(&render_trace_summary());
1169            }
1170            if let Some(profile_rollup) = profile_rollup.as_ref() {
1171                if let Err(error) =
1172                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1173                {
1174                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1175                }
1176            }
1177            if let Some(diagnostic) = terminal.nonzero_return_diagnostic() {
1178                stderr.push_str(&diagnostic);
1179            }
1180            let aux_emission = emit_run_aux_for_exit(
1181                summary.as_ref(),
1182                phase.as_ref(),
1183                rusage.as_ref(),
1184                run_started,
1185                exit_code,
1186                profile_rollup.as_ref(),
1187                summary_llm,
1188                timing.as_deref(),
1189                main_events,
1190                cpu_ms_total,
1191                json_session.is_some(),
1192                &mut stderr,
1193            );
1194            if let Some(session) = json_session {
1195                if let Some(error) = aux_emission.error {
1196                    let mut outcome = session.finalize_error(
1197                        "run_aux",
1198                        format!("failed to emit auxiliary run JSON: {error}"),
1199                        1,
1200                    );
1201                    outcome.stderr = aux_emission.stderr;
1202                    return outcome;
1203                }
1204                let value = terminal.json_value();
1205                let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1206                outcome.stderr = aux_emission.stderr;
1207                return outcome;
1208            }
1209            RunOutcome {
1210                stdout,
1211                stderr,
1212                exit_code: aux_emission.exit_code,
1213            }
1214        }
1215        RunExecution::Failed(rendered_error) => {
1216            stderr.push_str(&rendered_error);
1217            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1218            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1219            let profile_rollup = if profile.is_enabled() {
1220                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1221            } else {
1222                None
1223            };
1224            if let Some(profile_rollup) = profile_rollup.as_ref() {
1225                if let Err(error) =
1226                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1227                {
1228                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1229                }
1230            }
1231            let aux_emission = emit_run_aux_for_exit(
1232                summary.as_ref(),
1233                phase.as_ref(),
1234                rusage.as_ref(),
1235                run_started,
1236                1,
1237                profile_rollup.as_ref(),
1238                None,
1239                timing.as_deref(),
1240                main_events,
1241                cpu_ms_total,
1242                json_session.is_some(),
1243                &mut stderr,
1244            );
1245            if let Some(session) = json_session {
1246                let mut outcome =
1247                    session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1248                outcome.stderr = aux_emission.stderr;
1249                return outcome;
1250            }
1251            RunOutcome {
1252                stdout,
1253                stderr,
1254                exit_code: aux_emission.exit_code,
1255            }
1256        }
1257    }
1258}
1259
1260pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1261    use notify::{Event, EventKind, RecursiveMode, Watcher};
1262
1263    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1264        eprintln!("Error: {e}");
1265        process::exit(1);
1266    });
1267    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1268
1269    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1270    run_file(
1271        path,
1272        false,
1273        denied_builtins.clone(),
1274        Vec::new(),
1275        CliLlmMockMode::Off,
1276        None,
1277        RunProfileOptions::default(),
1278    )
1279    .await;
1280
1281    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1282    let _watcher = {
1283        let tx = tx.clone();
1284        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1285            if let Ok(event) = res {
1286                if matches!(
1287                    event.kind,
1288                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1289                ) {
1290                    let has_harn = event
1291                        .paths
1292                        .iter()
1293                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1294                    if has_harn {
1295                        let _ = tx.blocking_send(());
1296                    }
1297                }
1298            }
1299        })
1300        .unwrap_or_else(|e| {
1301            eprintln!("Error setting up file watcher: {e}");
1302            process::exit(1);
1303        });
1304        watcher
1305            .watch(watch_dir, RecursiveMode::Recursive)
1306            .unwrap_or_else(|e| {
1307                eprintln!("Error watching directory: {e}");
1308                process::exit(1);
1309            });
1310        watcher // keep alive
1311    };
1312
1313    eprintln!(
1314        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1315        watch_dir.display()
1316    );
1317
1318    loop {
1319        rx.recv().await;
1320        // Debounce: let bursts of events settle for 200ms before re-running.
1321        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1322        while rx.try_recv().is_ok() {}
1323
1324        eprintln!();
1325        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1326        run_file(
1327            path,
1328            false,
1329            denied_builtins.clone(),
1330            Vec::new(),
1331            CliLlmMockMode::Off,
1332            None,
1333            RunProfileOptions::default(),
1334        )
1335        .await;
1336    }
1337}
1338
1339#[cfg(test)]
1340mod tests;