1use 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
29pub const ENTRY_SHIM: &str = include_str!("entry_shim.py");
31
32#[derive(Debug, Clone)]
34pub struct Invocation {
35 pub argv: Vec<String>,
36 pub env: BTreeMap<String, String>,
37 pub cwd: PathBuf,
38 pub output: PathBuf,
40}
41
42#[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
59fn 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
81pub 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
110pub 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 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 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 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 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
207pub 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 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
237const MAX_RESULT_FILE: usize = 16 << 20;
239
240pub struct CommandAdapter {
242 profile: HarnessProfile,
243 ctx: Ctx,
244 deciders: BTreeMap<String, crate::decide::DeciderSpec>,
245 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 pub fn with_lock(mut self, lock: Option<(crate::lockfile::Lockfile, PathBuf)>) -> Self {
256 self.lock = lock;
257 self
258 }
259
260 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 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 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 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 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}