Skip to main content

harness_lock_core/
contract.rs

1//! The execution contract for code harnesses (SPEC §7.8.3): how hlock runs a
2//! framework agent or a custom script declared under `[harnesses.<id>]`.
3//!
4//! hlock passes everything through environment variables (`HLOCK_PROMPT`,
5//! `HLOCK_MODEL`, `HLOCK_LLM_BASE_URL`, `HLOCK_WORKSPACE`, `HLOCK_MCP_CONFIG`,
6//! `HLOCK_SKILLS_DIR`, `HLOCK_LOCK`, `HLOCK_OUTPUT`, `HLOCK_SEED`) and reads an
7//! optional JSON result from `HLOCK_OUTPUT`; without one, exit code 0 means
8//! success. A Python `entrypoint` runs through a small shim (`entry_shim.py`,
9//! passed with `python -c`), so both kinds share one path and the agent's
10//! interpreter needs nothing from hlock installed. [`CommandAdapter`] plugs this into the resolver,
11//! so `resolve` evaluates code harnesses for real.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant};
17
18use serde_json::{json, Map, Value};
19
20use crate::adapters::{HarnessAdapter, RunOutcome};
21use crate::error::{Error, Result};
22use crate::fsutil;
23use crate::lockfile::{Lockfile, Tool};
24use crate::process::{self, Limits};
25use crate::profiles::{python_for, Ctx, HarnessProfile};
26use crate::search::Stack;
27use crate::tasks::Task;
28
29/// The Python shim that calls `module:callable(prompt, ctx)` and writes the result.
30pub const ENTRY_SHIM: &str = include_str!("entry_shim.py");
31
32/// A ready-to-run code-harness invocation.
33#[derive(Debug, Clone)]
34pub struct Invocation {
35    pub argv: Vec<String>,
36    pub env: BTreeMap<String, String>,
37    pub cwd: PathBuf,
38    /// Where the harness may write its JSON result.
39    pub output: PathBuf,
40}
41
42/// The result of one run.
43#[derive(Debug, Clone, Default, serde::Serialize)]
44pub struct RunResult {
45    pub success: bool,
46    pub exit_code: Option<i32>,
47    pub output: Option<Value>,
48    pub cost_usd: f64,
49    pub steps: u64,
50    pub latency_s: f64,
51    pub trace: Value,
52    pub log: String,
53}
54
55fn subst(s: &str, vars: &[(&str, &str)]) -> String {
56    vars.iter().fold(s.to_string(), |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
57}
58
59/// Replace `${HLOCK_SECRET_…}` placeholders in a string with their values, from the
60/// environment or the user's secret sources ([`crate::secrets`]). Any other `${…}` is
61/// left as it is, so a lock can't copy arbitrary variables into a config file.
62fn fill_env(s: &str, ctx: &Ctx) -> String {
63    let mut out = s.to_string();
64    let mut start = 0;
65    while let Some(i) = out[start..].find("${") {
66        let i = start + i;
67        let Some(j) = out[i..].find('}') else { break };
68        let name = out[i + 2..i + j].to_string();
69        let found = if name.starts_with(crate::secrets::PREFIX) { crate::secrets::lookup(&name, ctx).map(|f| f.value) } else { None };
70        match found {
71            Some(v) => {
72                out.replace_range(i..i + j + 1, &v);
73                start = i + v.len();
74            }
75            None => start = i + j + 1,
76        }
77    }
78    out
79}
80
81/// A standard `mcpServers` document for locked MCP servers (secrets filled from the environment).
82pub fn mcp_config(tools: &[Tool], ctx: &Ctx) -> Value {
83    let mut servers = Map::new();
84    for t in tools.iter().filter(|t| t.kind == "mcp" && t.enabled != Some(false)) {
85        let fill_map = |m: &BTreeMap<String, String>| -> Value {
86            Value::Object(m.iter().map(|(k, v)| (k.clone(), json!(fill_env(v, ctx)))).collect())
87        };
88        let entry = match (t.transport.as_deref(), &t.command, &t.url) {
89            (Some("http") | Some("sse"), _, Some(url)) => {
90                let mut e = json!({"type": t.transport, "url": fill_env(url, ctx)});
91                if !t.headers.is_empty() {
92                    e["headers"] = fill_map(&t.headers);
93                }
94                e
95            }
96            (_, Some(cmd), _) => {
97                let mut e = json!({"command": cmd, "args": t.args.iter().map(|a| fill_env(a, ctx)).collect::<Vec<_>>()});
98                if !t.env.is_empty() {
99                    e["env"] = fill_map(&t.env);
100                }
101                e
102            }
103            _ => continue,
104        };
105        servers.insert(t.name.clone(), entry);
106    }
107    json!({ "mcpServers": servers })
108}
109
110/// Build the invocation for a code harness. `model` is `provider:model`; when
111/// `None` it comes from the lock's first LLM role.
112pub fn invocation(
113    profile: &HarnessProfile,
114    lock: Option<&Lockfile>,
115    lock_path: Option<&Path>,
116    model: Option<&str>,
117    prompt: &str,
118    workspace: &Path,
119    seed: u64,
120    ctx: &Ctx,
121) -> Result<Invocation> {
122    // the harness runs inside the workspace, so every path it gets must be absolute
123    let workspace = &if workspace.is_absolute() { workspace.to_path_buf() } else { ctx.cwd.join(workspace) };
124    let hl = workspace.join(".hlock");
125    fsutil::clear_symlink(&hl)?;
126    std::fs::create_dir_all(&hl)?;
127    let output = hl.join("result.json");
128    let _ = std::fs::remove_file(&output);
129
130    let llm = lock.and_then(|l| l.llm.iter().find(|x| x.role == "default").or_else(|| l.llm.first()));
131    let model = model.map(str::to_string).or_else(|| llm.map(|l| format!("{}:{}", l.provider, l.model))).unwrap_or_default();
132    let base_url = llm.and_then(|l| l.endpoint.clone()).or_else(|| ctx.var("HL_LLM_BASE_URL").map(str::to_string));
133
134    let mut env: BTreeMap<String, String> = profile.run.env.clone();
135    env.insert("HLOCK_PROMPT".into(), prompt.into());
136    env.insert("HLOCK_MODEL".into(), model.clone());
137    env.insert("HLOCK_WORKSPACE".into(), workspace.to_string_lossy().into_owned());
138    env.insert("HLOCK_OUTPUT".into(), output.to_string_lossy().into_owned());
139    env.insert("HLOCK_SEED".into(), seed.to_string());
140    env.insert("HLOCK_HARNESS".into(), profile.id.clone());
141    if let Some(b) = &base_url {
142        env.insert("HLOCK_LLM_BASE_URL".into(), b.clone());
143    }
144    if let Some(l) = lock {
145        let deciders: Vec<(String, crate::decide::DeciderConfig)> = l.decider.iter().map(|d| (d.use_.clone(), crate::decide::entry_config_for(d, crate::decide::own_spec(&ctx.cwd, &d.use_).as_ref()))).collect();
146        let var = |k: &str| ctx.var(k).map(str::to_string);
147        env.extend(crate::decide::decider_env(&deciders, &profile.decider_env, &var));
148        if let (false, Some(p)) = (l.decider.is_empty(), lock_path) {
149            // `hlock decide hook <use> --format plugin --lock $HLOCK_DECIDE_LOCK` asks them
150            let abs = if p.is_absolute() { p.to_path_buf() } else { ctx.cwd.join(p) };
151            env.insert("HLOCK_DECIDE_LOCK".into(), abs.to_string_lossy().into_owned());
152            env.insert("HLOCK_BIN".into(), ctx.var("HLOCK_BIN").unwrap_or("hlock").to_string());
153        }
154    }
155    if let Some(p) = lock_path {
156        let abs = if p.is_absolute() { p.to_path_buf() } else { ctx.cwd.join(p) };
157        env.insert("HLOCK_LOCK".into(), abs.to_string_lossy().into_owned());
158    }
159    if let Some(l) = lock {
160        if l.tool.iter().any(|t| t.kind == "mcp") {
161            let path = hl.join("mcp.json");
162            // it holds filled-in secrets
163            fsutil::write_private(&path, serde_json::to_string_pretty(&mcp_config(&l.tool, ctx))?)?;
164            env.insert("HLOCK_MCP_CONFIG".into(), path.to_string_lossy().into_owned());
165        }
166        let lock_dir = lock_path.and_then(Path::parent).map(|d| if d.is_absolute() { d.to_path_buf() } else { ctx.cwd.join(d) }).unwrap_or_else(|| ctx.cwd.clone());
167        let mut skills: Vec<(String, PathBuf)> = Vec::new();
168        for s in &l.skill {
169            let Some(dir) = s.vendored.as_ref().map(|v| lock_dir.join(v)).or_else(|| s.path.as_ref().map(|p| ctx.expand(p, None))) else { continue };
170            if dir.join("SKILL.md").is_file() {
171                fsutil::check_plain_name("skill name", &s.name)?;
172                skills.push((s.name.clone(), dir));
173            }
174        }
175        if !skills.is_empty() {
176            let dir = hl.join("skills");
177            let _ = std::fs::remove_dir_all(&dir);
178            for (name, src) in skills {
179                fsutil::copy_tree(&src, &dir.join(name))?;
180            }
181            env.insert("HLOCK_SKILLS_DIR".into(), dir.to_string_lossy().into_owned());
182        }
183    }
184
185    let vars = [("prompt", prompt), ("model", model.as_str()), ("workspace", &*workspace.to_string_lossy())];
186    let argv: Vec<String> = if let Some(ep) = &profile.run.entrypoint {
187        let py = python_for(profile.run.python.as_deref(), ctx).ok_or_else(|| Error::Profile("no Python interpreter found (set HL_PYTHON)".into()))?;
188        // the project directory must be importable
189        let pp = match ctx.var("PYTHONPATH") {
190            Some(old) => format!("{}{}{old}", ctx.cwd.display(), if cfg!(windows) { ";" } else { ":" }),
191            None => ctx.cwd.to_string_lossy().into_owned(),
192        };
193        env.insert("PYTHONPATH".into(), pp);
194        vec![py.to_string_lossy().into_owned(), "-c".into(), ENTRY_SHIM.into(), ep.clone()]
195    } else if !profile.run.command.is_empty() {
196        let mut a: Vec<String> = profile.run.command.iter().map(|x| subst(x, &vars)).collect();
197        if a[0].contains('/') && !Path::new(&a[0]).is_absolute() {
198            a[0] = ctx.cwd.join(&a[0]).to_string_lossy().into_owned();
199        }
200        a
201    } else {
202        return Err(Error::Profile(format!("harness {:?} has no entrypoint or command", profile.id)));
203    };
204    Ok(Invocation { argv, env, cwd: workspace.to_path_buf(), output })
205}
206
207/// Run an invocation to completion, capturing output, with a timeout.
208pub fn run(inv: &Invocation, ctx: &Ctx, timeout: Duration) -> Result<RunResult> {
209    let start = Instant::now();
210    let Some((prog, args)) = inv.argv.split_first() else {
211        return Err(Error::Profile("the harness invocation has no command".into()));
212    };
213    let mut cmd = Command::new(prog);
214    cmd.args(args).current_dir(&inv.cwd).env_clear().envs(&ctx.env).envs(&inv.env).stdin(Stdio::null());
215    let out = process::run(cmd, &Limits::timeout(timeout)).map_err(|e| Error::Profile(format!("cannot run {prog}: {e}")))?;
216    let timed_out = out.timed_out;
217    let mut log = out.text();
218    // the harness wrote it into its workspace: read it as a plain file, never through a symlink
219    let parsed: Option<Value> = fsutil::read_regular_text(&inv.output, MAX_RESULT_FILE).ok().and_then(|t| serde_json::from_str(&t).ok());
220    let exit_ok = out.success && !timed_out;
221    let success = parsed.as_ref().and_then(|v| v.get("success")).and_then(Value::as_bool).unwrap_or(exit_ok) && !timed_out;
222    if timed_out {
223        log.push_str(&format!("\n[hlock] timed out after {}s", timeout.as_secs()));
224    }
225    Ok(RunResult {
226        success,
227        exit_code: out.code,
228        cost_usd: parsed.as_ref().and_then(|v| v["cost_usd"].as_f64()).unwrap_or(0.0),
229        steps: parsed.as_ref().and_then(|v| v["steps"].as_u64()).unwrap_or(1),
230        trace: parsed.as_ref().and_then(|v| v.get("trace").cloned()).unwrap_or_else(|| json!([])),
231        latency_s: (start.elapsed().as_secs_f64() * 1e3).round() / 1e3,
232        output: parsed,
233        log,
234    })
235}
236
237/// The most of a harness's JSON result hlock reads.
238const MAX_RESULT_FILE: usize = 16 << 20;
239
240/// Runs trials through a code harness's entrypoint, for `resolve` and `tune`.
241pub struct CommandAdapter {
242    profile: HarnessProfile,
243    ctx: Ctx,
244    deciders: BTreeMap<String, crate::decide::DeciderSpec>,
245    /// A lock whose skills and MCP servers each trial gets (`hlock add --eval`).
246    lock: Option<(crate::lockfile::Lockfile, PathBuf)>,
247}
248
249impl CommandAdapter {
250    pub fn new(profile: HarnessProfile, ctx: Ctx) -> Self {
251        CommandAdapter { profile, ctx, deciders: BTreeMap::new(), lock: None }
252    }
253
254    /// Give each trial a lock's skills and MCP servers, as `hlock launch` would.
255    pub fn with_lock(mut self, lock: Option<(crate::lockfile::Lockfile, PathBuf)>) -> Self {
256        self.lock = lock;
257        self
258    }
259
260    /// The manifest's `[deciders.*]`, for the endpoint settings of a stack's deciders.
261    pub fn with_deciders(mut self, deciders: BTreeMap<String, crate::decide::DeciderSpec>) -> Self {
262        self.deciders = deciders;
263        self
264    }
265}
266
267impl HarnessAdapter for CommandAdapter {
268    fn name(&self) -> &str {
269        &self.profile.id
270    }
271
272    fn run(&self, stack: &Stack, task: &Task, workspace: &Path, seed: u64) -> Result<RunOutcome> {
273        if let (Some(fixtures), Some(pack_dir)) = (&task.fixtures, &task.pack_dir) {
274            let src = pack_dir.join(fixtures);
275            if src.exists() {
276                fsutil::copy_tree(&src, workspace)?;
277            }
278        }
279        let model = stack.llm.get("planner").or_else(|| stack.llm.values().next()).cloned();
280        let (lock, lock_path) = match &self.lock {
281            Some((l, p)) => (Some(l), Some(p.as_path())),
282            None => (None, None),
283        };
284        let mut inv = invocation(&self.profile, lock, lock_path, model.as_deref(), &task.prompt_text, workspace, seed, &self.ctx)?;
285        let deciders: Vec<(String, crate::decide::DeciderConfig)> = stack
286            .deciders
287            .iter()
288            .map(|(u, m)| (u.clone(), self.deciders.get(u).map(|s| s.config(m)).unwrap_or_else(|| crate::decide::DeciderConfig::from_ref(crate::decide::split_threshold(m).0))))
289            .collect();
290        let var = |k: &str| self.ctx.var(k).map(str::to_string);
291        inv.env.extend(crate::decide::decider_env(&deciders, &self.profile.decider_env, &var));
292        if !stack.deciders.is_empty() {
293            // a lock holding just this trial's deciders, for `hlock decide hook --format plugin`
294            // a decider that can't be locked fails the trial: running without it would
295            // measure a different stack than the one being scored
296            let entries: Vec<crate::lockfile::Decider> = stack
297                .deciders
298                .iter()
299                .map(|(u, m)| {
300                    let spec = self.deciders.get(u).cloned().unwrap_or_default();
301                    crate::decide::lock_entry(u, m, &spec, "env", vec![], &self.ctx.cwd).map_err(|e| Error::Profile(format!("decider {u}: {e}")))
302                })
303                .collect::<Result<_>>()?;
304            // absolute: the harness runs in the workspace, so a relative path would miss
305            let ws = if inv.cwd.is_absolute() { inv.cwd.clone() } else { self.ctx.cwd.join(&inv.cwd) };
306            let path = ws.join(".hlock").join("deciders.lock");
307            let lock = Lockfile { decider: entries, ..Default::default() };
308            lock.save(&path)?;
309            inv.env.insert("HLOCK_DECIDE_LOCK".into(), path.to_string_lossy().into_owned());
310            inv.env.insert("HLOCK_BIN".into(), self.ctx.var("HLOCK_BIN").unwrap_or("hlock").to_string());
311        }
312        let r = run(&inv, &self.ctx, Duration::from_secs(task.timeout_seconds.max(1)))?;
313        let mut trace = r.trace.clone();
314        if let Value::Array(a) = &mut trace {
315            let tail: Vec<&str> = r.log.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect();
316            a.push(json!({"type": "harness.exit", "harness": self.profile.id, "code": r.exit_code, "latency_s": r.latency_s, "log_tail": tail.join("\n")}));
317        }
318        Ok(RunOutcome { success: r.success, cost_usd: r.cost_usd, latency_s: r.latency_s, steps: r.steps, trace })
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn substitution_and_env_fill() {
328        assert_eq!(subst("--model={model} {prompt}", &[("model", "a:b"), ("prompt", "hi")]), "--model=a:b hi");
329        let env = [("HLOCK_SECRET_TOK", "x"), ("HOME_TOKEN", "leak"), ("HLOCK_KEYCHAIN", "0")].map(|(k, v)| (k.to_string(), v.to_string()));
330        let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: env.into(), path: vec![] };
331        // only secret placeholders are filled; other variables stay as written
332        assert_eq!(fill_env("Bearer ${HLOCK_SECRET_TOK} ${HOME_TOKEN} ${HLOCK_SECRET_MISSING}", &ctx), "Bearer x ${HOME_TOKEN} ${HLOCK_SECRET_MISSING}");
333    }
334
335    fn tmp(tag: &str) -> PathBuf {
336        let d = std::env::temp_dir().join(format!("hlock-contract-{tag}-{}", std::process::id()));
337        let _ = std::fs::remove_dir_all(&d);
338        std::fs::create_dir_all(&d).unwrap();
339        d
340    }
341
342    fn inv(argv: &[&str], cwd: &Path) -> Invocation {
343        Invocation { argv: argv.iter().map(|s| s.to_string()).collect(), env: BTreeMap::new(), cwd: cwd.into(), output: cwd.join("result.json") }
344    }
345
346    #[test]
347    fn an_empty_command_is_an_error() {
348        let d = tmp("empty");
349        assert!(run(&inv(&[], &d), &Ctx::at(&d), Duration::from_secs(5)).unwrap_err().to_string().contains("no command"));
350        let _ = std::fs::remove_dir_all(&d);
351    }
352
353    #[cfg(unix)]
354    #[test]
355    fn big_output_doesnt_hang() {
356        let d = tmp("run");
357        let ctx = Ctx::at(&d);
358        let r = run(&inv(&["sh", "-c", "head -c 300000 /dev/zero | tr '\\0' x; exit 2"], &d), &ctx, Duration::from_secs(20)).unwrap();
359        assert_eq!((r.success, r.exit_code), (false, Some(2)));
360        assert!(r.log.len() >= 300_000);
361        // the timeout is tested in tests/bounded.rs, away from process::stop_all's test
362        let _ = std::fs::remove_dir_all(&d);
363    }
364
365    #[cfg(unix)]
366    #[test]
367    fn mcp_config_is_private_and_skill_names_are_checked() {
368        use std::os::unix::fs::PermissionsExt;
369        let d = tmp("inv");
370        std::fs::create_dir_all(d.join("skill")).unwrap();
371        std::fs::write(d.join("skill/SKILL.md"), "x").unwrap();
372        let ctx = Ctx { env: [("HLOCK_SECRET_T".to_string(), "s3".to_string())].into(), ..Ctx::at(&d) };
373        let profile = HarnessProfile { id: "h".into(), run: crate::profiles::RunSpec { command: vec!["true".into()], ..Default::default() }, ..Default::default() };
374        let tool = Tool { name: "gh".into(), kind: "mcp".into(), command: Some("npx".into()), env: [("T".to_string(), "${HLOCK_SECRET_T}".to_string())].into(), ..Default::default() };
375        let skill = |name: &str| crate::lockfile::Skill { name: name.into(), path: Some(d.join("skill").to_string_lossy().into_owned()), ..Default::default() };
376        let mut lock = Lockfile { tool: vec![tool], skill: vec![skill("ok")], ..Default::default() };
377        let ws = d.join("ws");
378        let i = invocation(&profile, Some(&lock), None, None, "hi", &ws, 0, &ctx).unwrap();
379        let mcp = PathBuf::from(&i.env["HLOCK_MCP_CONFIG"]);
380        assert_eq!(std::fs::metadata(&mcp).unwrap().permissions().mode() & 0o777, 0o600);
381        assert!(ws.join(".hlock/skills/ok/SKILL.md").is_file());
382        lock.skill = vec![skill("../../escape")];
383        assert!(invocation(&profile, Some(&lock), None, None, "hi", &ws, 0, &ctx).unwrap_err().to_string().contains("plain name"));
384        let _ = std::fs::remove_dir_all(&d);
385    }
386
387    #[test]
388    fn mcp_document() {
389        let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: [("HLOCK_SECRET_T".to_string(), "s3".to_string())].into(), path: vec![] };
390        let tools = vec![
391            Tool { name: "gh".into(), kind: "mcp".into(), transport: Some("stdio".into()), command: Some("npx".into()), args: vec!["pkg".into()], env: [("T".to_string(), "${HLOCK_SECRET_T}".to_string())].into(), ..Default::default() },
392            Tool { name: "remote".into(), kind: "mcp".into(), transport: Some("http".into()), url: Some("https://x/mcp".into()), ..Default::default() },
393            Tool { name: "off".into(), kind: "mcp".into(), command: Some("x".into()), enabled: Some(false), ..Default::default() },
394        ];
395        let v = mcp_config(&tools, &ctx);
396        assert_eq!(v["mcpServers"]["gh"]["env"]["T"], "s3");
397        assert_eq!(v["mcpServers"]["remote"]["type"], "http");
398        assert!(v["mcpServers"].get("off").is_none());
399    }
400}