use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use trusty_common::url_userinfo::{ends_url, strip_url_secret, userinfo_end};
use super::registry::ManagedRegistry;
use crate::core::remote_url_redact::{authority_userinfo_end, is_git_scheme};
#[derive(Debug, Serialize, Deserialize)]
pub struct ManagedMarker {
pub alias: String,
pub url: String,
pub git_ref: String,
pub claude_config_dir: String,
}
pub fn load_alias(
alias: &str,
managed_root: &Path,
claude_config_dir: &Path,
) -> anyhow::Result<PathBuf> {
load_alias_with_git_env(alias, managed_root, claude_config_dir, &[])
}
fn load_alias_with_git_env(
alias: &str,
managed_root: &Path,
claude_config_dir: &Path,
git_env: &[(&str, &OsStr)],
) -> anyhow::Result<PathBuf> {
let registry = ManagedRegistry::load(managed_root)
.with_context(|| format!("failed to load registry from {}", managed_root.display()))?;
let entry = registry
.get(alias)
.with_context(|| format!("alias '{alias}' is not registered"))?;
let url = clone_url(alias, &entry.url)?;
let git_ref = entry.git_ref.clone();
let project_dir = managed_root.join("projects").join(alias);
let repo_dir = project_dir.join("repo");
if !repo_dir.exists() {
clone_repo(&url, &project_dir, git_env)?;
} else {
pull_ff_only(&repo_dir);
}
run_prepare_session(&repo_dir, Some(&url), managed_root)?;
write_marker(&repo_dir, alias, &url, &git_ref, claude_config_dir)?;
if let Err(err) = super::trust_seed::preseed_managed_trust(claude_config_dir, &repo_dir) {
tracing::warn!("failed to pre-seed managed trust for '{alias}': {err}");
}
Ok(repo_dir)
}
fn clone_url(alias: &str, stored: &str) -> anyhow::Result<String> {
let url = strip_url_secret(stored);
if may_carry_secret(&url) {
anyhow::bail!(
"refusing to load '{alias}': its registered URL may carry a \
credential that cannot be stripped; re-register it with \
`tm register --force <url-without-credentials> {alias}`"
);
}
Ok(url.into_owned())
}
fn may_carry_secret(url: &str) -> bool {
let (userinfo, http) = match url.find("://").filter(|&at| is_git_scheme(&url[..at])) {
Some(at) => {
let tail = &url[at + 3..];
let cut = authority_userinfo_end(tail);
debug_assert!(userinfo_end(tail) <= cut, "stored cut ends early");
let Some(cut) = cut else {
return false;
};
if tail[..cut].contains(ends_url) {
return true;
}
let scheme = url[..at].rsplit('+').next().unwrap_or_default();
let http = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https");
(&tail[..cut], http)
}
None => {
let head = &url[..url.find('/').unwrap_or(url.len())];
match head.rfind('@').filter(|&at| head[at..].contains(':')) {
Some(at) => (&url[..at], false),
None => return false,
}
}
};
http || userinfo.contains(':')
}
fn clone_origin(url: &str) -> String {
debug_assert!(!may_carry_secret(url), "clone_origin of a refused URL");
if let Some(at) = url.find("://").filter(|&at| is_git_scheme(&url[..at])) {
let tail = &url[at + 3..];
let authority = &tail[..tail.find(['/', '?', '#']).unwrap_or(tail.len())];
let host = authority.rsplit('@').next().unwrap_or_default();
return format!("{}://{host}", &url[..at]);
}
let head = &url[..url.find('/').unwrap_or(url.len())];
match head.rsplit('@').next().unwrap_or_default().split_once(':') {
Some((host, _)) => host.to_owned(),
None => "a local path".to_owned(),
}
}
fn clone_repo(url: &str, project_dir: &Path, git_env: &[(&str, &OsStr)]) -> anyhow::Result<()> {
std::fs::create_dir_all(project_dir).with_context(|| {
format!(
"failed to create project directory {}",
project_dir.display()
)
})?;
let status = Command::new("git")
.args(["clone", "--depth", "1", url, "repo"])
.envs(git_env.iter().copied())
.current_dir(project_dir)
.status()
.context("failed to spawn git clone")?;
if !status.success() {
anyhow::bail!("git clone failed for '{}'", clone_origin(url));
}
Ok(())
}
fn pull_ff_only(repo_dir: &Path) {
let result = Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(repo_dir)
.status();
if let Ok(status) = result
&& !status.success()
{
eprintln!(
"warning: git pull --ff-only failed in {}; skipping refresh",
repo_dir.display()
);
}
}
fn run_prepare_session(
repo_dir: &Path,
repo_url: Option<&str>,
managed_root: &Path,
) -> anyhow::Result<()> {
let fw = crate::core::paths::FrameworkPaths::for_managed_project(managed_root, repo_dir);
let report =
crate::core::session_launch::prepare_session_with_repo_url(&fw, repo_dir, repo_url)
.map_err(|e| anyhow::anyhow!("prepare_session failed: {e}"))?;
crate::core::session_launch::log_prep_findings(
&report.roster_errors,
&report.asset_notices,
crate::core::session_launch::PrepScope {
kind: "load",
session: None,
dir: repo_dir,
},
);
Ok(())
}
fn write_marker(
repo_dir: &Path,
alias: &str,
url: &str,
git_ref: &str,
claude_config_dir: &Path,
) -> anyhow::Result<()> {
let dot_dir = repo_dir.join(".trusty-mpm");
std::fs::create_dir_all(&dot_dir)
.with_context(|| format!("failed to create {}", dot_dir.display()))?;
let marker = ManagedMarker {
alias: alias.to_string(),
url: url.to_string(),
git_ref: git_ref.to_string(),
claude_config_dir: claude_config_dir.to_string_lossy().to_string(),
};
let toml = toml::to_string_pretty(&marker).context("failed to serialize managed.toml")?;
std::fs::write(dot_dir.join("managed.toml"), toml)
.context("failed to write .trusty-mpm/managed.toml")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::credentials::test_sandbox::CredentialSandbox;
struct EnvClearGuard {
key: &'static str,
prior: Option<String>,
}
impl EnvClearGuard {
fn clear(key: &'static str) -> Self {
let prior = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self { key, prior }
}
}
impl Drop for EnvClearGuard {
fn drop(&mut self) {
unsafe {
match self.prior.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
#[test]
#[serial_test::serial]
fn run_prepare_session_never_writes_real_home_claude_dirs() {
let _palace_guard = EnvClearGuard::clear("TRUSTY_MEMORY_PALACE");
let fake_home = crate::test_support::hermetic_temp_dir();
let _home_guard = {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", fake_home.path()) };
HomeGuard(prior)
};
let managed_root = fake_home.path().join(".trusty-mpm");
let agents_src = managed_root.join("framework").join("agents");
std::fs::create_dir_all(&agents_src).unwrap();
std::fs::write(
agents_src.join("regression-agent.md"),
"---\nname: regression-agent\nrole: engineer\n---\n\n\
# Regression Agent\n\nAgent content.\n",
)
.unwrap();
let project_root = crate::test_support::hermetic_temp_dir();
let repo = project_root.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
run_prepare_session(&repo, None, &managed_root).expect("run_prepare_session succeeds");
assert!(
!fake_home.path().join(".claude").join("agents").exists(),
"run_prepare_session must NOT write to the real $HOME/.claude/agents (issue #1927)"
);
assert!(
!fake_home.path().join(".claude").join("skills").exists(),
"run_prepare_session must NOT write to the real $HOME/.claude/skills (issue #1927)"
);
let agent_tier =
crate::core::paths::FrameworkPaths::for_managed_project(&managed_root, &repo)
.agent_deploy_dir();
assert!(
agent_tier.join("regression-agent.md").exists(),
"run_prepare_session must deploy agents to the tm-managed config tier ({}) — #4409",
agent_tier.display()
);
assert!(
!repo.join(".claude").join("agents").exists(),
"run_prepare_session must NOT deploy bundled agents into the workspace (#4409)"
);
let deployed_skills_dir = repo.join(".claude").join("skills");
let has_deployed_skill = deployed_skills_dir
.read_dir()
.map(|mut entries| entries.next().is_some())
.unwrap_or(false);
assert!(
has_deployed_skill,
"run_prepare_session must deploy the compiled-in skill bundle to \
repo/.claude/skills (project-local, DOC-24); dir: {}",
deployed_skills_dir.display()
);
}
#[test]
fn test_marker_write_round_trip() {
let tmp = crate::test_support::hermetic_temp_dir();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
let cfg = tmp.path().join("claude-config");
write_marker(
&repo,
"my-alias",
"https://github.com/org/r",
"default",
&cfg,
)
.unwrap();
let toml_path = repo.join(".trusty-mpm").join("managed.toml");
assert!(toml_path.exists());
let text = std::fs::read_to_string(&toml_path).unwrap();
let marker: ManagedMarker = toml::from_str(&text).unwrap();
assert_eq!(marker.alias, "my-alias");
assert_eq!(marker.url, "https://github.com/org/r");
assert_eq!(
marker.git_ref, "default",
"git_ref must be the 'default' sentinel, not a literal branch name"
);
}
#[test]
fn clone_url_strips_or_refuses() {
let wrong = [
("https://u:T@github.com/o/r", Some("https://github.com/o/r")),
("git@github.com:o/r.git", Some("git@github.com:o/r.git")),
("ssh://git@h:2222/o/r", Some("ssh://git@h:2222/o/r")),
("u:T@host:o/r", Some("u@host:o/r")),
("/srv/dir@x/repo", Some("/srv/dir@x/repo")),
("https://u:/T@host/o/r", None),
("https://u:1234/T@host/o/r", None),
("https://host:8080/@scope/pkg", None),
("https://u:pa'ss@host/o/r", None),
("https://u:pa ss@host/o/r", None),
("https://u:pa\"ss@host/o/r", None),
("https://u:pa`ss@host/o/r", None),
("https://us'er:T@host/o/r", None),
("https://u:pa'ss/x@host/o/r", None),
("ssh://git@h'x:pw@host/r", None),
("ssh://gi t@host/r", None),
("u:T@h:o/r://x", None),
]
.into_iter()
.enumerate()
.filter(|(_, (stored, want))| clone_url("a", stored).ok().as_deref() != *want)
.map(|(row, _)| row)
.collect::<Vec<_>>();
assert!(wrong.is_empty(), "rows {wrong:?} gave the wrong verdict");
}
#[test]
fn clone_origin_prints_scheme_and_host_only() {
for (url, want) in [
("https://h:8443/o/r?token=a'SECRET", "https://h:8443"),
("ssh://git@h:22/o/r", "ssh://h:22"),
("git@github.com:o/r.git", "github.com"),
("/srv/dir@x/repo", "a local path"),
] {
assert_eq!(clone_origin(url), want, "{url:?}");
}
}
#[test]
fn clone_url_refusal_never_echoes_a_quoted_password() {
let Err(err) = clone_url("q9227", "https://u:pa'ss@host/o/r") else {
panic!("a quoted password was admitted");
};
let text = format!("{err:#} {err:?}");
assert!(
text.contains("q9227"),
"the refusal does not name the alias"
);
for part in ["pa'ss", "'ss", "u:pa", "ss@"] {
assert!(!text.contains(part), "the refusal echoes {part:?}");
}
}
const TOKEN: &str = "tm9227SentinelTok";
struct GitSandbox {
_dir: tempfile::TempDir,
home: PathBuf,
gitconfig: PathBuf,
managed_root: PathBuf,
claude_config_dir: PathBuf,
}
impl GitSandbox {
fn new() -> Self {
let dir = crate::test_support::hermetic_temp_dir();
let root = dir.path().to_path_buf();
let home = root.join("home");
let remotes = root.join("remotes");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&remotes).unwrap();
let gitconfig = root.join("gitconfig");
let base = format!("file://{}/", remotes.display());
std::fs::write(
&gitconfig,
format!(
"[init]\n\tdefaultBranch = main\n[commit]\n\tgpgsign = false\n\
[url \"{base}\"]\n\tinsteadOf = https://example.invalid/\n\
\tinsteadOf = https://x-access-token:{TOKEN}@example.invalid/\n\
\tinsteadOf = https://x-access-token:/{TOKEN}@example.invalid/\n"
),
)
.unwrap();
let sb = Self {
_dir: dir,
home,
gitconfig,
managed_root: root.join("managed"),
claude_config_dir: root.join("claude-config"),
};
let work = root.join("work");
sb.git(&root, &["init", "-q", "work"]);
std::fs::write(work.join("README.md"), "fixture\n").unwrap();
sb.git(&work, &["add", "README.md"]);
sb.git(
&work,
&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-qm",
"i",
],
);
sb.git(
&root,
&["clone", "-q", "--bare", "work", "remotes/o9227/r9227.git"],
);
sb
}
fn env(&self) -> [(&'static str, &OsStr); 5] {
[
("HOME", self.home.as_os_str()),
("GIT_CONFIG_GLOBAL", self.gitconfig.as_os_str()),
("GIT_CONFIG_NOSYSTEM", OsStr::new("1")),
("GIT_CONFIG_COUNT", OsStr::new("0")),
("GIT_CONFIG_PARAMETERS", OsStr::new("")),
]
}
fn git(&self, dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.envs(self.env())
.current_dir(dir)
.status()
.expect("spawn git");
assert!(status.success(), "fixture git {:?} failed", args[0]);
}
fn register(&self, alias: &str, url: &str) {
let mut reg = ManagedRegistry::load(&self.managed_root).unwrap();
reg.add(alias, url, false).unwrap();
reg.save().unwrap();
}
fn load(&self, alias: &str) -> anyhow::Result<PathBuf> {
load_alias_with_git_env(
alias,
&self.managed_root,
&self.claude_config_dir,
&self.env(),
)
}
}
#[test]
#[serial_test::serial]
fn load_alias_strips_a_stored_token_before_clone_and_marker() {
let _creds = CredentialSandbox::enter();
let _palace_guard = EnvClearGuard::clear("TRUSTY_MEMORY_PALACE");
let sb = GitSandbox::new();
let raw = format!("https://x-access-token:{TOKEN}@example.invalid/o9227/r9227.git");
let clean = "https://example.invalid/o9227/r9227.git";
sb.register("tok9227", &raw);
let repo = sb.load("tok9227").expect("load_alias clones the fixture");
let git_config = std::fs::read_to_string(repo.join(".git").join("config")).unwrap();
assert!(!git_config.contains(TOKEN), ".git/config holds the token");
assert!(
!git_config.contains('@') && git_config.contains(&format!("url = {clean}")),
"remote.origin.url is not the userinfo-free URL"
);
let marker_text =
std::fs::read_to_string(repo.join(".trusty-mpm").join("managed.toml")).unwrap();
let marker: ManagedMarker = toml::from_str(&marker_text).unwrap();
assert!(
!marker_text.contains(TOKEN) && !marker_text.contains('@'),
"managed.toml holds userinfo"
);
assert!(
marker.url == clean,
"managed.toml url is not the stripped URL"
);
let reg = ManagedRegistry::load(&sb.managed_root).unwrap();
assert!(
reg.get("tok9227").unwrap().url == raw,
"registry.json entry was rewritten"
);
}
#[test]
#[serial_test::serial]
fn load_alias_refuses_a_url_whose_token_cannot_be_stripped() {
let _creds = CredentialSandbox::enter();
let _palace_guard = EnvClearGuard::clear("TRUSTY_MEMORY_PALACE");
let sb = GitSandbox::new();
sb.register(
"bad9227",
&format!("https://x-access-token:/{TOKEN}@example.invalid/o9227/r9227.git"),
);
let Err(err) = sb.load("bad9227") else {
panic!("load_alias cloned a URL whose token it could not strip");
};
let text = format!("{err:#} {err:?}");
assert!(!text.contains(TOKEN), "the refusal echoes the token");
assert!(
!sb.managed_root.join("projects").join("bad9227").exists(),
"a refused load created the project directory"
);
}
}