use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::capture::{live_mcp_servers, read_config, render_config, tree_digest};
use crate::error::{Error, Result};
use crate::fsutil;
use crate::lockfile::{Lockfile, Tool};
use crate::profiles::{probe_harness, Ctx, HarnessProfile, Registry};
use crate::secrets;
use crate::sync::{self, lock_dir_of, placeholders, pretty, set_pointer, SyncOptions};
pub const ENV_DIR: &str = ".hlock/env";
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
pub target: Option<String>,
pub prefix: Option<PathBuf>,
pub allow_version_mismatch: bool,
pub in_place: bool,
pub workspace: Option<PathBuf>,
pub prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct LaunchPrep {
pub harness: String,
pub harness_name: String,
pub kind: String,
pub argv: Vec<String>,
pub env: BTreeMap<String, String>,
pub root: Option<PathBuf>,
pub translated: bool,
pub done: Vec<String>,
pub notes: Vec<String>,
pub missing_secrets: Vec<String>,
pub secret_env: BTreeMap<String, String>,
}
fn model_arg(lock: &Lockfile, target: &HarnessProfile, notes: &mut Vec<String>) -> Option<String> {
let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first())?;
if matches!(llm.provider.as_str(), "reference" | "unknown") || llm.model == "harness-default" {
return None;
}
if target.model.format.as_deref() == Some("provider/model") || target.providers.is_some() {
if target.providers.is_some() && !matches!(llm.provider.as_str(), "anthropic" | "openai" | "google") {
notes.push(format!("{} must have a provider named {:?} configured for {}", target.name, llm.provider, llm.model));
}
return Some(format!("{}/{}", llm.provider, llm.model));
}
match target.model.default_provider.as_deref() {
Some(p) if p == llm.provider => Some(llm.model.clone()),
Some(p) => {
notes.push(format!("{} talks to {p}, so the locked model {}:{} isn't passed; it uses its own default", target.name, llm.provider, llm.model));
None
}
None => Some(llm.model.clone()),
}
}
pub fn mcp_entry(t: &Tool, shape: &str) -> Value {
let remote = matches!(t.transport.as_deref(), Some("http") | Some("sse")) && t.url.is_some();
let env = json!(t.env);
let headers = json!(t.headers);
let enabled = t.enabled != Some(false);
match (shape, remote) {
("opencode", false) => {
let mut cmd = vec![t.command.clone().unwrap_or_default()];
cmd.extend(t.args.iter().cloned());
let mut e = json!({"type": "local", "command": cmd, "enabled": enabled});
if !t.env.is_empty() {
e["environment"] = env;
}
e
}
("opencode", true) => {
let mut e = json!({"type": "remote", "url": t.url, "enabled": enabled});
if !t.headers.is_empty() {
e["headers"] = headers;
}
e
}
("goose", false) => json!({"type": "stdio", "name": t.name, "cmd": t.command, "args": t.args, "envs": env, "enabled": enabled, "timeout": 300}),
("goose", true) => json!({"type": "streamable_http", "name": t.name, "uri": t.url, "headers": headers, "enabled": enabled, "timeout": 300}),
(s @ ("openclaw" | "nanobot"), true) => {
let (key, http) = if s == "openclaw" { ("transport", "streamable-http") } else { ("type", "streamableHttp") };
let mut e = json!({key: if t.transport.as_deref() == Some("sse") { "sse" } else { http }, "url": t.url});
if !t.headers.is_empty() {
e["headers"] = headers;
}
e
}
("codex", true) => {
let mut e = json!({"url": t.url});
if !t.headers.is_empty() {
e["http_headers"] = headers;
}
e
}
(_, true) => {
let mut e = json!({"type": t.transport, "url": t.url});
if !t.headers.is_empty() {
e["headers"] = headers;
}
e
}
(_, false) => {
let mut e = json!({"command": t.command, "args": t.args});
if !t.env.is_empty() {
e["env"] = env;
}
e
}
}
}
fn fill_tool(t: &Tool, live: Option<&Value>, ctx: &Ctx, env_ref: Option<&str>, values: &mut BTreeMap<String, String>, missing: &mut BTreeSet<String>) -> Tool {
let mut t = t.clone();
let lookup = |section: &[&str], key: &str| -> Option<String> {
let entry = live?;
section.iter().find_map(|s| entry.get(*s).and_then(|m| m.get(key)).and_then(Value::as_str).map(str::to_string))
};
let mut fill = |v: &str, live_value: Option<String>| -> String {
let names = placeholders(v);
if names.is_empty() {
return v.to_string();
}
let found: BTreeMap<String, String> = names.iter().filter_map(|n| secrets::lookup(n, ctx).map(|f| (n.clone(), f.value))).collect();
if let Some(tpl) = env_ref {
let from_live = |n: &str| -> Option<String> {
let lv = live_value.as_deref().filter(|x| placeholders(x).is_empty())?;
let (pre, post) = v.split_once(&format!("${{{n}}}"))?;
(names.len() == 1 && lv.len() > pre.len() + post.len() && lv.starts_with(pre) && lv.ends_with(post)).then(|| lv[pre.len()..lv.len() - post.len()].to_string())
};
let mut out = v.to_string();
for n in &names {
match found.get(n).cloned().or_else(|| from_live(n)) {
Some(val) => {
values.insert(n.clone(), val);
}
None => {
missing.insert(n.clone());
}
}
out = out.replace(&format!("${{{n}}}"), &secrets::env_ref(tpl, n));
}
return out;
}
let mut out = v.to_string();
let mut unfilled = false;
for n in &names {
match found.get(n) {
Some(val) => out = out.replace(&format!("${{{n}}}"), val),
None => unfilled = true,
}
}
if unfilled {
if let Some(lv) = live_value.filter(|x| placeholders(x).is_empty()) {
return lv;
}
missing.extend(names.into_iter().filter(|n| !found.contains_key(n)));
}
out
};
t.env = t.env.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["env", "environment", "envs"], k)))).collect();
t.headers = t.headers.iter().map(|(k, v)| (k.clone(), fill(v, lookup(&["headers", "http_headers"], k)))).collect();
if let Some(u) = &t.url {
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));
t.url = Some(fill(u, live_url));
}
t
}
fn write_pointer(path: &Path, format: &str, ptr: &str, value: Value, private: bool) -> Result<()> {
let mut doc = read_config(path, format)?.filter(Value::is_object).unwrap_or_else(|| json!({}));
set_pointer(&mut doc, ptr, value);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let fmt = if format == "jsonc" { "json" } else { format };
let text = render_config(&doc, fmt).map_err(Error::Profile)?;
if private {
fsutil::write_private(path, text)?;
} else {
std::fs::write(path, text)?;
}
Ok(())
}
fn translate(lock: &Lockfile, lock_path: &Path, source: Option<&HarnessProfile>, target: &HarnessProfile, t: &Ctx, root: &Path, ctx: &Ctx, prep: &mut LaunchPrep) -> Result<()> {
let lock_dir = lock_dir_of(lock_path, ctx);
let source_root = source.map(|s| s.config_root(ctx));
let tools: Vec<&Tool> = lock.tool.iter().filter(|x| x.kind == "mcp").collect();
if !tools.is_empty() {
match target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
Some((file, ptr)) => {
let spec = target.files.get(file).ok_or_else(|| Error::Profile(format!("{} profile: mcp.write names unknown file {file:?}", target.id)))?;
let path = target.file_path(file, t).unwrap_or_default();
let live = source.map(|s| live_mcp_servers(s, ctx)).unwrap_or_default();
let shape = target.mcp.shape.as_deref().unwrap_or("standard");
let mut missing = BTreeSet::new();
let mut values = BTreeMap::new();
let env_ref = spec.env_ref.as_deref();
let servers: Map<String, Value> = tools
.iter()
.filter(|x| x.enabled != Some(false) || matches!(shape, "opencode" | "goose"))
.map(|x| (x.name.clone(), mcp_entry(&fill_tool(x, live.get(&x.name), ctx, env_ref, &mut values, &mut missing), shape)))
.collect();
let n = servers.len();
let private = env_ref.is_none() && tools.iter().any(|x| x.env.values().chain(x.headers.values()).chain(x.url.iter()).any(|v| !placeholders(v).is_empty()));
write_pointer(&path, &spec.format, ptr, Value::Object(servers), private)?;
prep.secret_env.extend(values);
prep.done.push(format!("wrote {n} MCP server{} to {}", if n == 1 { "" } else { "s" }, pretty(&path, ctx)));
prep.missing_secrets.extend(missing);
}
None => prep.notes.push(format!("{} has no MCP config hlock can write; MCP servers weren't carried over", target.name)),
}
}
if let Some(write) = &target.skills.write {
let dir = t.expand(write, Some(root));
let mut copied = 0;
for s in lock.skill.iter().filter(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled")) {
if !fsutil::plain_name(&s.name) {
prep.notes.push(format!("skill {:?} wasn't copied: its name must be a plain name (no '/', '\\', or '..')", s.name));
continue;
}
let src = s
.vendored
.as_ref()
.map(|v| lock_dir.join(v))
.filter(|p| p.join("SKILL.md").is_file())
.or_else(|| s.path.as_ref().map(|p| ctx.expand(p, source_root.as_deref())).filter(|p| p.join("SKILL.md").is_file()));
let Some(src) = src else {
prep.notes.push(format!("skill {} has no local copy; run hlock sync first", s.name));
continue;
};
let dest = dir.join(&s.name);
if dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest {
continue;
}
replace_dir(&src, &dest)?;
copied += 1;
}
if copied > 0 {
prep.done.push(format!("copied {copied} skill{} to {}", if copied == 1 { "" } else { "s" }, pretty(&dir, ctx)));
}
let skipped = lock.skill.iter().filter(|s| matches!(s.origin.as_str(), "harness-builtin" | "bundled")).count();
if skipped > 0 {
prep.notes.push(format!("{skipped} skills that ship with the source harness or its plugins don't carry over"));
}
} else if !lock.skill.is_empty() {
prep.notes.push(format!("{} has no skills directory; skills weren't carried over", target.name));
}
let user_ins: Vec<_> = lock.instructions.iter().filter(|i| i.scope == "user").collect();
let names: Vec<String> = user_ins.iter().map(|i| i.name.clone()).collect();
for (ins, dest_tpl) in user_ins.iter().zip(user_instruction_targets(target, &names)) {
let src = ins.vendored.as_ref().map(|v| lock_dir.join(v)).or_else(|| Some(ctx.expand(&ins.name, source_root.as_deref()))).filter(|p| p.is_file());
match (dest_tpl, src) {
(Some(tpl), Some(src)) => {
let dest = t.expand(tpl, Some(root));
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dest)?;
prep.done.push(format!("wrote {} from {}", pretty(&dest, ctx), ins.name));
}
_ => prep.notes.push(format!("user instructions {} weren't carried over", ins.name)),
}
}
if !lock.native.is_empty() {
let names: Vec<&str> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.as_str()).collect();
if !names.is_empty() {
prep.notes.push(format!("plugins don't carry across harnesses: {}", names.join(", ")));
}
}
if lock.config.iter().any(|c| c.restore != "record") {
prep.notes.push(format!("{}-specific settings (permissions, hooks, theme, providers) don't carry over", source.map(|s| s.name.as_str()).unwrap_or("source")));
}
Ok(())
}
fn replace_dir(src: &Path, dest: &Path) -> Result<()> {
let name = dest.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let tmp = dest.with_file_name(format!(".{name}.hlock-new-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
if let Err(e) = fsutil::copy_tree(src, &tmp) {
let _ = std::fs::remove_dir_all(&tmp);
return Err(e);
}
if std::fs::symlink_metadata(dest).is_ok_and(|m| m.file_type().is_symlink() || !m.is_dir()) {
std::fs::remove_file(dest)?;
} else if dest.exists() {
std::fs::remove_dir_all(dest)?;
}
std::fs::rename(&tmp, dest)?;
Ok(())
}
pub(crate) fn user_instruction_targets<'a>(target: &'a HarnessProfile, names: &[String]) -> Vec<Option<&'a str>> {
let file = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
let tpls: Vec<&str> = target.instructions.iter().map(String::as_str).filter(|i| i.starts_with("{root}/")).collect();
let mut used = BTreeSet::new();
let mut out: Vec<Option<&str>> = names
.iter()
.map(|n| tpls.iter().copied().find(|t| file(t) == file(n)).filter(|t| used.insert(*t)))
.collect();
if let Some(first) = tpls.first().filter(|t| !used.contains(*t)) {
if let Some(slot) = out.iter_mut().find(|o| o.is_none()) {
*slot = Some(first);
}
}
out
}
pub fn env_prefix(lock_path: &Path, ctx: &Ctx) -> PathBuf {
let dir = match lock_path.file_stem().and_then(|s| s.to_str()) {
Some("harness") | None => PathBuf::from(ENV_DIR),
Some(stem) => PathBuf::from(ENV_DIR).join(stem),
};
ctx.cwd.join(dir)
}
pub fn prepare(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LaunchOptions, args: &[String]) -> Result<LaunchPrep> {
let source = reg.harness(&lock.harness.name);
let target_name = opts.target.clone().unwrap_or_else(|| lock.harness.name.clone());
let target = reg.require_harness(&target_name)?;
let same = source.is_some_and(|s| s.id == target.id);
let mut prep = LaunchPrep { harness: target.id.clone(), harness_name: target.name.clone(), kind: target.kind.clone(), translated: !same, ..Default::default() };
if target.kind != "cli" {
let workspace = opts.workspace.clone().unwrap_or_else(|| ctx.cwd.clone());
let prompt = args.join(" ");
let inv = crate::contract::invocation(target, Some(lock), Some(lock_path), None, &prompt, &workspace, 0, ctx)?;
prep.argv = inv.argv;
prep.env = inv.env;
return Ok(prep);
}
let probe = probe_harness(target, ctx);
let bin = probe.binary.clone().ok_or_else(|| {
let hint = if same { "run `hlock sync --install`" } else { "install it first" };
Error::Profile(format!("{} isn't installed; {hint}", target.name))
})?;
if same {
if let (Some(want), Some(have)) = (&lock.harness.version, &probe.version) {
if want != have {
if !opts.allow_version_mismatch {
return Err(Error::Profile(format!(
"{} {have} is installed but the lock pins {want}; install {want} (hlock sync --install) or pass --allow-version-mismatch",
target.name
)));
}
prep.notes.push(format!("running {} {have}; the lock pins {want}", target.name));
}
}
} else {
prep.notes.push(format!("translating a {} lock into {}", source.map(|s| s.name.as_str()).unwrap_or(&lock.harness.name), target.name));
}
let mut argv = vec![bin];
argv.extend(target.launch.args.iter().cloned());
if let Some(prompt) = &opts.prompt {
if target.launch.prompt.is_empty() {
return Err(Error::Profile(format!(
"the {} profile doesn't say how to run a prompt non-interactively; add `prompt` to its [launch] table, or use hlock launch",
target.name
)));
}
argv.extend(target.launch.prompt.iter().map(|a| a.replace("{prompt}", prompt)));
}
let has_config = lock.config.iter().any(|c| c.restore != "record");
if same && opts.in_place {
prep.notes.push("using your own config (no isolation)".into());
} else {
let prefix = match opts.prefix.clone() {
Some(p) if p.is_absolute() => p,
Some(p) => ctx.cwd.join(p),
None => env_prefix(lock_path, ctx),
};
sync::ignore_env(&sync::env_dir(target, &prefix, ctx))?;
let sopts = SyncOptions { prefix: Some(prefix), inject: true, ..Default::default() };
let (t, _, iso) = sync::target_ctx(target, ctx, &sopts);
let root = target.config_root(&t);
std::fs::create_dir_all(&root)?;
if same {
let plan = sync::plan(lock, lock_path, reg, ctx, &sopts)?;
for st in plan.steps.iter().filter(|s| s.status == "auto" && matches!(s.kind.as_str(), "config" | "skill" | "instructions" | "plugin")) {
match sync::run_step(st, ctx) {
Ok(_) => prep.done.push(st.title.clone()),
Err(e) => prep.notes.push(format!("{}: {e}", st.title)),
}
}
for st in plan.steps.iter().filter(|s| s.kind == "secret" && s.status == "manual") {
prep.missing_secrets.push(st.id.trim_start_matches("secret:").to_string());
}
let (values, missing) = sync::secret_env(lock, lock_path, reg, ctx, &sopts)?;
prep.secret_env.extend(values);
prep.missing_secrets.extend(missing);
} else {
translate(lock, lock_path, source, target, &t, &root, ctx, &mut prep)?;
}
prep.env = iso;
prep.root = Some(root);
}
if !same || !has_config {
match (target.launch.model_flag.clone(), model_arg(lock, target, &mut prep.notes)) {
(Some(flag), Some(m)) => {
argv.push(flag);
argv.push(m);
}
(None, Some(m)) => prep.notes.push(format!("{} takes no model flag, so it uses its own configured model, not the locked {m}", target.name)),
_ => {}
}
}
argv.extend(args.iter().cloned());
prep.argv = argv;
prep.missing_secrets.sort();
prep.missing_secrets.dedup();
Ok(prep)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_shapes() {
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() };
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() };
assert_eq!(mcp_entry(&stdio, "standard"), json!({"command": "npx", "args": ["pkg"], "env": {"T": "v"}}));
assert_eq!(mcp_entry(&stdio, "opencode"), json!({"type": "local", "command": ["npx", "pkg"], "environment": {"T": "v"}, "enabled": true}));
assert_eq!(mcp_entry(&http, "opencode")["type"], "remote");
assert_eq!(mcp_entry(&http, "codex"), json!({"url": "https://x/mcp", "http_headers": {"Authorization": "Bearer t"}}));
assert_eq!(mcp_entry(&stdio, "goose")["cmd"], "npx");
assert_eq!(mcp_entry(&http, "standard")["type"], "http");
assert_eq!(mcp_entry(&http, "openclaw"), json!({"transport": "streamable-http", "url": "https://x/mcp", "headers": {"Authorization": "Bearer t"}}));
assert_eq!(mcp_entry(&stdio, "openclaw"), mcp_entry(&stdio, "standard"));
assert_eq!(mcp_entry(&http, "nanobot")["type"], "streamableHttp");
}
#[test]
fn user_instructions_land_on_their_own_files() {
let r = Registry::builtin();
let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let oc = names(&["{root}/workspace/AGENTS.md", "{root}/workspace/SOUL.md", "{root}/workspace/TOOLS.md"]);
assert_eq!(
user_instruction_targets(r.harness("nanobot").unwrap(), &oc),
[Some("{root}/workspace/AGENTS.md"), Some("{root}/workspace/SOUL.md"), None]
);
assert_eq!(user_instruction_targets(r.harness("claude-code").unwrap(), &oc), [Some("{root}/CLAUDE.md"), None, None]);
assert_eq!(user_instruction_targets(r.harness("hermes").unwrap(), &oc), [None, Some("{root}/SOUL.md"), None]);
assert_eq!(user_instruction_targets(r.harness("openclaw").unwrap(), &names(&["{root}/CLAUDE.md"])), [Some("{root}/workspace/AGENTS.md")]);
}
#[test]
fn secrets_from_live_config() {
let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: [("HLOCK_KEYCHAIN".to_string(), "0".to_string())].into(), path: vec![] };
let t = Tool { name: "gh".into(), kind: "mcp".into(), env: [("TOKEN".to_string(), "${HLOCK_SECRET_GH_TOKEN}".to_string())].into(), ..Default::default() };
let live = json!({"command": "npx", "env": {"TOKEN": "real"}});
let (mut missing, mut values) = (BTreeSet::new(), BTreeMap::new());
assert_eq!(fill_tool(&t, Some(&live), &ctx, None, &mut values, &mut missing).env["TOKEN"], "real");
assert!(missing.is_empty() && values.is_empty());
let filled = fill_tool(&t, None, &ctx, None, &mut values, &mut missing);
assert_eq!(filled.env["TOKEN"], "${HLOCK_SECRET_GH_TOKEN}");
assert!(missing.contains("HLOCK_SECRET_GH_TOKEN"));
}
fn tmp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("hlock-launch-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_failed_skill_copy_keeps_the_old_one() {
let d = tmp("replace");
std::fs::create_dir_all(d.join("dest")).unwrap();
std::fs::write(d.join("dest/SKILL.md"), "old").unwrap();
assert!(replace_dir(&d.join("missing"), &d.join("dest")).is_err());
assert_eq!(std::fs::read_to_string(d.join("dest/SKILL.md")).unwrap(), "old");
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("src/SKILL.md"), "new").unwrap();
replace_dir(&d.join("src"), &d.join("dest")).unwrap();
assert_eq!(std::fs::read_to_string(d.join("dest/SKILL.md")).unwrap(), "new");
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn skills_with_path_names_are_skipped() {
let d = tmp("skillname");
std::fs::create_dir_all(d.join("skill")).unwrap();
std::fs::write(d.join("skill/SKILL.md"), "x").unwrap();
std::fs::create_dir_all(d.join("keep")).unwrap();
let ctx = Ctx { home: d.join("home"), cwd: d.clone(), env: [("HLOCK_KEYCHAIN".to_string(), "0".to_string())].into(), path: vec![] };
let reg = Registry::builtin();
let target = reg.harness("claude-code").unwrap();
let skill = |name: &str| crate::lockfile::Skill { name: name.into(), origin: "user".into(), path: Some(d.join("skill").to_string_lossy().into_owned()), ..Default::default() };
let lock = Lockfile { skill: vec![skill("../../keep"), skill("ok")], ..Default::default() };
let root = d.join("root");
let mut prep = LaunchPrep::default();
translate(&lock, &d.join("harness.lock"), None, target, &ctx, &root, &ctx, &mut prep).unwrap();
assert!(d.join("keep").is_dir(), "a path-named skill must not replace anything");
assert!(prep.notes.iter().any(|n| n.contains("\"../../keep\" wasn't copied")), "{:?}", prep.notes);
assert!(prep.done.iter().any(|n| n.starts_with("copied 1 skill")), "{:?}", prep.done);
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn configs_with_secrets_are_private_from_the_start() {
use std::os::unix::fs::PermissionsExt;
let d = tmp("private");
write_pointer(&d.join("a.json"), "json", "/mcpServers", json!({"x": {"env": {"T": "s3"}}}), true).unwrap();
assert_eq!(std::fs::metadata(d.join("a.json")).unwrap().permissions().mode() & 0o777, 0o600);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn secrets_as_environment_references() {
let ctx = Ctx { home: "/h".into(), cwd: "/w".into(), env: [("HLOCK_KEYCHAIN".to_string(), "0".to_string())].into(), path: vec![] };
let t = Tool { name: "api".into(), kind: "mcp".into(), headers: [("Authorization".to_string(), "Bearer ${HLOCK_SECRET_API}".to_string())].into(), ..Default::default() };
let live = json!({"headers": {"Authorization": "Bearer abc"}});
let (mut missing, mut values) = (BTreeSet::new(), BTreeMap::new());
let filled = fill_tool(&t, Some(&live), &ctx, Some("{env:{name}}"), &mut values, &mut missing);
assert_eq!(filled.headers["Authorization"], "Bearer {env:HLOCK_SECRET_API}"); assert_eq!(values["HLOCK_SECRET_API"], "abc"); assert!(missing.is_empty());
}
}