Skip to main content

harness_lock_core/
launch.rs

1//! `hlock launch`: start a harness session that matches a lock (SPEC §7.8.6).
2//!
3//! [`prepare`] verifies the installed harness, materializes the locked setup
4//! into an isolated config root (the same path as `hlock sync --prefix`), and
5//! returns the command and environment to `exec`. Launching a different harness
6//! than the lock's translates the portable components (MCP servers, skills,
7//! instruction files, and the model where the target can reach it) into the
8//! target's formats, and reports what doesn't carry over.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12
13use serde::Serialize;
14use serde_json::{json, Map, Value};
15
16use crate::capture::{live_mcp_servers, read_config, render_config, tree_digest};
17use crate::error::{Error, Result};
18use crate::lockfile::{Lockfile, Tool};
19use crate::profiles::{probe_harness, Ctx, HarnessProfile, Registry};
20use crate::sync::{self, copy_tree, lock_dir_of, placeholders, pretty, set_pointer, SyncOptions};
21
22/// Default directory for isolated launch environments.
23pub const ENV_DIR: &str = ".hlock/env";
24
25#[derive(Debug, Clone, Default)]
26pub struct LaunchOptions {
27    /// Harness to launch (default: the lock's).
28    pub target: Option<String>,
29    /// Where isolated config roots live (default [`ENV_DIR`]).
30    pub prefix: Option<PathBuf>,
31    /// Launch even if the installed version differs from the lock.
32    pub allow_version_mismatch: bool,
33    /// Use the user's own config instead of an isolated root (same harness only).
34    pub in_place: bool,
35    /// Where a code harness works (default: the project directory).
36    pub workspace: Option<PathBuf>,
37    /// Run this prompt non-interactively with the profile's `[launch] prompt` arguments.
38    pub prompt: Option<String>,
39}
40
41/// What to `exec`, and what happened while preparing it.
42#[derive(Debug, Clone, Serialize, Default)]
43pub struct LaunchPrep {
44    pub harness: String,
45    pub harness_name: String,
46    /// `cli` or `custom` (a code harness).
47    pub kind: String,
48    pub argv: Vec<String>,
49    pub env: BTreeMap<String, String>,
50    pub root: Option<PathBuf>,
51    /// True when the lock is for another harness and its portable parts were translated.
52    pub translated: bool,
53    /// Things done while preparing (files written, plugins installed).
54    pub done: Vec<String>,
55    /// Things to know: what didn't carry over, version notes.
56    pub notes: Vec<String>,
57    pub missing_secrets: Vec<String>,
58}
59
60/// The locked model to pass on the command line, if the target can use it.
61fn model_arg(lock: &Lockfile, target: &HarnessProfile, notes: &mut Vec<String>) -> Option<String> {
62    let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first())?;
63    if matches!(llm.provider.as_str(), "reference" | "unknown") || llm.model == "harness-default" {
64        return None;
65    }
66    if target.model.format.as_deref() == Some("provider/model") || target.providers.is_some() {
67        if target.providers.is_some() && !matches!(llm.provider.as_str(), "anthropic" | "openai" | "google") {
68            notes.push(format!("{} must have a provider named {:?} configured for {}", target.name, llm.provider, llm.model));
69        }
70        return Some(format!("{}/{}", llm.provider, llm.model));
71    }
72    match target.model.default_provider.as_deref() {
73        Some(p) if p == llm.provider => Some(llm.model.clone()),
74        Some(p) => {
75            notes.push(format!("{} talks to {p}, so the locked model {}:{} isn't passed; it uses its own default", target.name, llm.provider, llm.model));
76            None
77        }
78        None => Some(llm.model.clone()),
79    }
80}
81
82/// One MCP server in a target harness's config shape.
83pub fn mcp_entry(t: &Tool, shape: &str) -> Value {
84    let remote = matches!(t.transport.as_deref(), Some("http") | Some("sse")) && t.url.is_some();
85    let env = json!(t.env);
86    let headers = json!(t.headers);
87    let enabled = t.enabled != Some(false);
88    match (shape, remote) {
89        ("opencode", false) => {
90            let mut cmd = vec![t.command.clone().unwrap_or_default()];
91            cmd.extend(t.args.iter().cloned());
92            let mut e = json!({"type": "local", "command": cmd, "enabled": enabled});
93            if !t.env.is_empty() {
94                e["environment"] = env;
95            }
96            e
97        }
98        ("opencode", true) => {
99            let mut e = json!({"type": "remote", "url": t.url, "enabled": enabled});
100            if !t.headers.is_empty() {
101                e["headers"] = headers;
102            }
103            e
104        }
105        ("goose", false) => json!({"type": "stdio", "name": t.name, "cmd": t.command, "args": t.args, "envs": env, "enabled": enabled, "timeout": 300}),
106        ("goose", true) => json!({"type": "streamable_http", "name": t.name, "uri": t.url, "headers": headers, "enabled": enabled, "timeout": 300}),
107        ("codex", true) => {
108            let mut e = json!({"url": t.url});
109            if !t.headers.is_empty() {
110                e["http_headers"] = headers;
111            }
112            e
113        }
114        (_, true) => {
115            let mut e = json!({"type": t.transport, "url": t.url});
116            if !t.headers.is_empty() {
117                e["headers"] = headers;
118            }
119            e
120        }
121        (_, false) => {
122            let mut e = json!({"command": t.command, "args": t.args});
123            if !t.env.is_empty() {
124                e["env"] = env;
125            }
126            e
127        }
128    }
129}
130
131/// Fill `${HLOCK_SECRET_*}` in a tool from the environment, else from the same
132/// server's entry in the source harness's live config.
133fn fill_tool(t: &Tool, live: Option<&Value>, ctx: &Ctx, missing: &mut BTreeSet<String>) -> Tool {
134    let mut t = t.clone();
135    let lookup = |section: &[&str], key: &str| -> Option<String> {
136        let entry = live?;
137        section.iter().find_map(|s| entry.get(*s).and_then(|m| m.get(key)).and_then(Value::as_str).map(str::to_string))
138    };
139    let mut fill = |v: &str, live_value: Option<String>| -> String {
140        let names = placeholders(v);
141        if names.is_empty() {
142            return v.to_string();
143        }
144        let mut out = v.to_string();
145        let mut unfilled = false;
146        for n in &names {
147            match ctx.var(n) {
148                Some(val) => out = out.replace(&format!("${{{n}}}"), val),
149                None => unfilled = true,
150            }
151        }
152        if unfilled {
153            if let Some(lv) = live_value.filter(|x| placeholders(x).is_empty()) {
154                return lv;
155            }
156            missing.extend(names.into_iter().filter(|n| ctx.var(n).is_none()));
157        }
158        out
159    };
160    t.env = t.env.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["env", "environment", "envs"], k)))).collect();
161    t.headers = t.headers.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["headers", "http_headers"], k)))).collect();
162    if let Some(u) = &t.url {
163        let live_url = live.and_then(|e| ["url", "serverUrl", "httpUrl", "uri"].iter().find_map(|k| e.get(*k).and_then(Value::as_str)).map(str::to_string));
164        t.url = Some(fill(u, live_url));
165    }
166    t
167}
168
169fn write_pointer(path: &Path, format: &str, ptr: &str, value: Value) -> Result<()> {
170    let mut doc = read_config(path, format)?.filter(Value::is_object).unwrap_or_else(|| json!({}));
171    set_pointer(&mut doc, ptr, value);
172    if let Some(parent) = path.parent() {
173        std::fs::create_dir_all(parent)?;
174    }
175    let fmt = if format == "jsonc" { "json" } else { format };
176    std::fs::write(path, render_config(&doc, fmt).map_err(Error::Profile)?)?;
177    Ok(())
178}
179
180/// Translate a lock's portable components into `target`'s isolated root.
181fn translate(lock: &Lockfile, lock_path: &Path, source: Option<&HarnessProfile>, target: &HarnessProfile, t: &Ctx, root: &Path, ctx: &Ctx, prep: &mut LaunchPrep) -> Result<()> {
182    let lock_dir = lock_dir_of(lock_path, ctx);
183    let source_root = source.map(|s| s.config_root(ctx));
184
185    // MCP servers
186    let tools: Vec<&Tool> = lock.tool.iter().filter(|x| x.kind == "mcp").collect();
187    if !tools.is_empty() {
188        match target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
189            Some((file, ptr)) => {
190                let spec = target.files.get(file).ok_or_else(|| Error::Profile(format!("{} profile: mcp.write names unknown file {file:?}", target.id)))?;
191                let path = target.file_path(file, t).unwrap_or_default();
192                let live = source.map(|s| live_mcp_servers(s, ctx)).unwrap_or_default();
193                let shape = target.mcp.shape.as_deref().unwrap_or("standard");
194                let mut missing = BTreeSet::new();
195                let servers: Map<String, Value> = tools
196                    .iter()
197                    .filter(|x| x.enabled != Some(false) || matches!(shape, "opencode" | "goose"))
198                    .map(|x| (x.name.clone(), mcp_entry(&fill_tool(x, live.get(&x.name), ctx, &mut missing), shape)))
199                    .collect();
200                let n = servers.len();
201                write_pointer(&path, &spec.format, ptr, Value::Object(servers))?;
202                prep.done.push(format!("wrote {n} MCP server{} to {}", if n == 1 { "" } else { "s" }, pretty(&path, ctx)));
203                prep.missing_secrets.extend(missing);
204            }
205            None => prep.notes.push(format!("{} has no MCP config hlock can write; MCP servers weren't carried over", target.name)),
206        }
207    }
208
209    // skills
210    if let Some(write) = &target.skills.write {
211        let dir = t.expand(write, Some(root));
212        let mut copied = 0;
213        for s in lock.skill.iter().filter(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled")) {
214            let src = s
215                .vendored
216                .as_ref()
217                .map(|v| lock_dir.join(v))
218                .filter(|p| p.join("SKILL.md").is_file())
219                .or_else(|| s.path.as_ref().map(|p| ctx.expand(p, source_root.as_deref())).filter(|p| p.join("SKILL.md").is_file()));
220            let Some(src) = src else {
221                prep.notes.push(format!("skill {} has no local copy; run hlock sync first", s.name));
222                continue;
223            };
224            let dest = dir.join(&s.name);
225            if dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest {
226                continue;
227            }
228            let _ = std::fs::remove_dir_all(&dest);
229            copy_tree(&src, &dest)?;
230            copied += 1;
231        }
232        if copied > 0 {
233            prep.done.push(format!("copied {copied} skill{} to {}", if copied == 1 { "" } else { "s" }, pretty(&dir, ctx)));
234        }
235        let skipped = lock.skill.iter().filter(|s| matches!(s.origin.as_str(), "harness-builtin" | "bundled")).count();
236        if skipped > 0 {
237            prep.notes.push(format!("{skipped} skills that ship with the source harness or its plugins don't carry over"));
238        }
239    } else if !lock.skill.is_empty() {
240        prep.notes.push(format!("{} has no skills directory; skills weren't carried over", target.name));
241    }
242
243    // user-level instruction files → the target's user-level instruction file
244    let user_ins: Vec<_> = lock.instructions.iter().filter(|i| i.scope == "user").collect();
245    if let Some(first) = user_ins.first() {
246        let dest_tpl = target.instructions.iter().find(|i| i.starts_with("{root}/"));
247        let src = first.vendored.as_ref().map(|v| lock_dir.join(v)).or_else(|| Some(ctx.expand(&first.name, source_root.as_deref()))).filter(|p| p.is_file());
248        match (dest_tpl, src) {
249            (Some(tpl), Some(src)) => {
250                let dest = t.expand(tpl, Some(root));
251                if let Some(parent) = dest.parent() {
252                    std::fs::create_dir_all(parent)?;
253                }
254                std::fs::copy(&src, &dest)?;
255                prep.done.push(format!("wrote {} from {}", pretty(&dest, ctx), first.name));
256            }
257            _ => prep.notes.push(format!("user instructions {} weren't carried over", first.name)),
258        }
259    }
260
261    // what doesn't carry
262    if !lock.native.is_empty() {
263        let names: Vec<&str> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.as_str()).collect();
264        if !names.is_empty() {
265            prep.notes.push(format!("plugins don't carry across harnesses: {}", names.join(", ")));
266        }
267    }
268    if lock.config.iter().any(|c| c.restore != "record") {
269        prep.notes.push(format!("{}-specific settings (permissions, hooks, theme, providers) don't carry over", source.map(|s| s.name.as_str()).unwrap_or("source")));
270    }
271    Ok(())
272}
273
274/// Where a lock's isolated environments live: [`ENV_DIR`], or `ENV_DIR/<stem>` for a
275/// lock not named `harness.lock`, so two locks never share a root.
276pub fn env_prefix(lock_path: &Path, ctx: &Ctx) -> PathBuf {
277    let dir = match lock_path.file_stem().and_then(|s| s.to_str()) {
278        Some("harness") | None => PathBuf::from(ENV_DIR),
279        Some(stem) => PathBuf::from(ENV_DIR).join(stem),
280    };
281    ctx.cwd.join(dir)
282}
283
284/// Prepare a launch: verify, materialize, and build the command to `exec`.
285/// `args` are passed through to the harness (or, for a code harness, joined into the prompt).
286pub fn prepare(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LaunchOptions, args: &[String]) -> Result<LaunchPrep> {
287    let source = reg.harness(&lock.harness.name);
288    let target_name = opts.target.clone().unwrap_or_else(|| lock.harness.name.clone());
289    let target = reg.require_harness(&target_name)?;
290    let same = source.is_some_and(|s| s.id == target.id);
291    let mut prep = LaunchPrep { harness: target.id.clone(), harness_name: target.name.clone(), kind: target.kind.clone(), translated: !same, ..Default::default() };
292
293    if target.kind != "cli" {
294        let workspace = opts.workspace.clone().unwrap_or_else(|| ctx.cwd.clone());
295        let prompt = args.join(" ");
296        let inv = crate::contract::invocation(target, Some(lock), Some(lock_path), None, &prompt, &workspace, 0, ctx)?;
297        prep.argv = inv.argv;
298        prep.env = inv.env;
299        return Ok(prep);
300    }
301
302    let probe = probe_harness(target, ctx);
303    let bin = probe.binary.clone().ok_or_else(|| {
304        let hint = if same { "run `hlock sync --install`" } else { "install it first" };
305        Error::Profile(format!("{} isn't installed; {hint}", target.name))
306    })?;
307    if same {
308        if let (Some(want), Some(have)) = (&lock.harness.version, &probe.version) {
309            if want != have {
310                if !opts.allow_version_mismatch {
311                    return Err(Error::Profile(format!(
312                        "{} {have} is installed but the lock pins {want}; install {want} (hlock sync --install) or pass --allow-version-mismatch",
313                        target.name
314                    )));
315                }
316                prep.notes.push(format!("running {} {have}; the lock pins {want}", target.name));
317            }
318        }
319    } else {
320        prep.notes.push(format!("translating a {} lock into {}", source.map(|s| s.name.as_str()).unwrap_or(&lock.harness.name), target.name));
321    }
322
323    let mut argv = vec![bin];
324    argv.extend(target.launch.args.iter().cloned());
325    // the prompt arguments come first: `opencode run`, `codex exec` take flags after them
326    if let Some(prompt) = &opts.prompt {
327        if target.launch.prompt.is_empty() {
328            return Err(Error::Profile(format!(
329                "the {} profile doesn't say how to run a prompt non-interactively; add `prompt` to its [launch] table, or use hlock launch",
330                target.name
331            )));
332        }
333        argv.extend(target.launch.prompt.iter().map(|a| a.replace("{prompt}", prompt)));
334    }
335    let has_config = lock.config.iter().any(|c| c.restore != "record");
336
337    if same && opts.in_place {
338        prep.notes.push("using your own config (no isolation)".into());
339    } else {
340        let prefix = match opts.prefix.clone() {
341            Some(p) if p.is_absolute() => p,
342            Some(p) => ctx.cwd.join(p),
343            None => env_prefix(lock_path, ctx),
344        };
345        let sopts = SyncOptions { prefix: Some(prefix), ..Default::default() };
346        let (t, _, iso) = sync::target_ctx(target, ctx, &sopts);
347        let root = target.config_root(&t);
348        std::fs::create_dir_all(&root)?;
349        if same {
350            let plan = sync::plan(lock, lock_path, reg, ctx, &sopts)?;
351            for st in plan.steps.iter().filter(|s| s.status == "auto" && matches!(s.kind.as_str(), "config" | "skill" | "instructions" | "plugin")) {
352                match sync::run_step(st, ctx) {
353                    Ok(_) => prep.done.push(st.title.clone()),
354                    Err(e) => prep.notes.push(format!("{}: {e}", st.title)),
355                }
356            }
357            for st in plan.steps.iter().filter(|s| s.kind == "secret" && s.status == "manual") {
358                prep.missing_secrets.push(st.id.trim_start_matches("secret:").to_string());
359            }
360        } else {
361            translate(lock, lock_path, source, target, &t, &root, ctx, &mut prep)?;
362        }
363        prep.env = iso;
364        prep.root = Some(root);
365    }
366    // pinned locks restore the model through config; otherwise pass it explicitly
367    if !same || !has_config {
368        if let (Some(flag), Some(m)) = (target.launch.model_flag.clone(), model_arg(lock, target, &mut prep.notes)) {
369            argv.push(flag);
370            argv.push(m);
371        }
372    }
373    argv.extend(args.iter().cloned());
374    prep.argv = argv;
375    prep.missing_secrets.sort();
376    prep.missing_secrets.dedup();
377    Ok(prep)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn mcp_shapes() {
386        let stdio = Tool { name: "gh".into(), kind: "mcp".into(), transport: Some("stdio".into()), command: Some("npx".into()), args: vec!["pkg".into()], env: [("T".to_string(), "v".to_string())].into(), ..Default::default() };
387        let http = Tool { name: "r".into(), kind: "mcp".into(), transport: Some("http".into()), url: Some("https://x/mcp".into()), headers: [("Authorization".to_string(), "Bearer t".to_string())].into(), ..Default::default() };
388        assert_eq!(mcp_entry(&stdio, "standard"), json!({"command": "npx", "args": ["pkg"], "env": {"T": "v"}}));
389        assert_eq!(mcp_entry(&stdio, "opencode"), json!({"type": "local", "command": ["npx", "pkg"], "environment": {"T": "v"}, "enabled": true}));
390        assert_eq!(mcp_entry(&http, "opencode")["type"], "remote");
391        assert_eq!(mcp_entry(&http, "codex"), json!({"url": "https://x/mcp", "http_headers": {"Authorization": "Bearer t"}}));
392        assert_eq!(mcp_entry(&stdio, "goose")["cmd"], "npx");
393        assert_eq!(mcp_entry(&http, "standard")["type"], "http");
394    }
395
396    #[test]
397    fn secrets_from_live_config() {
398        let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: BTreeMap::new(), path: vec![] };
399        let t = Tool { name: "gh".into(), kind: "mcp".into(), env: [("TOKEN".to_string(), "${HLOCK_SECRET_GH_TOKEN}".to_string())].into(), ..Default::default() };
400        let live = json!({"command": "npx", "env": {"TOKEN": "real"}});
401        let mut missing = BTreeSet::new();
402        assert_eq!(fill_tool(&t, Some(&live), &ctx, &mut missing).env["TOKEN"], "real");
403        assert!(missing.is_empty());
404        let filled = fill_tool(&t, None, &ctx, &mut missing);
405        assert_eq!(filled.env["TOKEN"], "${HLOCK_SECRET_GH_TOKEN}");
406        assert!(missing.contains("HLOCK_SECRET_GH_TOKEN"));
407    }
408}