1use 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
25pub const VENDOR_DIR: &str = ".hlock/vendor";
27
28#[derive(Debug, Clone)]
30pub struct VendorFile {
31 pub rel: String,
33 pub bytes: Vec<u8>,
34 pub executable: bool,
35}
36
37#[derive(Debug, Clone)]
39pub struct Capture {
40 pub lock: Lockfile,
41 pub vendor: Vec<VendorFile>,
42 pub warnings: Vec<String>,
43}
44
45pub 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
72pub 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
122pub 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
143struct 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 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
192const 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
205fn 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
229pub 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
245fn 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
252fn 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
261pub 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
283pub 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#[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 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 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 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 fn config_label(context: &[String]) -> String {
414 match context {
415 [_, file, rest @ ..] => format!("{file}#/{}", rest.join("/")),
416 _ => context.join("/"),
417 }
418 }
419
420 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
454pub 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
475fn 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
481fn 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
517pub 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
553pub(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
576fn 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; }
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
591fn 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
623fn 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
636pub(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 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
704fn 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
721fn 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
770fn 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
820fn 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
853fn 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
866fn 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; }
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
893fn 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
997pub 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 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 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 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 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 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 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 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 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 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 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 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
1292pub(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
1306fn 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
1331pub 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
1371pub 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
1385fn 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#[derive(Debug, Clone)]
1402pub struct PinOutcome {
1403 pub lock: Lockfile,
1404 pub path: PathBuf,
1405 pub selection: crate::profiles::Selection,
1406 pub diff: Vec<String>,
1408 pub previous: bool,
1409 pub vendored: Vec<String>,
1410 pub warnings: Vec<String>,
1411}
1412
1413pub 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
1493pub 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")); assert!(!looks_secret("6f1d2c1a-2b3c-4d5e-8f90-1a2b3c4d5e6f")); 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}