Skip to main content

harness_lock_core/
capture.rs

1//! `hlock pin`: capture the live setup of one harness into a pinned
2//! `harness.lock` (SPEC §7.8.4).
3//!
4//! Everything here is driven by the harness's [profile](crate::profiles): which
5//! files to read, where the model, MCP servers, skills and plugins live, and how
6//! they're shaped. Secrets are replaced with `${HLOCK_SECRET_…}` placeholders
7//! before anything is written, and content a new machine couldn't fetch (hand
8//! written skills, user-level config) is collected for vendoring next to the lock.
9
10use std::cell::RefCell;
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde_json::{json, Map, Value};
16
17use crate::error::{Error, Result};
18use crate::hash::{content_hash, sha256_bytes};
19use crate::lockfile::{
20    ConfigFile, Context, Decision, Harness, Instructions, Lockfile, Native, Provenance, Runtime, RuntimeModel,
21    SecretRef, Skill, Tool, LLM,
22};
23use crate::profiles::{probe_harness, probe_runtime, run_capture, Ctx, HarnessProfile, Registry, RuntimeProfile};
24
25/// Where vendored files go, relative to the lock's directory.
26pub const VENDOR_DIR: &str = ".hlock/vendor";
27
28/// A file to write under the vendor dir.
29#[derive(Debug, Clone)]
30pub struct VendorFile {
31    /// Path relative to the lock's directory (starts with [`VENDOR_DIR`]).
32    pub rel: String,
33    pub bytes: Vec<u8>,
34    pub executable: bool,
35}
36
37/// The result of capturing one harness.
38#[derive(Debug, Clone)]
39pub struct Capture {
40    pub lock: Lockfile,
41    pub vendor: Vec<VendorFile>,
42    pub warnings: Vec<String>,
43}
44
45// --------------------------------------------------------------------------- //
46// Reading config files
47// --------------------------------------------------------------------------- //
48
49/// Parse a config file into JSON, whatever its format. `Ok(None)` when it doesn't exist.
50pub fn read_config(path: &Path, format: &str) -> Result<Option<Value>> {
51    let Ok(text) = std::fs::read_to_string(path) else { return Ok(None) };
52    parse_config(&text, format).map(Some).map_err(|e| Error::Profile(format!("cannot parse {}: {e}", path.display())))
53}
54
55pub fn parse_config(text: &str, format: &str) -> std::result::Result<Value, String> {
56    match format {
57        "json" | "jsonc" => {
58            if text.trim().is_empty() {
59                return Ok(json!({}));
60            }
61            serde_json::from_str(text).or_else(|_| serde_json::from_str(&strip_jsonc(text))).map_err(|e| e.to_string())
62        }
63        "toml" => toml::from_str::<Value>(text).map_err(|e| e.to_string()),
64        "yaml" => {
65            let v: Value = serde_norway::from_str(text).map_err(|e| e.to_string())?;
66            Ok(if v.is_null() { json!({}) } else { v })
67        }
68        other => Err(format!("unknown config format {other:?}")),
69    }
70}
71
72/// Remove `//` and `/* */` comments and trailing commas, leaving strings intact.
73pub fn strip_jsonc(text: &str) -> String {
74    let b: Vec<char> = text.chars().collect();
75    let mut out = String::with_capacity(text.len());
76    let (mut i, mut in_str, mut esc) = (0, false, false);
77    while i < b.len() {
78        let c = b[i];
79        if in_str {
80            out.push(c);
81            if esc {
82                esc = false;
83            } else if c == '\\' {
84                esc = true;
85            } else if c == '"' {
86                in_str = false;
87            }
88            i += 1;
89            continue;
90        }
91        if c == '"' {
92            in_str = true;
93            out.push(c);
94            i += 1;
95        } else if c == '/' && b.get(i + 1) == Some(&'/') {
96            while i < b.len() && b[i] != '\n' {
97                i += 1;
98            }
99        } else if c == '/' && b.get(i + 1) == Some(&'*') {
100            i += 2;
101            while i + 1 < b.len() && !(b[i] == '*' && b[i + 1] == '/') {
102                i += 1;
103            }
104            i += 2;
105        } else if c == ',' {
106            let mut j = i + 1;
107            while j < b.len() && b[j].is_whitespace() {
108                j += 1;
109            }
110            if !matches!(b.get(j), Some('}') | Some(']')) {
111                out.push(c);
112            }
113            i += 1;
114        } else {
115            out.push(c);
116            i += 1;
117        }
118    }
119    out
120}
121
122/// Serialize a JSON value back into a config format (comments aren't preserved).
123pub fn render_config(v: &Value, format: &str) -> std::result::Result<String, String> {
124    match format {
125        "toml" => toml::to_string_pretty(&drop_nulls(v)).map_err(|e| e.to_string()),
126        "yaml" => serde_norway::to_string(v).map_err(|e| e.to_string()),
127        _ => serde_json::to_string_pretty(v).map(|s| s + "\n").map_err(|e| e.to_string()),
128    }
129}
130
131pub(crate) fn drop_nulls(v: &Value) -> Value {
132    match v {
133        Value::Object(m) => Value::Object(m.iter().filter(|(_, v)| !v.is_null()).map(|(k, v)| (k.clone(), drop_nulls(v))).collect()),
134        Value::Array(a) => Value::Array(a.iter().filter(|v| !v.is_null()).map(drop_nulls).collect()),
135        other => other.clone(),
136    }
137}
138
139fn escape_pointer(s: &str) -> String {
140    s.replace('~', "~0").replace('/', "~1")
141}
142
143/// Reads profile sources (`env:VAR` or `<file>#<pointer>`), caching parsed files.
144struct Sources<'a> {
145    profile: &'a HarnessProfile,
146    ctx: &'a Ctx,
147    cache: RefCell<BTreeMap<String, Option<Value>>>,
148    warnings: RefCell<Vec<String>>,
149}
150
151impl<'a> Sources<'a> {
152    fn new(profile: &'a HarnessProfile, ctx: &'a Ctx) -> Self {
153        Sources { profile, ctx, cache: RefCell::new(BTreeMap::new()), warnings: RefCell::new(Vec::new()) }
154    }
155
156    fn file(&self, id: &str) -> Option<Value> {
157        if let Some(v) = self.cache.borrow().get(id) {
158            return v.clone();
159        }
160        let v = match (self.profile.files.get(id), self.profile.file_path(id, self.ctx)) {
161            (Some(spec), Some(path)) => match read_config(&path, &spec.format) {
162                Ok(v) => v,
163                Err(e) => {
164                    self.warnings.borrow_mut().push(e.to_string());
165                    None
166                }
167            },
168            _ => None,
169        };
170        self.cache.borrow_mut().insert(id.to_string(), v.clone());
171        v
172    }
173
174    fn get(&self, src: &str) -> Option<Value> {
175        if let Some(var) = src.strip_prefix("env:") {
176            return self.ctx.var(var).map(|v| Value::String(v.to_string()));
177        }
178        let (file, ptr) = src.split_once('#').unwrap_or((src, ""));
179        let ptr = ptr.replace("{cwd}", &escape_pointer(&self.ctx.cwd.to_string_lossy()));
180        let v = self.file(file)?;
181        v.pointer(&ptr).cloned().filter(|v| !v.is_null())
182    }
183
184    /// The first source with a non-empty string value, and which source it was.
185    fn first_str(&self, srcs: &[String]) -> Option<(String, String)> {
186        srcs.iter().find_map(|s| {
187            self.get(s).and_then(|v| v.as_str().map(str::to_string)).filter(|v| !v.is_empty()).map(|v| (v, s.clone()))
188        })
189    }
190}
191
192// --------------------------------------------------------------------------- //
193// Secrets
194// --------------------------------------------------------------------------- //
195
196const SECRET_WORDS: &[&str] = &[
197    "key", "apikey", "token", "secret", "password", "passwd", "pwd", "credential", "credentials", "auth",
198    "authorization", "bearer", "cookie", "accesskey", "privatekey", "clientsecret",
199];
200const SECRET_PREFIXES: &[&str] = &[
201    "sk-", "sk_", "ghp_", "gho_", "ghs_", "github_pat_", "xoxb-", "xoxp-", "xapp-", "AKIA", "AIza", "hf_", "glpat-",
202    "Bearer ", "bearer ", "Basic ", "ya29.", "eyJ",
203];
204
205/// Split a key into lowercase words (`apiKey` → `api`, `key`; `x-api-key` → `x`, `api`, `key`).
206fn key_words(key: &str) -> Vec<String> {
207    let mut words = Vec::new();
208    let mut cur = String::new();
209    let chars: Vec<char> = key.chars().collect();
210    for (i, &c) in chars.iter().enumerate() {
211        if !c.is_ascii_alphanumeric() {
212            if !cur.is_empty() {
213                words.push(std::mem::take(&mut cur));
214            }
215            continue;
216        }
217        let boundary = c.is_ascii_uppercase() && i > 0 && chars[i - 1].is_ascii_lowercase();
218        if boundary && !cur.is_empty() {
219            words.push(std::mem::take(&mut cur));
220        }
221        cur.push(c.to_ascii_lowercase());
222    }
223    if !cur.is_empty() {
224        words.push(cur);
225    }
226    words
227}
228
229/// Whether a config key names a secret.
230/// A value local runtimes accept in place of an API key (`"local"`, `"ollama"`, vLLM's
231/// `"EMPTY"`): not a secret, so it's kept as is.
232pub fn is_placeholder_key(value: &str) -> bool {
233    matches!(
234        value.to_ascii_lowercase().as_str(),
235        "local" | "none" | "empty" | "dummy" | "ollama" | "lm-studio" | "lmstudio" | "llama.cpp" | "not-needed" | "no-key" | "sk-no-key-required"
236    )
237}
238
239pub fn is_secret_key(key: &str) -> bool {
240    let words = key_words(key);
241    let joined = words.concat();
242    words.iter().any(|w| SECRET_WORDS.contains(&w.as_str())) || SECRET_WORDS[1..].contains(&joined.as_str())
243}
244
245/// Keys whose whole subtree is credentials (`auth`, `credentials`, `secrets`, `oauth`).
246/// Narrower than [`is_secret_key`], so a provider named `qwen-token-plan` isn't swallowed.
247fn is_secret_container(key: &str) -> bool {
248    let words = key_words(key);
249    words.iter().any(|w| matches!(w.as_str(), "auth" | "oauth" | "credential" | "credentials" | "secret" | "secrets"))
250}
251
252/// Whether a value is already a reference to an environment variable.
253fn is_env_ref(v: &str) -> bool {
254    let t = v.trim();
255    (t.starts_with("${") && t.ends_with('}'))
256        || (t.starts_with("{env:") && t.ends_with('}'))
257        || t.starts_with("env:")
258        || (t.starts_with('$') && t[1..].chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && t.len() > 1)
259}
260
261/// Whether a string value looks like a credential on its own.
262pub fn looks_secret(v: &str) -> bool {
263    if v.len() < 12 || is_env_ref(v) {
264        return false;
265    }
266    if SECRET_PREFIXES.iter().any(|p| v.starts_with(p)) {
267        return true;
268    }
269    let hexish = v.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
270    if v.len() < 32 || hexish || v.contains(char::is_whitespace) || v.contains("://") {
271        return false;
272    }
273    if v.starts_with(['/', '.', '~']) || v.contains('@') {
274        return false;
275    }
276    let charset = v.chars().all(|c| c.is_ascii_alphanumeric() || "_-.=+/".contains(c));
277    let digits = v.chars().any(|c| c.is_ascii_digit());
278    let upper = v.chars().any(|c| c.is_ascii_uppercase());
279    let lower = v.chars().any(|c| c.is_ascii_lowercase());
280    charset && digits && upper && lower
281}
282
283/// Free text with every credential-looking word replaced by `[redacted]`, for prompts
284/// drafted from traces. A word is split at `=` and `:` so `TOKEN=sk-…` is caught too.
285pub fn redact_text(s: &str) -> String {
286    let trim = |c: char| "\"'`()[]{}<>,;.!?".contains(c);
287    let mut out = String::with_capacity(s.len());
288    let mut rest = s;
289    while !rest.is_empty() {
290        let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
291        let (word, tail) = rest.split_at(end);
292        let core = word.trim_matches(trim);
293        let mut kept = word.to_string();
294        if !core.contains(['=', ':']) {
295            if looks_secret(core) {
296                kept = word.replacen(core, "[redacted]", 1);
297            }
298        } else {
299            for part in core.split(['=', ':']).map(|p| p.trim_matches(trim)).filter(|p| looks_secret(p)) {
300                kept = kept.replacen(part, "[redacted]", 1);
301            }
302        }
303        out.push_str(&kept);
304        let ws = tail.find(|c: char| !c.is_whitespace()).unwrap_or(tail.len());
305        out.push_str(&tail[..ws]);
306        rest = &tail[ws..];
307    }
308    out
309}
310
311/// Collects placeholders for the secrets a capture redacts.
312#[derive(Default)]
313struct Secrets {
314    refs: Vec<SecretRef>,
315    by_value: BTreeMap<String, String>,
316}
317
318impl Secrets {
319    fn placeholder_name(&self, context: &[&str]) -> String {
320        let mut raw = String::from("HLOCK_SECRET");
321        for part in context {
322            raw.push('_');
323            raw.push_str(part);
324        }
325        let mut name = String::new();
326        let mut last_us = false;
327        for c in raw.chars() {
328            if c.is_ascii_alphanumeric() {
329                name.push(c.to_ascii_uppercase());
330                last_us = false;
331            } else if !last_us {
332                name.push('_');
333                last_us = true;
334            }
335        }
336        let mut name = name.trim_end_matches('_').to_string();
337        name.truncate(64);
338        let base = name.clone();
339        let mut n = 2;
340        while self.refs.iter().any(|r| r.name == name) {
341            name = format!("{base}_{n}");
342            n += 1;
343        }
344        name
345    }
346
347    /// Replace `value` with a placeholder, reusing one for a value seen before.
348    fn redact(&mut self, context: &[&str], used_by: &str, value: &str) -> String {
349        let id = sha256_bytes(value.as_bytes());
350        if let Some(name) = self.by_value.get(&id) {
351            return format!("${{{name}}}");
352        }
353        let name = self.placeholder_name(context);
354        self.by_value.insert(id, name.clone());
355        self.refs.push(SecretRef { name: name.clone(), used_by: used_by.to_string() });
356        format!("${{{name}}}")
357    }
358
359    /// Redact a string if its key or its shape says it's a secret; URLs get their
360    /// credential-bearing query parameters and userinfo redacted.
361    /// `used_by` describes where the value lives, for the lock's `[[secret]]` entry.
362    fn scrub(&mut self, context: &[&str], key: &str, value: &str, force: bool, used_by: &str) -> String {
363        if value.is_empty() || is_env_ref(value) || is_placeholder_key(value) {
364            return value.to_string();
365        }
366        if force || is_secret_key(key) || looks_secret(value) {
367            return self.redact(context, used_by, value);
368        }
369        if value.contains("://") {
370            return self.scrub_url(context, value, used_by);
371        }
372        value.to_string()
373    }
374
375    fn scrub_url(&mut self, context: &[&str], url: &str, used_by: &str) -> String {
376        let (base, query) = match url.split_once('?') {
377            Some((b, q)) => (b.to_string(), Some(q.to_string())),
378            None => (url.to_string(), None),
379        };
380        // userinfo: scheme://user:pass@host
381        let base = match base.split_once("://") {
382            Some((scheme, rest)) => {
383                let (auth, host) = rest.split_once('/').map(|(a, h)| (a.to_string(), format!("/{h}"))).unwrap_or((rest.to_string(), String::new()));
384                match auth.rsplit_once('@') {
385                    Some((cred, hostpart)) if cred.contains(':') => {
386                        let mut ctx2: Vec<&str> = context.to_vec();
387                        ctx2.push("url_credentials");
388                        let ph = self.redact(&ctx2, &format!("{used_by} (credentials)"), cred);
389                        format!("{scheme}://{ph}@{hostpart}{host}")
390                    }
391                    _ => base.clone(),
392                }
393            }
394            None => base,
395        };
396        let Some(q) = query else { return base };
397        let parts: Vec<String> = q
398            .split('&')
399            .map(|kv| match kv.split_once('=') {
400                Some((k, v)) if is_secret_key(k) || looks_secret(v) => {
401                    let mut ctx2: Vec<&str> = context.to_vec();
402                    ctx2.push(k);
403                    let label = format!("{used_by} ?{k}");
404                    format!("{k}={}", self.redact(&ctx2, &label, v))
405                }
406                _ => kv.to_string(),
407            })
408            .collect();
409        format!("{base}?{}", parts.join("&"))
410    }
411
412    /// Where a config value lives: `<file>#/<json/pointer>` for `[harness, file, keys…]`.
413    fn config_label(context: &[String]) -> String {
414        match context {
415            [_, file, rest @ ..] => format!("{file}#/{}", rest.join("/")),
416            _ => context.join("/"),
417        }
418    }
419
420    /// Redact secrets anywhere in a JSON value (`context` names its location).
421    fn scrub_value(&mut self, context: &mut Vec<String>, v: &mut Value, inherited: bool) {
422        match v {
423            Value::Object(map) => {
424                for (k, child) in map.iter_mut() {
425                    let force = inherited || (is_secret_container(k) && !child.is_string());
426                    context.push(k.clone());
427                    if let Value::String(s) = child {
428                        let label = Self::config_label(context);
429                        let ctx: Vec<&str> = context.iter().map(String::as_str).collect();
430                        *s = self.scrub(&ctx, k, s, inherited, &label);
431                    } else {
432                        self.scrub_value(context, child, force);
433                    }
434                    context.pop();
435                }
436            }
437            Value::Array(items) => {
438                for item in items.iter_mut() {
439                    if let Value::String(s) = item {
440                        let label = Self::config_label(context);
441                        let ctx: Vec<&str> = context.iter().map(String::as_str).collect();
442                        let key = context.last().cloned().unwrap_or_default();
443                        *s = self.scrub(&ctx, &key, s, inherited, &label);
444                    } else {
445                        self.scrub_value(context, item, inherited);
446                    }
447                }
448            }
449            _ => {}
450        }
451    }
452}
453
454// --------------------------------------------------------------------------- //
455// Paths and digests
456// --------------------------------------------------------------------------- //
457
458/// A path written so it means the same thing on another machine: `{root}/…`,
459/// `~/…`, a project-relative path, or (last resort) the absolute path.
460pub fn portable_path(p: &Path, root: Option<&Path>, ctx: &Ctx) -> String {
461    if let Some(r) = root {
462        if let Ok(rel) = p.strip_prefix(r) {
463            return if rel.as_os_str().is_empty() { "{root}".into() } else { format!("{{root}}/{}", rel.to_string_lossy()) };
464        }
465    }
466    if let Ok(rel) = p.strip_prefix(&ctx.cwd) {
467        return rel.to_string_lossy().into_owned();
468    }
469    if let Ok(rel) = p.strip_prefix(&ctx.home) {
470        return format!("~/{}", rel.to_string_lossy());
471    }
472    p.to_string_lossy().into_owned()
473}
474
475/// A project file: under the working directory, but not under the harness's own config
476/// root (an isolated root, like the meta harness's, can live inside the project).
477fn is_project_path(p: &Path, root: &Path, ctx: &Ctx) -> bool {
478    p.starts_with(&ctx.cwd) && !ctx.home.starts_with(&ctx.cwd) && !p.starts_with(root)
479}
480
481/// Files under `dir` (sorted, relative), skipping VCS metadata and OS litter.
482fn walk_files(dir: &Path) -> Vec<PathBuf> {
483    fn rec(base: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
484        let Ok(rd) = std::fs::read_dir(dir) else { return };
485        for e in rd.flatten() {
486            let name = e.file_name().to_string_lossy().into_owned();
487            if matches!(name.as_str(), ".git" | ".DS_Store" | "__pycache__" | "node_modules") {
488                continue;
489            }
490            let p = e.path();
491            if p.is_dir() {
492                rec(base, &p, out);
493            } else if p.is_file() {
494                if let Ok(rel) = p.strip_prefix(base) {
495                    out.push(rel.to_path_buf());
496                }
497            }
498        }
499    }
500    let mut out = Vec::new();
501    rec(dir, dir, &mut out);
502    out.sort();
503    out
504}
505
506#[cfg(unix)]
507fn is_executable(p: &Path) -> bool {
508    use std::os::unix::fs::PermissionsExt;
509    std::fs::metadata(p).map(|m| m.permissions().mode() & 0o111 != 0).unwrap_or(false)
510}
511
512#[cfg(not(unix))]
513fn is_executable(_p: &Path) -> bool {
514    false
515}
516
517/// Merkle-style tree digest (SPEC §7.7.2): normalized relative paths, contents,
518/// and executable bits. A vendored copy and a checkout of the same tree match.
519pub fn tree_digest(dir: &Path) -> String {
520    let mut acc = String::new();
521    for rel in walk_files(dir) {
522        let p = dir.join(&rel);
523        let bytes = std::fs::read(&p).unwrap_or_default();
524        acc.push_str(&format!(
525            "{}\0{}\0{}\n",
526            rel.to_string_lossy().replace('\\', "/"),
527            if is_executable(&p) { "x" } else { "-" },
528            sha256_bytes(&bytes)
529        ));
530    }
531    sha256_bytes(acc.as_bytes())
532}
533
534const MAX_VENDOR_BYTES: u64 = 20 * 1024 * 1024;
535
536fn vendor_tree(dir: &Path, dest: &str, out: &mut Vec<VendorFile>) -> std::result::Result<(), String> {
537    let files = walk_files(dir);
538    let total: u64 = files.iter().filter_map(|f| std::fs::metadata(dir.join(f)).ok()).map(|m| m.len()).sum();
539    if total > MAX_VENDOR_BYTES {
540        return Err(format!("{} is {} MB, too large to vendor", dir.display(), total / 1_000_000));
541    }
542    for rel in files {
543        let p = dir.join(&rel);
544        out.push(VendorFile {
545            rel: format!("{dest}/{}", rel.to_string_lossy().replace('\\', "/")),
546            bytes: std::fs::read(&p).unwrap_or_default(),
547            executable: is_executable(&p),
548        });
549    }
550    Ok(())
551}
552
553// --------------------------------------------------------------------------- //
554// Models and local runtimes
555// --------------------------------------------------------------------------- //
556
557/// Host and port of a URL (`http://localhost:11434/v1` → `localhost`, `11434`).
558pub(crate) fn host_port(url: &str) -> Option<(String, Option<u16>)> {
559    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
560    let auth = rest.split(['/', '?']).next()?;
561    let auth = auth.rsplit_once('@').map(|(_, h)| h).unwrap_or(auth);
562    if let Some(stripped) = auth.strip_prefix('[') {
563        let (h, p) = stripped.split_once(']')?;
564        return Some((h.to_string(), p.strip_prefix(':').and_then(|p| p.parse().ok())));
565    }
566    match auth.rsplit_once(':') {
567        Some((h, p)) => Some((h.to_string(), p.parse().ok())),
568        None => Some((auth.to_string(), None)),
569    }
570}
571
572fn is_local_host(h: &str) -> bool {
573    matches!(h, "localhost" | "127.0.0.1" | "0.0.0.0" | "::1" | "host.docker.internal") || h.ends_with(".localhost")
574}
575
576/// Private-network hosts: RFC 1918, CGNAT/Tailscale (100.64/10), link-local, mDNS and tailnet names.
577fn is_private_host(h: &str) -> bool {
578    if h.ends_with(".local") || h.ends_with(".lan") || h.ends_with(".internal") || h.ends_with(".ts.net") || h.ends_with(".home.arpa") {
579        return true;
580    }
581    if !h.contains('.') && !h.contains(':') && !h.is_empty() {
582        return true; // a bare hostname on the LAN
583    }
584    let o: Vec<u8> = h.split('.').filter_map(|x| x.parse().ok()).collect();
585    if o.len() != 4 {
586        return false;
587    }
588    o[0] == 10 || (o[0] == 172 && (16..32).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || (o[0] == 100 && (64..128).contains(&o[1])) || (o[0] == 169 && o[1] == 254)
589}
590
591/// The runtime behind a provider, and where it runs (`local` or `network`): by
592/// provider name, then by the base URL's port. Public hosts are API providers.
593fn match_runtime<'r>(reg: &'r Registry, provider: Option<&str>, base_url: Option<&str>) -> Option<(&'r RuntimeProfile, &'static str)> {
594    let hp = base_url.and_then(host_port);
595    let location = match hp.as_ref().map(|(h, _)| h.as_str()) {
596        None => "local",
597        Some(h) if is_local_host(h) => "local",
598        Some(h) if is_private_host(h) => "network",
599        Some(_) => return None,
600    };
601    if let Some(p) = provider.map(str::to_ascii_lowercase) {
602        if let Some(r) = reg.runtimes.iter().find(|r| r.provider_names.iter().any(|n| n.to_ascii_lowercase() == p) || r.matches(&p)) {
603            return Some((r, location));
604        }
605    }
606    let port = hp.and_then(|(_, p)| p)?;
607    reg.runtimes.iter().find(|r| r.ports.contains(&port)).map(|r| (r, location))
608}
609
610fn new_runtime(rt: &RuntimeProfile, location: &str, endpoint: Option<String>, ctx: &Ctx) -> Runtime {
611    let p = if location == "local" { probe_runtime(rt, ctx) } else { Default::default() };
612    let name = runtime_key(rt, location, endpoint.as_deref());
613    Runtime { name, location: location.into(), version: p.version, source: p.source, endpoint, model: vec![] }
614}
615
616fn runtime_key(rt: &RuntimeProfile, location: &str, endpoint: Option<&str>) -> String {
617    match (location, endpoint.and_then(host_port)) {
618        ("network", Some((h, _))) => format!("{}@{h}", rt.id),
619        _ => rt.id.clone(),
620    }
621}
622
623/// Network runtimes can't be inspected, so their models are recorded, not located.
624fn runtime_model(rt: &RuntimeProfile, location: &str, id: &str, ctx: &Ctx) -> RuntimeModel {
625    if location == "local" {
626        locate_model(rt, id, ctx)
627    } else {
628        RuntimeModel { id: id.to_string(), ..Default::default() }
629    }
630}
631
632fn norm(s: &str) -> String {
633    s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
634}
635
636/// Look a model up in a runtime's local store.
637pub(crate) fn locate_model(rt: &RuntimeProfile, model: &str, ctx: &Ctx) -> RuntimeModel {
638    let mut m = RuntimeModel { id: model.to_string(), ..Default::default() };
639    let dir = rt.models_dir(ctx);
640    match rt.models.layout.as_str() {
641        "ollama" => {
642            // hf.co/<org>/<repo>:<quant> | <ns>/<name>:<tag> | <name>:<tag>
643            let (name, tag) = model.rsplit_once(':').filter(|(_, t)| !t.contains('/')).unwrap_or((model, "latest"));
644            let rel = if name.starts_with("hf.co/") || name.starts_with("huggingface.co/") {
645                format!("{name}/{tag}")
646            } else if name.contains('/') {
647                format!("registry.ollama.ai/{name}/{tag}")
648            } else {
649                format!("registry.ollama.ai/library/{name}/{tag}")
650            };
651            m.source = Some(match name.strip_prefix("hf.co/").or_else(|| name.strip_prefix("huggingface.co/")) {
652                Some(repo) => format!("hf://{repo}"),
653                None => format!("ollama:{name}:{tag}"),
654            });
655            if let Some(path) = dir.map(|d| d.join("manifests").join(rel)) {
656                if let Ok(bytes) = std::fs::read(&path) {
657                    m.present = true;
658                    m.digest = Some(sha256_bytes(&bytes));
659                    if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
660                        let size: u64 = v["layers"].as_array().map(|ls| ls.iter().filter_map(|l| l["size"].as_u64()).sum()).unwrap_or(0);
661                        m.size_bytes = Some(size + v["config"]["size"].as_u64().unwrap_or(0));
662                    }
663                }
664            }
665        }
666        "publisher-repo" => {
667            let want = norm(model.rsplit('/').next().unwrap_or(model));
668            let found = dir.and_then(|d| {
669                let pubs = std::fs::read_dir(&d).ok()?;
670                for p in pubs.flatten().filter(|p| p.path().is_dir()) {
671                    for r in std::fs::read_dir(p.path()).ok()?.flatten().filter(|r| r.path().is_dir()) {
672                        let repo = r.file_name().to_string_lossy().into_owned();
673                        let n = norm(repo.trim_end_matches("-GGUF").trim_end_matches("-gguf").trim_end_matches("-MLX"));
674                        if !want.is_empty() && (n.contains(&want) || want.contains(&n)) {
675                            return Some((p.file_name().to_string_lossy().into_owned(), repo, r.path()));
676                        }
677                    }
678                }
679                None
680            });
681            if let Some((publisher, repo, path)) = found {
682                m.present = true;
683                m.source = Some(format!("hf://{publisher}/{repo}"));
684                m.size_bytes = Some(walk_files(&path).iter().filter_map(|f| std::fs::metadata(path.join(f)).ok()).map(|x| x.len()).sum());
685            }
686        }
687        "hf-cache" => {
688            if model.contains('/') {
689                m.source = Some(format!("hf://{model}"));
690                if let Some(d) = dir {
691                    let cache = d.join(format!("models--{}", model.replace('/', "--")));
692                    if let Ok(rev) = std::fs::read_to_string(cache.join("refs/main")) {
693                        m.present = true;
694                        m.revision = Some(rev.trim().to_string());
695                    }
696                }
697            }
698        }
699        _ => {}
700    }
701    m
702}
703
704/// Model ids configured under one provider entry (object keys or a list of ids).
705fn provider_models(entry: &Value, pointers: &[String]) -> Vec<String> {
706    for p in pointers {
707        match entry.pointer(p) {
708            Some(Value::Object(m)) => return m.keys().cloned().collect(),
709            Some(Value::Array(a)) => {
710                return a
711                    .iter()
712                    .filter_map(|x| x.as_str().map(str::to_string).or_else(|| x["id"].as_str().or(x["name"].as_str()).map(str::to_string)))
713                    .collect()
714            }
715            _ => {}
716        }
717    }
718    Vec::new()
719}
720
721// --------------------------------------------------------------------------- //
722// MCP servers
723// --------------------------------------------------------------------------- //
724
725/// The package a launcher command runs (`npx -y pkg@1` → `npm:pkg@1`).
726fn launcher_source(command: &str, args: &[String]) -> Option<String> {
727    let bin = Path::new(command).file_name()?.to_string_lossy().into_owned();
728    let first_pkg = |skip: &[&str]| args.iter().find(|a| !a.starts_with('-') && !skip.contains(&a.as_str())).cloned();
729    match bin.as_str() {
730        "npx" | "bunx" => first_pkg(&[]).map(|p| format!("npm:{p}")),
731        "pnpm" | "yarn" if args.first().map(String::as_str) == Some("dlx") => first_pkg(&["dlx"]).map(|p| format!("npm:{p}")),
732        "uvx" | "pipx" => first_pkg(&["run"]).map(|p| format!("pypi:{p}")),
733        "uv" if args.first().map(String::as_str) == Some("tool") => first_pkg(&["tool", "run"]).map(|p| format!("pypi:{p}")),
734        "docker" | "podman" => {
735            let mut it = args.iter().skip_while(|a| *a != "run").skip(1);
736            let mut image = None;
737            while let Some(a) = it.next() {
738                if a.starts_with('-') {
739                    if matches!(a.as_str(), "-e" | "--env" | "-v" | "--volume" | "--name" | "-p" | "--network") {
740                        it.next();
741                    }
742                    continue;
743                }
744                image = Some(a.clone());
745                break;
746            }
747            image.map(|i| format!("oci:{i}"))
748        }
749        _ => None,
750    }
751}
752
753fn str_map(v: Option<&Value>, secrets: &mut Secrets, context: &[&str]) -> BTreeMap<String, String> {
754    let mut out = BTreeMap::new();
755    if let Some(Value::Object(m)) = v {
756        for (k, val) in m {
757            let s = match val {
758                Value::String(s) => s.clone(),
759                other => other.to_string(),
760            };
761            let mut c: Vec<&str> = context.to_vec();
762            c.push(k);
763            let label = format!("mcp {} {k}", context.join(" "));
764            out.insert(k.clone(), secrets.scrub(&c, k, &s, false, &label));
765        }
766    }
767    out
768}
769
770/// Normalize one MCP server entry from any harness's config shape.
771fn normalize_mcp(name: &str, v: &Value, scope: &str, secrets: &mut Secrets) -> Option<Tool> {
772    let obj = v.as_object()?;
773    let ctx = [name];
774    let mut cmd_parts: Vec<String> = match obj.get("command").or_else(|| obj.get("cmd")) {
775        Some(Value::String(s)) => vec![s.clone()],
776        Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
777        _ => vec![],
778    };
779    let mut args: Vec<String> =
780        obj.get("args").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
781    if cmd_parts.len() > 1 {
782        let rest = cmd_parts.split_off(1);
783        args = rest.into_iter().chain(args).collect();
784    }
785    let command = cmd_parts.pop();
786    let url = ["url", "serverUrl", "httpUrl", "uri", "endpoint"].iter().find_map(|k| obj.get(*k).and_then(Value::as_str)).map(str::to_string);
787    let declared = obj.get("type").or_else(|| obj.get("transport")).and_then(Value::as_str).unwrap_or("");
788    let transport = match declared.to_ascii_lowercase().as_str() {
789        "sse" => "sse",
790        "http" | "remote" | "streamable-http" | "streamablehttp" | "streamable_http" => "http",
791        "stdio" | "local" => "stdio",
792        _ if url.is_some() && command.is_none() => "http",
793        _ => "stdio",
794    };
795    let args: Vec<String> = args.iter().map(|a| secrets.scrub(&ctx, "arg", a, false, &format!("mcp {name} args"))).collect();
796    let env = str_map(obj.get("env").or_else(|| obj.get("environment")).or_else(|| obj.get("envs")), secrets, &ctx);
797    let headers = str_map(obj.get("headers").or_else(|| obj.get("http_headers")), secrets, &[name, "header"]);
798    let enabled = match (obj.get("enabled").and_then(Value::as_bool), obj.get("disabled").and_then(Value::as_bool)) {
799        (Some(e), _) => Some(e),
800        (None, Some(d)) => Some(!d),
801        _ => None,
802    };
803    let source = command.as_deref().and_then(|c| launcher_source(c, &args)).or_else(|| url.as_ref().map(|_| "remote".to_string()));
804    Some(Tool {
805        name: name.to_string(),
806        kind: "mcp".into(),
807        source,
808        transport: Some(transport.into()),
809        command,
810        args,
811        url: url.map(|u| secrets.scrub(&ctx, "url", &u, false, &format!("mcp {name} url"))),
812        env,
813        headers,
814        scope: Some(scope.into()),
815        enabled,
816        ..Default::default()
817    })
818}
819
820// --------------------------------------------------------------------------- //
821// Skills
822// --------------------------------------------------------------------------- //
823
824/// Directories under `dir` (up to three levels) that contain a `SKILL.md`.
825fn find_skills(dir: &Path, include_hidden: bool) -> Vec<PathBuf> {
826    fn rec(d: &Path, depth: usize, hidden: bool, out: &mut Vec<PathBuf>) {
827        if d.join("SKILL.md").is_file() {
828            out.push(d.to_path_buf());
829            return;
830        }
831        if depth == 0 {
832            return;
833        }
834        let Ok(rd) = std::fs::read_dir(d) else { return };
835        let mut subs: Vec<PathBuf> = rd
836            .flatten()
837            .filter(|e| {
838                let n = e.file_name().to_string_lossy().into_owned();
839                (hidden || !n.starts_with('.')) && n != "node_modules" && e.path().is_dir()
840            })
841            .map(|e| e.path())
842            .collect();
843        subs.sort();
844        for s in subs {
845            rec(&s, depth - 1, false, out);
846        }
847    }
848    let mut out = Vec::new();
849    rec(dir, 3, include_hidden, &mut out);
850    out
851}
852
853/// `name:` from a SKILL.md's YAML frontmatter.
854fn skill_name(dir: &Path) -> String {
855    let fallback = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
856    let Ok(text) = std::fs::read_to_string(dir.join("SKILL.md")) else { return fallback };
857    let Some(rest) = text.strip_prefix("---") else { return fallback };
858    let front = rest.split("\n---").next().unwrap_or("");
859    front
860        .lines()
861        .find_map(|l| l.strip_prefix("name:").map(|v| v.trim().trim_matches(['"', '\'']).to_string()))
862        .filter(|n| !n.is_empty())
863        .unwrap_or(fallback)
864}
865
866/// Remote, commit and cleanliness of the git checkout containing `dir`, if any.
867fn git_origin(dir: &Path, ctx: &Ctx) -> Option<(String, String, String, bool)> {
868    let git = ctx.which("git")?;
869    let t = Duration::from_secs(5);
870    let run = |args: &[&str]| {
871        let mut a = vec!["-C".to_string(), dir.to_string_lossy().into_owned()];
872        a.extend(args.iter().map(|s| s.to_string()));
873        run_capture(&git, &a, ctx, t).map(|s| s.trim().to_string())
874    };
875    let top = run(&["rev-parse", "--show-toplevel"]).filter(|s| !s.contains("fatal"))?;
876    let top = PathBuf::from(top.lines().next()?);
877    if top == ctx.home || ctx.cwd.starts_with(&top) {
878        return None; // a dotfiles repo at ~, or the project itself
879    }
880    let url = run(&["config", "--get", "remote.origin.url"]).and_then(|s| s.lines().next().map(str::to_string)).filter(|s| !s.is_empty())?;
881    let commit = run(&["rev-parse", "HEAD"]).and_then(|s| s.lines().next().map(str::to_string))?;
882    let rel = dir.strip_prefix(&top).ok()?.to_string_lossy().into_owned();
883    let dirty = run(&["status", "--porcelain", "--", "."]).map(|s| !s.trim().is_empty()).unwrap_or(true);
884    Some((url, commit, rel, dirty))
885}
886
887fn builtin_names(profile: &HarnessProfile, root: &Path, ctx: &Ctx) -> BTreeSet<String> {
888    let Some(tpl) = &profile.skills.builtin_manifest else { return BTreeSet::new() };
889    let Ok(text) = std::fs::read_to_string(ctx.expand(tpl, Some(root))) else { return BTreeSet::new() };
890    text.lines().filter_map(|l| l.split(':').next()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
891}
892
893// --------------------------------------------------------------------------- //
894// Bundles (plugins, packages)
895// --------------------------------------------------------------------------- //
896
897fn npm_name_version(spec: &str) -> (String, Option<String>) {
898    let s = spec.strip_prefix("npm:").unwrap_or(spec);
899    let (scope, rest) = if let Some(r) = s.strip_prefix('@') { ("@", r) } else { ("", s) };
900    match rest.split_once('@') {
901        Some((n, v)) if !v.is_empty() => (format!("{scope}{n}"), Some(v.to_string())),
902        _ => (format!("{scope}{rest}"), None),
903    }
904}
905
906fn claude_plugins(profile: &HarnessProfile, src: &Sources, path: &Path, out: &mut Vec<Native>) {
907    let Ok(Some(installed)) = read_config(path, "json") else { return };
908    let enabled = src.get("settings#/enabledPlugins").unwrap_or(json!({}));
909    let markets = path.parent().map(|d| d.join("known_marketplaces.json")).and_then(|p| read_config(&p, "json").ok().flatten()).unwrap_or(json!({}));
910    let mut used = BTreeSet::new();
911    if let Some(Value::Object(plugins)) = installed.get("plugins") {
912        for (id, entries) in plugins {
913            let e = entries.as_array().and_then(|a| a.first()).cloned().unwrap_or(json!({}));
914            let market = id.split_once('@').map(|(_, m)| m.to_string());
915            if let Some(m) = &market {
916                used.insert(m.clone());
917            }
918            out.push(Native {
919                kind: format!("{}.plugin", profile.id),
920                name: id.clone(),
921                version: e["version"].as_str().map(str::to_string),
922                source: market.map(|m| format!("marketplace:{m}")),
923                revision: e["gitCommitSha"].as_str().map(str::to_string),
924                integrity: None,
925                enabled: enabled.get(id).and_then(Value::as_bool).unwrap_or(false),
926                config: e["scope"].as_str().map(|s| json!({"scope": s})),
927            });
928        }
929    }
930    for m in used {
931        let s = &markets[&m]["source"];
932        let (source, revision) = match s["source"].as_str() {
933            Some("github") => (s["repo"].as_str().map(|r| format!("github:{r}")), s["ref"].as_str().map(str::to_string)),
934            Some("git") => (s["url"].as_str().map(|u| format!("git+{u}")), s["ref"].as_str().map(str::to_string)),
935            Some("directory") | Some("local") => (s["path"].as_str().map(|p| format!("path:{p}")), None),
936            Some("url") => (s["url"].as_str().map(str::to_string), None),
937            _ => (None, None),
938        };
939        out.push(Native {
940            kind: format!("{}.marketplace", profile.id),
941            name: m,
942            source,
943            revision,
944            enabled: true,
945            ..Default::default()
946        });
947    }
948}
949
950fn pi_packages(profile: &HarnessProfile, list: &Value, lockfile: Option<&Path>, out: &mut Vec<Native>) {
951    let lock = lockfile.and_then(|p| read_config(p, "json").ok().flatten()).unwrap_or(json!({}));
952    for item in list.as_array().cloned().unwrap_or_default() {
953        let (spec, extra) = match &item {
954            Value::String(s) => (s.clone(), None),
955            Value::Object(o) => {
956                let mut rest = o.clone();
957                let s = rest.remove("source").and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
958                (s, (!rest.is_empty()).then_some(Value::Object(rest)))
959            }
960            _ => continue,
961        };
962        let mut n = Native { kind: format!("{}.package", profile.id), enabled: true, config: extra, ..Default::default() };
963        if spec.starts_with("npm:") {
964            let (name, ver) = npm_name_version(&spec);
965            let entry = &lock["packages"][format!("node_modules/{name}")];
966            n.version = entry["version"].as_str().map(str::to_string).or(ver);
967            n.integrity = entry["integrity"].as_str().map(str::to_string);
968            n.source = Some(match &n.version {
969                Some(v) => format!("npm:{name}@{v}"),
970                None => format!("npm:{name}"),
971            });
972            n.name = name;
973        } else {
974            n.name = spec.clone();
975            n.source = Some(spec);
976        }
977        out.push(n);
978    }
979}
980
981fn npm_list(profile: &HarnessProfile, kind: &str, list: &Value, out: &mut Vec<Native>) {
982    for item in list.as_array().cloned().unwrap_or_default() {
983        let Some(spec) = item.as_str() else { continue };
984        let local = spec.starts_with("file:") || spec.starts_with('.') || spec.starts_with('/');
985        let (name, version) = if local { (spec.to_string(), None) } else { npm_name_version(spec) };
986        out.push(Native {
987            kind: format!("{}.{kind}", profile.id),
988            source: Some(if local { format!("path:{}", spec.trim_start_matches("file:")) } else { format!("npm:{spec}") }),
989            name,
990            version,
991            enabled: true,
992            ..Default::default()
993        });
994    }
995}
996
997// --------------------------------------------------------------------------- //
998// Capture
999// --------------------------------------------------------------------------- //
1000
1001/// Capture `profile`'s live setup. Nothing is written; `vendor` holds the files
1002/// [`pin`] would place under [`VENDOR_DIR`].
1003pub fn capture(profile: &HarnessProfile, reg: &Registry, ctx: &Ctx, project: Option<&str>) -> Result<Capture> {
1004    let src = Sources::new(profile, ctx);
1005    let root = profile.config_root(ctx);
1006    let mut secrets = Secrets::default();
1007    let mut vendor: Vec<VendorFile> = Vec::new();
1008    let mut warnings: Vec<String> = Vec::new();
1009    let vroot = format!("{VENDOR_DIR}/{}", profile.id);
1010
1011    // ---- harness ----
1012    let probe = probe_harness(profile, ctx);
1013    if profile.kind == "cli" && !probe.installed {
1014        warnings.push(format!("{} isn't installed here; pinning its config only", profile.name));
1015    }
1016
1017    // ---- models ----
1018    let mut llms = Vec::new();
1019    let mut runtimes: BTreeMap<String, Runtime> = BTreeMap::new();
1020    let provider_entry = |provider: &str| -> Option<Value> {
1021        let spec = profile.providers.as_ref()?;
1022        spec.source.iter().find_map(|s| src.get(s).and_then(|m| m.get(provider).cloned()))
1023    };
1024    for (role, srcs) in [("default", &profile.model.default), ("small", &profile.model.small)] {
1025        let found = src.first_str(srcs);
1026        if found.is_none() && role == "small" {
1027            continue;
1028        }
1029        let (mut model, from) = found.unwrap_or_else(|| ("harness-default".into(), String::new()));
1030        let mut provider = src.first_str(&profile.model.provider).map(|(p, _)| p);
1031        if profile.model.format.as_deref() == Some("provider/model") {
1032            if let Some((p, m)) = model.split_once('/') {
1033                provider = Some(p.to_string());
1034                model = m.to_string();
1035            }
1036        }
1037        let provider = provider.or_else(|| profile.model.default_provider.clone());
1038        let entry = provider.as_deref().and_then(provider_entry);
1039        let base = src.first_str(&profile.model.base_url).map(|(b, _)| b).or_else(|| {
1040            let spec = profile.providers.as_ref()?;
1041            entry.as_ref().and_then(|e| spec.base_url.iter().find_map(|p| e.pointer(p).and_then(Value::as_str).map(str::to_string)))
1042        });
1043        let rt = match_runtime(reg, provider.as_deref(), base.as_deref());
1044        if let Some((rt, location)) = rt {
1045            let key = runtime_key(rt, location, base.as_deref());
1046            let r = runtimes.entry(key).or_insert_with(|| new_runtime(rt, location, base.clone(), ctx));
1047            let mut ids = vec![model.clone()];
1048            if let (Some(e), Some(spec)) = (&entry, &profile.providers) {
1049                ids.extend(provider_models(e, &spec.models));
1050            }
1051            for id in ids {
1052                if id != "harness-default" && !r.model.iter().any(|m| m.id == id) {
1053                    r.model.push(runtime_model(rt, location, &id, ctx));
1054                }
1055            }
1056        }
1057        llms.push(LLM {
1058            role: role.into(),
1059            provider: provider.unwrap_or_else(|| "unknown".into()),
1060            model,
1061            resolved_from: (!from.is_empty()).then_some(from),
1062            runtime: rt.map(|(r, loc)| runtime_key(r, loc, base.as_deref())),
1063            endpoint: base.map(|b| secrets.scrub(&["llm", role], "url", &b, false, &format!("llm {role} endpoint"))),
1064            ..Default::default()
1065        });
1066    }
1067
1068    // other configured providers served by a local runtime
1069    if let Some(spec) = &profile.providers {
1070        for s in &spec.source {
1071            let Some(Value::Object(providers)) = src.get(s) else { continue };
1072            for (name, entry) in &providers {
1073                let base = spec.base_url.iter().find_map(|p| entry.pointer(p).and_then(Value::as_str).map(str::to_string));
1074                let Some((rt, location)) = match_runtime(reg, Some(name), base.as_deref()) else { continue };
1075                let key = runtime_key(rt, location, base.as_deref());
1076                let r = runtimes.entry(key).or_insert_with(|| new_runtime(rt, location, base.clone(), ctx));
1077                for id in provider_models(entry, &spec.models) {
1078                    if !r.model.iter().any(|m| m.id == id) {
1079                        r.model.push(runtime_model(rt, location, &id, ctx));
1080                    }
1081                }
1082            }
1083        }
1084    }
1085
1086    // ---- MCP servers ----
1087    let mut tools: Vec<Tool> = Vec::new();
1088    for s in &profile.mcp.sources {
1089        let file = s.split_once('#').map(|(f, _)| f).unwrap_or(s);
1090        let scope = match profile.file_path(file, ctx) {
1091            Some(p) if is_project_path(&p, &root, ctx) || s.contains("{cwd}") => "project",
1092            _ => "user",
1093        };
1094        if let Some(Value::Object(servers)) = src.get(s) {
1095            for (name, cfg) in &servers {
1096                if tools.iter().any(|t| &t.name == name) {
1097                    continue;
1098                }
1099                if let Some(t) = normalize_mcp(name, cfg, scope, &mut secrets) {
1100                    tools.push(t);
1101                }
1102            }
1103        }
1104    }
1105
1106    // ---- skills ----
1107    let mut skills: Vec<Skill> = Vec::new();
1108    let mut seen = BTreeSet::new();
1109    let builtins = builtin_names(profile, &root, ctx);
1110    let builtin_dirs: Vec<PathBuf> = profile.skills.builtin_dirs.iter().map(|d| ctx.expand(d, Some(&root))).collect();
1111    let dirs: Vec<(PathBuf, bool)> = profile
1112        .skills
1113        .dirs
1114        .iter()
1115        .map(|d| (ctx.expand(d, Some(&root)), false))
1116        .chain(builtin_dirs.iter().map(|d| (d.clone(), true)))
1117        .collect();
1118    for (dir, is_builtin_dir) in dirs {
1119        for sk in find_skills(&dir, is_builtin_dir) {
1120            let real = std::fs::canonicalize(&sk).unwrap_or(sk.clone());
1121            if !seen.insert(real.clone()) {
1122                continue;
1123            }
1124            let name = skill_name(&sk);
1125            let digest = tree_digest(&sk);
1126            let project = is_project_path(&sk, &root, ctx);
1127            let mut s = Skill {
1128                name: name.clone(),
1129                digest: Some(digest),
1130                scope: Some(if project { "project" } else { "user" }.into()),
1131                path: Some(portable_path(&sk, Some(&root), ctx)),
1132                enabled: true,
1133                ..Default::default()
1134            };
1135            let realstr = real.to_string_lossy();
1136            if is_builtin_dir || builtin_dirs.iter().any(|b| sk.starts_with(b)) || builtins.contains(&name) {
1137                s.origin = "harness-builtin".into();
1138                s.provided_by = Some(format!("harness:{}", profile.id));
1139            } else if realstr.contains("/plugins/") || realstr.contains("/node_modules/") {
1140                s.origin = "bundled".into();
1141            } else if project {
1142                s.origin = "first-party".into();
1143                s.source = Some(format!("path:{}", portable_path(&sk, None, ctx)));
1144            } else if let Some((url, commit, rel, dirty)) = git_origin(&real, ctx) {
1145                s.origin = "third-party".into();
1146                s.source = Some(format!("git+{url}@{commit}#path={rel}"));
1147                if dirty {
1148                    warnings.push(format!("skill {name} has uncommitted changes; vendoring it"));
1149                }
1150                if dirty {
1151                    vendor_skill(&sk, &vroot, &name, &mut s, &mut vendor, &mut warnings);
1152                }
1153            } else {
1154                s.origin = "local".into();
1155                vendor_skill(&sk, &vroot, &name, &mut s, &mut vendor, &mut warnings);
1156            }
1157            skills.push(s);
1158        }
1159    }
1160
1161    // ---- bundles ----
1162    let mut natives: Vec<Native> = Vec::new();
1163    for b in &profile.bundles {
1164        match b.extractor.as_str() {
1165            "claude-plugins" => claude_plugins(profile, &src, &ctx.expand(&b.source, Some(&root)), &mut natives),
1166            "pi-packages" => {
1167                let lock = b.lockfile.as_ref().map(|l| ctx.expand(l, Some(&root)));
1168                pi_packages(profile, &src.get(&b.source).unwrap_or(json!([])), lock.as_deref(), &mut natives)
1169            }
1170            "npm-list" => npm_list(profile, &b.kind, &src.get(&b.source).unwrap_or(json!([])), &mut natives),
1171            other => warnings.push(format!("unknown bundle extractor {other:?} in the {} profile", profile.id)),
1172        }
1173    }
1174    // code harnesses pin their packages
1175    for (pkg, version) in &probe.packages {
1176        natives.push(Native {
1177            kind: "package".into(),
1178            name: pkg.clone(),
1179            version: Some(version.clone()),
1180            source: Some(format!("pypi:{}=={version}", pkg.trim_start_matches("pypi:"))),
1181            enabled: true,
1182            ..Default::default()
1183        });
1184    }
1185
1186    // ---- instructions ----
1187    let mut instructions = Vec::new();
1188    for tpl in &profile.instructions {
1189        let p = ctx.expand(tpl, Some(&root));
1190        let Ok(bytes) = std::fs::read(&p) else { continue };
1191        let project = is_project_path(&p, &root, ctx);
1192        let name = portable_path(&p, Some(&root), ctx);
1193        let mut ins = Instructions { name: name.clone(), digest: Some(sha256_bytes(&bytes)), scope: if project { "project" } else { "user" }.into(), vendored: None };
1194        if !project {
1195            let rel = format!("{vroot}/instructions/{}", p.file_name().unwrap_or_default().to_string_lossy());
1196            vendor.push(VendorFile { rel: rel.clone(), bytes, executable: false });
1197            ins.vendored = Some(rel);
1198        }
1199        instructions.push(ins);
1200    }
1201
1202    // ---- config files ----
1203    let mut configs = Vec::new();
1204    for (id, spec) in &profile.files {
1205        let Some(path) = profile.file_path(id, ctx) else { continue };
1206        let Ok(raw) = std::fs::read(&path) else { continue };
1207        let project = is_project_path(&path, &root, ctx);
1208        let mut cf = ConfigFile {
1209            id: id.clone(),
1210            path: portable_path(&path, Some(&root), ctx),
1211            format: spec.format.clone(),
1212            restore: if project { "record".into() } else { spec.restore.clone() },
1213            pointers: spec.pointers.clone(),
1214            digest: Some(sha256_bytes(&raw)),
1215            vendored: None,
1216        };
1217        if cf.restore != "record" {
1218            match src.file(id) {
1219                Some(mut v) => {
1220                    let (mut body, fmt) = if cf.restore == "pointers" {
1221                        let mut m = Map::new();
1222                        for p in &spec.pointers {
1223                            if let Some(sub) = v.pointer(p) {
1224                                m.insert(p.clone(), sub.clone());
1225                            }
1226                        }
1227                        (Value::Object(m), "json")
1228                    } else {
1229                        (std::mem::take(&mut v), if spec.format == "jsonc" { "json" } else { spec.format.as_str() })
1230                    };
1231                    secrets.scrub_value(&mut vec![profile.id.clone(), id.clone()], &mut body, false);
1232                    // digest what's restored, redacted and parsed, so a sync that reformats the
1233                    // file or fills in a different secret value isn't drift
1234                    cf.digest = Some(content_hash(&body));
1235                    match render_config(&body, fmt) {
1236                        Ok(text) => {
1237                            let ext = if cf.restore == "pointers" { "pointers.json".to_string() } else { fmt.to_string() };
1238                            let rel = format!("{vroot}/config/{id}.{ext}");
1239                            vendor.push(VendorFile { rel: rel.clone(), bytes: text.into_bytes(), executable: false });
1240                            cf.vendored = Some(rel);
1241                        }
1242                        Err(e) => warnings.push(format!("can't vendor {}: {e}", path.display())),
1243                    }
1244                }
1245                None => warnings.push(format!("can't parse {}; recording its digest only", path.display())),
1246            }
1247        }
1248        configs.push(cf);
1249    }
1250    warnings.extend(src.warnings.borrow().iter().cloned());
1251
1252    // ---- assemble ----
1253    let config_digest = content_hash(&json!(configs.iter().map(|c| (&c.id, &c.digest)).collect::<Vec<_>>()));
1254    let mut stack = BTreeMap::new();
1255    stack.insert("name".into(), json!(project.unwrap_or(&profile.id)));
1256    stack.insert("harness".into(), json!(profile.id));
1257    stack.insert("selection".into(), json!("pin"));
1258    let lock = Lockfile {
1259        kind: Some("pin".into()),
1260        stack,
1261        llm: llms,
1262        decision: Decision { kind: "none".into(), ..Default::default() },
1263        harness: Harness {
1264            name: profile.id.clone(),
1265            version: probe.version.clone(),
1266            source: probe.source.clone(),
1267            config: Some(config_digest),
1268            ..Default::default()
1269        },
1270        tool: tools,
1271        skill: skills,
1272        instructions,
1273        context: Context { isolation: "locked-only".into(), ..Default::default() },
1274        provenance: Provenance { backend: "pin".into(), ..Default::default() },
1275        native: natives,
1276        runtime: runtimes.into_values().collect(),
1277        config: configs,
1278        secret: secrets.refs,
1279        ..Default::default()
1280    };
1281    Ok(Capture { lock, vendor, warnings })
1282}
1283
1284fn vendor_skill(dir: &Path, vroot: &str, name: &str, s: &mut Skill, vendor: &mut Vec<VendorFile>, warnings: &mut Vec<String>) {
1285    let dest = format!("{vroot}/skills/{name}");
1286    match vendor_tree(dir, &dest, vendor) {
1287        Ok(()) => s.vendored = Some(dest),
1288        Err(e) => warnings.push(format!("skill {name}: {e}")),
1289    }
1290}
1291
1292/// The raw MCP server entries a harness has configured right now, by name.
1293pub(crate) fn live_mcp_servers(profile: &HarnessProfile, ctx: &Ctx) -> BTreeMap<String, Value> {
1294    let src = Sources::new(profile, ctx);
1295    let mut out = BTreeMap::new();
1296    for s in &profile.mcp.sources {
1297        if let Some(Value::Object(m)) = src.get(s) {
1298            for (k, v) in m {
1299                out.entry(k).or_insert(v);
1300            }
1301        }
1302    }
1303    out
1304}
1305
1306// --------------------------------------------------------------------------- //
1307// Diff
1308// --------------------------------------------------------------------------- //
1309
1310fn index<T, F: Fn(&T) -> String>(items: &[T], key: F) -> BTreeMap<String, &T> {
1311    items.iter().map(|i| (key(i), i)).collect()
1312}
1313
1314fn diff_maps<T, S: Fn(&&T) -> String>(out: &mut Vec<String>, label: &str, old: BTreeMap<String, &T>, new: BTreeMap<String, &T>, sig: S) {
1315    for (k, n) in &new {
1316        match old.get(k) {
1317            None => out.push(format!("+ {label} {k}")),
1318            Some(o) if sig(o) != sig(n) => out.push(format!("~ {label} {k}: {} → {}", sig(o), sig(n))),
1319            _ => {}
1320        }
1321    }
1322    for k in old.keys().filter(|k| !new.contains_key(*k)) {
1323        out.push(format!("- {label} {k}"));
1324    }
1325}
1326
1327fn short(d: &Option<String>) -> String {
1328    d.as_deref().map(|d| d.trim_start_matches("sha256:").chars().take(10).collect()).unwrap_or_else(|| "—".into())
1329}
1330
1331/// Human-readable changes from `old` to `new` (`+` added, `-` removed, `~` changed).
1332pub fn diff_locks(old: &Lockfile, new: &Lockfile) -> Vec<String> {
1333    let mut out = Vec::new();
1334    if old.harness.name != new.harness.name {
1335        out.push(format!("~ harness: {} → {}", old.harness.name, new.harness.name));
1336    } else if old.harness.version != new.harness.version || old.harness.source != new.harness.source {
1337        let v = |h: &Harness| h.source.clone().or(h.version.clone()).unwrap_or_else(|| "—".into());
1338        out.push(format!("~ harness {}: {} → {}", new.harness.name, v(&old.harness), v(&new.harness)));
1339    }
1340    let llm_sig = |l: &&LLM| format!("{}:{}{}", l.provider, l.model, l.endpoint.as_deref().map(|e| format!(" @ {e}")).unwrap_or_default());
1341    diff_maps(&mut out, "llm", index(&old.llm, |l| l.role.clone()), index(&new.llm, |l| l.role.clone()), llm_sig);
1342    let tool_sig = |t: &&Tool| {
1343        let env: Vec<&String> = t.env.keys().collect();
1344        format!("{} {} {} {} env={env:?} enabled={:?}", t.transport.as_deref().unwrap_or(""), t.command.as_deref().unwrap_or(""), t.args.join(" "), t.url.as_deref().unwrap_or(""), t.enabled)
1345            .split_whitespace()
1346            .collect::<Vec<_>>()
1347            .join(" ")
1348    };
1349    diff_maps(&mut out, "mcp", index(&old.tool, |t| t.name.clone()), index(&new.tool, |t| t.name.clone()), tool_sig);
1350    let skill_key = |s: &Skill| format!("{} ({})", s.name, s.scope.as_deref().unwrap_or("?"));
1351    diff_maps(&mut out, "skill", index(&old.skill, skill_key), index(&new.skill, skill_key), |s: &&Skill| short(&s.digest));
1352    let nat_key = |n: &Native| format!("{} {}", n.kind, n.name);
1353    let nat_sig = |n: &&Native| {
1354        let state = if n.enabled { "" } else { " (disabled)" };
1355        format!("{}{}", n.version.clone().or(n.revision.clone()).unwrap_or_else(|| "—".into()), state)
1356    };
1357    diff_maps(&mut out, "plugin", index(&old.native, nat_key), index(&new.native, nat_key), nat_sig);
1358    diff_maps(&mut out, "runtime", index(&old.runtime, |r| r.name.clone()), index(&new.runtime, |r| r.name.clone()), |r: &&Runtime| {
1359        r.version.clone().unwrap_or_else(|| "—".into())
1360    });
1361    let models = |l: &Lockfile| -> Vec<(String, String)> {
1362        l.runtime.iter().flat_map(|r| r.model.iter().map(move |m| (format!("{}/{}", r.name, m.id), m.digest.clone().or(m.revision.clone()).unwrap_or_else(|| if m.present { "present".into() } else { "missing".into() })))).collect()
1363    };
1364    let (om, nm) = (models(old), models(new));
1365    diff_maps(&mut out, "model", om.iter().map(|(k, v)| (k.clone(), v)).collect(), nm.iter().map(|(k, v)| (k.clone(), v)).collect(), |v: &&String| short(&Some((*v).clone())));
1366    diff_maps(&mut out, "instructions", index(&old.instructions, |i| i.name.clone()), index(&new.instructions, |i| i.name.clone()), |i: &&Instructions| short(&i.digest));
1367    diff_maps(&mut out, "config", index(&old.config, |c| c.path.clone()), index(&new.config, |c| c.path.clone()), |c: &&ConfigFile| short(&c.digest));
1368    out
1369}
1370
1371// --------------------------------------------------------------------------- //
1372// pin / check
1373// --------------------------------------------------------------------------- //
1374
1375/// The vendor namespace for a lock file, so two locks in one directory never share
1376/// vendored files: `harness.lock` has none; `harness.<name>.lock` and `<name>.lock`
1377/// vendor into `.hlock/vendor/<name>/<harness>/`.
1378pub fn vendor_namespace(lock: &Path) -> Option<String> {
1379    let name = lock.file_name()?.to_str()?;
1380    let stem = name.strip_suffix(".lock").unwrap_or(name);
1381    let ns = stem.strip_prefix("harness.").unwrap_or(stem);
1382    (stem != "harness" && !ns.is_empty()).then(|| ns.to_string())
1383}
1384
1385/// Move a capture's vendored paths from `.hlock/vendor/<id>/` to `.hlock/vendor/<ns>/<id>/`.
1386fn relocate_vendored(cap: &mut Capture, id: &str, ns: &str) {
1387    let from = format!("{VENDOR_DIR}/{id}/");
1388    let to = format!("{VENDOR_DIR}/{ns}/{id}/");
1389    let mv = |p: &mut String| {
1390        if let Some(rest) = p.strip_prefix(&from) {
1391            *p = format!("{to}{rest}");
1392        }
1393    };
1394    cap.vendor.iter_mut().for_each(|f| mv(&mut f.rel));
1395    cap.lock.skill.iter_mut().filter_map(|s| s.vendored.as_mut()).for_each(mv);
1396    cap.lock.instructions.iter_mut().filter_map(|i| i.vendored.as_mut()).for_each(mv);
1397    cap.lock.config.iter_mut().filter_map(|c| c.vendored.as_mut()).for_each(mv);
1398}
1399
1400/// What [`pin`] did.
1401#[derive(Debug, Clone)]
1402pub struct PinOutcome {
1403    pub lock: Lockfile,
1404    pub path: PathBuf,
1405    pub selection: crate::profiles::Selection,
1406    /// Changes from the previous pinned lock at `path` (empty for a first pin).
1407    pub diff: Vec<String>,
1408    pub previous: bool,
1409    pub vendored: Vec<String>,
1410    pub warnings: Vec<String>,
1411}
1412
1413/// Capture the target harness and write a pinned lock to `out`, vendoring into
1414/// `<out dir>/.hlock/vendor/<harness>/` unless `vendor` is false. Refuses to
1415/// replace a resolved lock (one with evidence) unless `force`.
1416pub fn pin(
1417    reg: &Registry,
1418    ctx: &Ctx,
1419    harness: Option<&str>,
1420    manifest_target: Option<&str>,
1421    project: Option<&str>,
1422    out: &Path,
1423    vendor: bool,
1424    force: bool,
1425) -> Result<PinOutcome> {
1426    let selection = reg.select(harness, manifest_target, ctx)?;
1427    let profile = reg.require_harness(&selection.id)?;
1428    let previous = if out.exists() {
1429        match Lockfile::load(out) {
1430            Ok(l) if !l.is_pin() && l.evidence.trials > 0 && !force => {
1431                return Err(Error::Lockfile(format!(
1432                    "{} is a resolved lock with evidence; pass --force to replace it, or pin to another file with -o",
1433                    out.display()
1434                )))
1435            }
1436            Ok(l) => Some(l),
1437            Err(e) if !force => return Err(Error::Lockfile(format!("{e} (pass --force to replace it)"))),
1438            Err(_) => None,
1439        }
1440    } else {
1441        None
1442    };
1443    let mut cap = capture(profile, reg, ctx, project)?;
1444    let ns = vendor_namespace(out);
1445    if let Some(ns) = &ns {
1446        relocate_vendored(&mut cap, &profile.id, ns);
1447    }
1448    let base = out.parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf).unwrap_or_else(|| ctx.cwd.clone());
1449    let mut vendored = Vec::new();
1450    if vendor {
1451        let vdir = match &ns {
1452            Some(ns) => base.join(VENDOR_DIR).join(ns).join(&profile.id),
1453            None => base.join(VENDOR_DIR).join(&profile.id),
1454        };
1455        let _ = std::fs::remove_dir_all(&vdir);
1456        for f in &cap.vendor {
1457            let dest = base.join(&f.rel);
1458            if let Some(parent) = dest.parent() {
1459                std::fs::create_dir_all(parent)?;
1460            }
1461            std::fs::write(&dest, &f.bytes)?;
1462            #[cfg(unix)]
1463            if f.executable {
1464                use std::os::unix::fs::PermissionsExt;
1465                let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));
1466            }
1467            vendored.push(f.rel.clone());
1468        }
1469    } else {
1470        for s in cap.lock.skill.iter_mut() {
1471            s.vendored = None;
1472        }
1473        for i in cap.lock.instructions.iter_mut() {
1474            i.vendored = None;
1475        }
1476        for c in cap.lock.config.iter_mut() {
1477            c.vendored = None;
1478        }
1479    }
1480    let diff = previous.as_ref().filter(|p| p.is_pin()).map(|p| diff_locks(p, &cap.lock)).unwrap_or_default();
1481    cap.lock.save(out)?;
1482    Ok(PinOutcome {
1483        lock: cap.lock,
1484        path: out.to_path_buf(),
1485        selection,
1486        diff,
1487        previous: previous.is_some_and(|p| p.is_pin()),
1488        vendored,
1489        warnings: cap.warnings,
1490    })
1491}
1492
1493/// Re-capture the harness a pinned lock is for and list what drifted.
1494pub fn check_pin(lock: &Lockfile, reg: &Registry, ctx: &Ctx) -> Result<Vec<String>> {
1495    let profile = reg.require_harness(&lock.harness.name)?;
1496    let project = lock.stack.get("name").and_then(Value::as_str);
1497    let cap = capture(profile, reg, ctx, project)?;
1498    Ok(diff_locks(lock, &cap.lock))
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503    use super::*;
1504
1505    #[test]
1506    fn placeholder_keys_stay() {
1507        assert!(is_placeholder_key("local") && is_placeholder_key("EMPTY") && !is_placeholder_key("sk-ant-abc123"));
1508        let mut s = Secrets::default();
1509        assert_eq!(s.scrub(&["pi", "models"], "apiKey", "local", false, "models#/apiKey"), "local");
1510        assert_ne!(s.scrub(&["pi", "models"], "apiKey", "sk-ant-0123456789abcdef", false, "models#/apiKey"), "sk-ant-0123456789abcdef");
1511    }
1512
1513    #[test]
1514    fn vendor_namespaces() {
1515        assert_eq!(vendor_namespace(Path::new("harness.lock")), None);
1516        assert_eq!(vendor_namespace(Path::new("/p/harness.meta.lock")).as_deref(), Some("meta"));
1517        assert_eq!(vendor_namespace(Path::new("pinned.lock")).as_deref(), Some("pinned"));
1518    }
1519
1520    #[test]
1521    fn jsonc() {
1522        let t = "{\n // c\n \"a\": \"http://x//y\", /* b */ \"b\": [1, 2,],\n}";
1523        let v = parse_config(t, "jsonc").unwrap();
1524        assert_eq!(v["a"], "http://x//y");
1525        assert_eq!(v["b"], json!([1, 2]));
1526    }
1527
1528    #[test]
1529    fn yaml_and_toml_round_trip() {
1530        let y = parse_config("model:\n  default: qwen3\n  provider: ollama\nmcp_servers:\n  fs:\n    command: uvx\n", "yaml").unwrap();
1531        assert_eq!(y.pointer("/model/default"), Some(&json!("qwen3")));
1532        assert_eq!(parse_config(&render_config(&y, "yaml").unwrap(), "yaml").unwrap(), y);
1533        let t = parse_config("model = \"gpt-5\"\n[mcp_servers.gh]\ncommand = \"npx\"\n", "toml").unwrap();
1534        assert_eq!(t.pointer("/mcp_servers/gh/command"), Some(&json!("npx")));
1535        assert_eq!(parse_config(&render_config(&t, "toml").unwrap(), "toml").unwrap(), t);
1536        assert_eq!(parse_config("", "yaml").unwrap(), json!({}));
1537    }
1538
1539    #[test]
1540    fn secret_detection() {
1541        for k in ["apiKey", "api_key", "OPENAI_API_KEY", "Authorization", "x-api-key", "token", "client_secret", "password"] {
1542            assert!(is_secret_key(k), "{k}");
1543        }
1544        for k in ["keybindings", "maxTokens", "model", "baseURL", "theme", "command"] {
1545            assert!(!is_secret_key(k), "{k}");
1546        }
1547        assert!(looks_secret("sk-ant-api03-abcdefghijklmnop"));
1548        assert!(looks_secret("ghp_0123456789abcdefABCDEF0123456789"));
1549        assert!(looks_secret("aB3dE5fG7hJ9kL1mN3pQ5rS7tU9vW1xY3zA5"));
1550        assert!(!looks_secret("3b1f4e4c0e6aac15f2fd7f55e151bda9b1f4e4c0")); // a git sha
1551        assert!(!looks_secret("6f1d2c1a-2b3c-4d5e-8f90-1a2b3c4d5e6f")); // a uuid
1552        assert!(!looks_secret("http://localhost:1234/v1"));
1553        assert!(!looks_secret("${GITHUB_TOKEN}"));
1554    }
1555
1556    #[test]
1557    fn redact_free_text() {
1558        let t = "use ghp_0123456789abcdefABCDEF0123456789, or export OPENAI_API_KEY=sk-live-123456789012\nthen run `make`";
1559        let r = redact_text(t);
1560        assert_eq!(r, "use [redacted], or export OPENAI_API_KEY=[redacted]\nthen run `make`");
1561        let plain = "fix the bug in src/auth/login.py (see https://example.com/issues/42)";
1562        assert_eq!(redact_text(plain), plain);
1563    }
1564
1565    #[test]
1566    fn scrub_redacts_and_reuses_placeholders() {
1567        let mut s = Secrets::default();
1568        let mut v = json!({
1569            "provider": {"vllm": {"options": {"baseURL": "http://localhost:8000/v1", "apiKey": "sk-live-123456789012"}}},
1570            "mcp": {"composio": {"url": "https://mcp.example.com/x?api_key=abcd1234efgh&user=me", "headers": {"Authorization": "Bearer tok_abcdefghijkl"}}},
1571            "env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}", "NODE_ENV": "production"},
1572            "again": {"apiKey": "sk-live-123456789012"},
1573            "auth": {"email": "me@example.com"},
1574            "qwen-token-plan": {"name": "Qwen plan", "models": [{"id": "qwen3-coder"}]},
1575        });
1576        s.scrub_value(&mut vec!["pi".into(), "settings".into()], &mut v, false);
1577        let text = v.to_string();
1578        assert!(!text.contains("sk-live") && !text.contains("tok_abc") && !text.contains("abcd1234efgh") && !text.contains("me@example.com"), "{text}");
1579        assert_eq!(v["provider"]["vllm"]["options"]["baseURL"], "http://localhost:8000/v1");
1580        assert_eq!(v["env"]["GITHUB_TOKEN"], "${GITHUB_TOKEN}");
1581        assert_eq!(v["env"]["NODE_ENV"], "production");
1582        assert_eq!(v["again"]["apiKey"], v["provider"]["vllm"]["options"]["apiKey"]);
1583        assert!(v["mcp"]["composio"]["url"].as_str().unwrap().contains("user=me"));
1584        assert_eq!(v["qwen-token-plan"]["models"][0]["id"], "qwen3-coder");
1585        assert_eq!(s.refs.len(), 4);
1586        assert!(s.refs.iter().all(|r| r.name.starts_with("HLOCK_SECRET_")));
1587    }
1588
1589    #[test]
1590    fn mcp_shapes() {
1591        let mut s = Secrets::default();
1592        let claude = normalize_mcp("gh", &json!({"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": {"GITHUB_TOKEN": "ghp_0123456789abcdefABCDEF0123456789"}}), "user", &mut s).unwrap();
1593        assert_eq!(claude.transport.as_deref(), Some("stdio"));
1594        assert_eq!(claude.source.as_deref(), Some("npm:@modelcontextprotocol/server-github"));
1595        assert!(claude.env["GITHUB_TOKEN"].starts_with("${HLOCK_SECRET_GH_GITHUB_TOKEN"));
1596        let oc = normalize_mcp("fs", &json!({"type": "local", "command": ["uvx", "mcp-server-fs"], "enabled": false}), "user", &mut s).unwrap();
1597        assert_eq!((oc.command.as_deref(), oc.args.clone(), oc.enabled), (Some("uvx"), vec!["mcp-server-fs".to_string()], Some(false)));
1598        assert_eq!(oc.source.as_deref(), Some("pypi:mcp-server-fs"));
1599        let remote = normalize_mcp("flyte", &json!({"type": "remote", "url": "https://x.dev/mcp", "headers": {"Authorization": "Bearer abcdefghijklmnop"}}), "user", &mut s).unwrap();
1600        assert_eq!(remote.transport.as_deref(), Some("http"));
1601        assert!(remote.headers["Authorization"].starts_with("${HLOCK_SECRET_FLYTE_HEADER_AUTHORIZATION"));
1602        let docker = normalize_mcp("d", &json!({"command": "docker", "args": ["run", "-i", "--rm", "-e", "X", "ghcr.io/a/b:1"]}), "user", &mut s).unwrap();
1603        assert_eq!(docker.source.as_deref(), Some("oci:ghcr.io/a/b:1"));
1604    }
1605
1606    #[test]
1607    fn runtime_matching() {
1608        let r = Registry::builtin();
1609        let m = |p: &str, u: Option<&str>| match_runtime(&r, Some(p), u).map(|(rt, loc)| (rt.id.clone(), loc));
1610        assert_eq!(m("lmstudio", Some("http://localhost:1234/v1")), Some(("lmstudio".into(), "local")));
1611        assert_eq!(m("my-local", Some("http://127.0.0.1:11434")), Some(("ollama".into(), "local")));
1612        assert_eq!(m("ollama", None), Some(("ollama".into(), "local")));
1613        assert_eq!(m("vllm", Some("http://10.0.0.5:8000/v1")), Some(("vllm".into(), "network")));
1614        assert_eq!(m("box", Some("http://100.70.1.2:1234/v1")), Some(("lmstudio".into(), "network")));
1615        assert_eq!(m("vllm", Some("https://api.example.com/v1")), None);
1616        assert_eq!(m("anthropic", None), None);
1617    }
1618
1619    #[test]
1620    fn npm_specs() {
1621        assert_eq!(npm_name_version("npm:pi-free"), ("pi-free".into(), None));
1622        assert_eq!(npm_name_version("@scope/pkg@1.2.3"), ("@scope/pkg".into(), Some("1.2.3".into())));
1623        assert_eq!(launcher_source("npx", &["-y".into(), "pkg@2".into()]).as_deref(), Some("npm:pkg@2"));
1624    }
1625}