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