use crate::adapter::Adapter;
use crate::profile::Paths;
use anyhow::Result;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredMount {
pub host: PathBuf,
pub guest: PathBuf,
pub file: bool,
}
pub const DEFAULT_ACCOUNT: &str = "default";
pub use crate::image::GUEST_HOME;
pub fn dir(paths: &Paths, harness: &str, account: &str) -> PathBuf {
paths.creds(harness).join(account)
}
pub fn accounts(paths: &Paths, adapter: &Adapter) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(paths.creds(&adapter.name)) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| is_captured(paths, adapter, name))
.collect();
out.sort();
out
}
pub fn is_captured(paths: &Paths, adapter: &Adapter, account: &str) -> bool {
unfilled(adapter, &dir(paths, &adapter.name, account), GUEST_HOME).is_empty()
}
pub fn resolve(
paths: &Paths,
adapter: &Adapter,
explicit: Option<&str>,
configured: Option<&str>,
) -> Result<String> {
let harness = &adapter.name;
let available = accounts(paths, adapter);
if available.is_empty() {
anyhow::bail!("no account for {harness} — run `omh auth {harness}` first");
}
if let Some(name) = explicit.or(configured) {
if !available.iter().any(|a| a == name) {
anyhow::bail!(
"no account `{name}` for {harness}\n captured: {}",
available.join(", ")
);
}
return Ok(name.to_string());
}
match available.len() {
1 => Ok(available[0].clone()),
_ => anyhow::bail!(
"{harness} has several accounts: {}\n \
pick one with `omh config set account <name>` or `-a <name>`",
available.join(", ")
),
}
}
pub fn mounts(
adapter: &Adapter,
account_dir: &std::path::Path,
guest_home: &str,
) -> Vec<CredMount> {
adapter
.creds
.iter()
.map(|template| {
let is_dir = template.ends_with('/');
let trimmed = template.trim_end_matches('/');
let guest = crate::adapter::expand(trimmed, guest_home);
let relative = trimmed.trim_start_matches("$HOME/");
CredMount {
host: account_dir.join(relative),
guest,
file: !is_dir,
}
})
.collect()
}
pub fn prepare(adapter: &Adapter, account_dir: &std::path::Path, guest_home: &str) -> Result<()> {
let creds = mounts(adapter, account_dir, guest_home);
for binding in adapter.capabilities.values() {
let guest = crate::adapter::expand(&binding.path, guest_home);
let Some(cred) = creds
.iter()
.find(|c| !c.file && guest.starts_with(&c.guest))
else {
continue;
};
let Ok(relative) = guest.strip_prefix(&cred.guest) else {
continue;
};
let point = cred.host.join(relative);
if binding.render == crate::adapter::Render::Dir {
std::fs::create_dir_all(&point)?;
} else {
if let Some(parent) = point.parent() {
std::fs::create_dir_all(parent)?;
}
if !point.exists() {
std::fs::write(&point, placeholder(&point))?;
}
}
}
for cred in creds {
if cred.file {
if let Some(parent) = cred.host.parent() {
std::fs::create_dir_all(parent)?;
}
let empty = std::fs::metadata(&cred.host)
.map(|m| m.len() == 0)
.unwrap_or(true);
if empty {
std::fs::write(&cred.host, placeholder(&cred.host))?;
}
} else {
std::fs::create_dir_all(&cred.host)?;
}
}
Ok(())
}
pub fn resolve_for_launch(
paths: &Paths,
adapter: &Adapter,
explicit: Option<&str>,
configured: Option<&str>,
) -> Result<Option<String>> {
let asked = explicit.or(configured).is_some();
if !asked && accounts(paths, adapter).is_empty() {
return Ok(None);
}
resolve(paths, adapter, explicit, configured).map(Some)
}
fn holds_content(path: &std::path::Path) -> bool {
match std::fs::read(path) {
Ok(bytes) => !is_placeholder(&String::from_utf8_lossy(&bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => true,
}
}
fn has_real_content(dir: &std::path::Path) -> bool {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.any(|e| {
let p = e.path();
if p.is_dir() {
has_real_content(&p)
} else {
holds_content(&p)
}
})
}
fn is_placeholder(content: &str) -> bool {
matches!(content.trim(), "" | "{}")
}
fn placeholder(path: &std::path::Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("json") => "{}",
_ => "",
}
}
pub fn unfilled(
adapter: &Adapter,
account_dir: &std::path::Path,
guest_home: &str,
) -> Vec<PathBuf> {
if !adapter.token.is_empty() {
return adapter
.token
.iter()
.map(|t| {
let guest = crate::adapter::expand(t.trim_end_matches('/'), guest_home);
let host = account_dir.join(t.trim_end_matches('/').trim_start_matches("$HOME/"));
(host, guest)
})
.filter(|(host, _)| !holds_content(host))
.map(|(_, guest)| guest)
.collect();
}
mounts(adapter, account_dir, guest_home)
.into_iter()
.filter(|c| {
if c.file {
!holds_content(&c.host)
} else {
!has_real_content(&c.host)
}
})
.map(|c| c.guest)
.collect()
}
pub fn validate_name(name: &str) -> Result<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
anyhow::bail!("an account needs a name");
}
if trimmed == "." || trimmed == ".." {
anyhow::bail!("`{name}` is not an account name");
}
if name.contains('/') || name.contains('\\') {
anyhow::bail!("an account name is a single name, not a path: `{name}`");
}
Ok(())
}
pub fn login_outcome(runtime_ok: bool, unfilled: &[PathBuf]) -> Result<()> {
if !runtime_ok {
anyhow::bail!("the sandbox exited with an error — nothing was captured");
}
if !unfilled.is_empty() {
anyhow::bail!(
"the login did not complete — still empty:\n{}",
unfilled
.iter()
.map(|p| format!(" {}", p.display()))
.collect::<Vec<_>>()
.join("\n")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
const ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
fn fixture() -> (tempfile::TempDir, Paths) {
let d = tempfile::tempdir().unwrap();
let paths = Paths {
root: d.path().join("home"),
repo: d.path().join("repo"),
};
(d, paths)
}
fn capture(paths: &Paths, harness: &str, account: &str) {
let adapter = if harness == "claude" {
claude()
} else {
opencode()
};
for token in &adapter.token {
let p = dir(paths, harness, account).join(token.trim_start_matches("$HOME/"));
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, "{\"token\":\"x\"}").unwrap();
}
}
fn claude() -> Adapter {
Adapter::find(Path::new(ADAPTERS), "claude").unwrap()
}
fn opencode() -> Adapter {
Adapter::find(Path::new(ADAPTERS), "opencode").unwrap()
}
fn adapter_with(creds: &[&str]) -> Adapter {
let list = creds
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(", ");
toml::from_str(&format!(
r#"
name = "t"
bin = "t"
install = "x"
creds = [{list}]
[capabilities.rules]
path = "/work/AGENTS.md"
render = "concat"
"#
))
.unwrap()
}
#[test]
fn there_are_no_accounts_before_any_login() {
let (_d, paths) = fixture();
assert!(accounts(&paths, &claude()).is_empty());
}
#[test]
fn accounts_are_listed_by_name() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
capture(&paths, "claude", "personal");
assert_eq!(accounts(&paths, &claude()), vec!["personal", "work"]);
}
#[test]
fn accounts_are_kept_apart_per_harness() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
assert!(accounts(&paths, &opencode()).is_empty());
}
#[test]
fn an_empty_account_directory_is_not_captured() {
let (_d, paths) = fixture();
std::fs::create_dir_all(dir(&paths, "claude", "work")).unwrap();
assert!(!is_captured(&paths, &claude(), "work"));
assert!(
accounts(&paths, &claude()).is_empty(),
"and it is not listed"
);
}
#[test]
fn an_explicit_account_wins() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
capture(&paths, "claude", "personal");
assert_eq!(
resolve(&paths, &claude(), Some("work"), Some("personal")).unwrap(),
"work"
);
}
#[test]
fn the_configured_account_is_used_when_there_are_several() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
capture(&paths, "claude", "personal");
assert_eq!(
resolve(&paths, &claude(), None, Some("personal")).unwrap(),
"personal"
);
}
#[test]
fn a_single_account_needs_no_choosing() {
let (_d, paths) = fixture();
capture(&paths, "claude", "personal");
assert_eq!(resolve(&paths, &claude(), None, None).unwrap(), "personal");
}
#[test]
fn several_accounts_with_no_preference_is_an_error_that_lists_them() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
capture(&paths, "claude", "personal");
let err = resolve(&paths, &claude(), None, None)
.unwrap_err()
.to_string();
assert!(
err.contains("work") && err.contains("personal"),
"got: {err}"
);
assert!(
err.contains("omh config set account"),
"must say how to fix it: {err}"
);
}
#[test]
fn no_accounts_at_all_points_at_omh_auth() {
let (_d, paths) = fixture();
let err = resolve(&paths, &claude(), None, None)
.unwrap_err()
.to_string();
assert!(err.contains("omh auth claude"), "got: {err}");
}
#[test]
fn naming_an_account_that_was_never_captured_is_an_error() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
let err = resolve(&paths, &claude(), Some("nope"), None)
.unwrap_err()
.to_string();
assert!(err.contains("nope"), "got: {err}");
}
#[test]
fn credentials_mount_where_the_harness_actually_looks() {
let account = PathBuf::from("/host/creds/claude/work");
let m = mounts(&claude(), &account, "/home/agent");
assert!(
m.iter().all(|c| c.guest.starts_with("/home/agent")),
"got: {m:?}"
);
assert!(
m.iter().any(|c| c.guest.ends_with(".claude")),
"config dir: {m:?}"
);
}
#[test]
fn stored_credentials_mirror_the_path_they_came_from() {
let account = PathBuf::from("/host/creds/claude/work");
let m = mounts(&claude(), &account, "/home/agent");
let store = m.iter().find(|c| c.guest.ends_with(".claude")).unwrap();
assert_eq!(store.host, account.join(".claude"));
}
#[test]
fn an_adapter_with_no_credentials_mounts_nothing() {
let bare: Adapter = toml::from_str(
r#"
name = "bare"
bin = "bare"
install = "x"
[capabilities.rules]
path = "/work/AGENTS.md"
render = "concat"
"#,
)
.unwrap();
assert!(mounts(&bare, Path::new("/x"), "/home/agent").is_empty());
}
#[test]
fn preparing_creates_the_paths_a_bind_mount_needs() {
let d = tempfile::tempdir().unwrap();
let account = d.path().join("work");
prepare(
&adapter_with(&["$HOME/.cfg/", "$HOME/.cfg.json"]),
&account,
"/home/agent",
)
.unwrap();
assert!(account.join(".cfg").is_dir());
let f = account.join(".cfg.json");
assert!(
f.is_file(),
"must be a file, or docker mounts a directory over it"
);
}
#[test]
fn json_placeholders_are_parseable() {
let d = tempfile::tempdir().unwrap();
prepare(&adapter_with(&["$HOME/.cfg.json"]), d.path(), "/home/agent").unwrap();
let body = std::fs::read_to_string(d.path().join(".cfg.json")).unwrap();
serde_json::from_str::<serde_json::Value>(&body)
.unwrap_or_else(|e| panic!("placeholder is not valid JSON ({e}): {body:?}"));
}
#[test]
fn non_json_placeholders_stay_empty() {
let d = tempfile::tempdir().unwrap();
prepare(&adapter_with(&["$HOME/.cfg.toml"]), d.path(), "/home/agent").unwrap();
assert_eq!(
std::fs::read_to_string(d.path().join(".cfg.toml")).unwrap(),
""
);
}
#[test]
fn an_empty_placeholder_left_by_an_older_omh_is_repaired() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join(".cfg.json"), "").unwrap();
prepare(&adapter_with(&["$HOME/.cfg.json"]), d.path(), "/home/agent").unwrap();
assert_eq!(
std::fs::read_to_string(d.path().join(".cfg.json")).unwrap(),
"{}"
);
}
#[test]
fn preparing_never_clobbers_an_existing_login() {
let d = tempfile::tempdir().unwrap();
let account = d.path().join("work");
let f = account.join(".claude.json");
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(&f, "{\"token\":\"keep-me\"}").unwrap();
prepare(&claude(), &account, "/home/agent").unwrap();
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"{\"token\":\"keep-me\"}"
);
}
#[test]
fn a_parseable_placeholder_is_still_not_a_login() {
let (_d, paths) = fixture();
let account = dir(&paths, "claude", "work");
prepare(&claude(), &account, "/home/agent").unwrap();
assert!(
!is_captured(&paths, &claude(), "work"),
"`{{}}` is something the harness can parse, not something you logged into"
);
}
#[test]
fn preparing_alone_does_not_count_as_captured() {
let (_d, paths) = fixture();
prepare(&claude(), &dir(&paths, "claude", "work"), "/home/agent").unwrap();
assert!(
!is_captured(&paths, &claude(), "work"),
"empty placeholder files are not a login"
);
}
#[test]
fn launching_without_any_account_is_allowed() {
let (_d, paths) = fixture();
assert_eq!(
resolve_for_launch(&paths, &claude(), None, None).unwrap(),
None
);
}
#[test]
fn naming_a_missing_account_stops_the_launch() {
let (_d, paths) = fixture();
capture(&paths, "claude", "personal");
let err = resolve_for_launch(&paths, &claude(), Some("work"), None).unwrap_err();
assert!(err.to_string().contains("work"), "got: {err}");
}
#[test]
fn a_configured_account_that_is_missing_also_stops_the_launch() {
let (_d, paths) = fixture();
capture(&paths, "claude", "personal");
assert!(resolve_for_launch(&paths, &claude(), None, Some("work")).is_err());
}
#[test]
fn the_only_account_is_used_at_launch() {
let (_d, paths) = fixture();
capture(&paths, "claude", "personal");
assert_eq!(
resolve_for_launch(&paths, &claude(), None, None)
.unwrap()
.as_deref(),
Some("personal")
);
}
#[test]
fn two_identities_and_no_preference_still_stops() {
let (_d, paths) = fixture();
capture(&paths, "claude", "work");
capture(&paths, "claude", "personal");
assert!(resolve_for_launch(&paths, &claude(), None, None).is_err());
}
#[test]
fn a_trailing_slash_declares_a_directory() {
let a = adapter_with(&["$HOME/.claude/", "$HOME/.claude.json"]);
let m = mounts(&a, Path::new("/acct"), "/home/agent");
let d = m.iter().find(|c| c.guest.ends_with(".claude")).unwrap();
assert!(
!d.file,
"a directory, so docker must not treat it as a file"
);
let f = m
.iter()
.find(|c| c.guest.ends_with(".claude.json"))
.unwrap();
assert!(f.file);
}
#[test]
fn preparing_creates_directories_as_directories() {
let d = tempfile::tempdir().unwrap();
let a = adapter_with(&["$HOME/.claude/", "$HOME/.claude.json"]);
prepare(&a, d.path(), "/home/agent").unwrap();
assert!(d.path().join(".claude").is_dir(), "must be a directory");
assert!(d.path().join(".claude.json").is_file(), "must be a file");
}
#[test]
fn storage_still_mirrors_the_guest_path_for_directories() {
let a = adapter_with(&["$HOME/.claude/"]);
let m = mounts(&a, Path::new("/acct"), "/home/agent");
assert_eq!(m[0].host, Path::new("/acct/.claude"));
}
#[test]
fn the_claude_adapter_captures_tokens_and_the_account_record() {
let guests: Vec<String> = mounts(&claude(), Path::new("/acct"), "/home/agent")
.iter()
.map(|c| c.guest.display().to_string())
.collect();
assert!(
guests.iter().any(|g| g.ends_with(".claude")),
"tokens: {guests:?}"
);
assert!(
guests.iter().any(|g| g.ends_with(".claude.json")),
"account: {guests:?}"
);
}
#[test]
fn the_token_store_is_a_directory_not_a_file() {
let m = mounts(&claude(), Path::new("/acct"), "/home/agent");
let store = m
.iter()
.find(|c| c.guest.ends_with(".claude"))
.expect("the config directory must be mounted");
assert!(!store.file, "a mounted file cannot be renamed over");
}
#[test]
fn capabilities_nested_in_a_credential_directory_get_mountpoints() {
let d = tempfile::tempdir().unwrap();
prepare(&claude(), d.path(), "/home/agent").unwrap();
assert!(
d.path().join(".claude/skills").is_dir(),
"skills mountpoint"
);
assert!(
d.path().join(".claude/commands").is_dir(),
"commands mountpoint"
);
assert!(
d.path().join(".claude/agents").is_dir(),
"subagents mountpoint"
);
assert!(
d.path().join(".claude/settings.json").is_file(),
"hooks mountpoint"
);
}
#[test]
fn capabilities_outside_a_credential_directory_are_left_alone() {
let d = tempfile::tempdir().unwrap();
prepare(&claude(), d.path(), "/home/agent").unwrap();
assert!(!d.path().join("work").exists());
}
#[test]
fn a_config_written_by_merely_starting_is_not_a_login() {
let d = tempfile::tempdir().unwrap();
prepare(&claude(), d.path(), "/home/agent").unwrap();
std::fs::write(d.path().join(".claude.json"), r#"{"userID":"abc"}"#).unwrap();
let missing = unfilled(&claude(), d.path(), "/home/agent");
assert!(
!missing.is_empty(),
"the token was never written, so the login is not complete: {missing:?}"
);
}
#[test]
fn a_completed_login_leaves_nothing_unfilled() {
let d = tempfile::tempdir().unwrap();
prepare(&claude(), d.path(), "/home/agent").unwrap();
std::fs::write(d.path().join(".claude.json"), r#"{"userID":"abc"}"#).unwrap();
std::fs::write(
d.path().join(".claude/.credentials.json"),
r#"{"token":"t"}"#,
)
.unwrap();
assert!(unfilled(&claude(), d.path(), "/home/agent").is_empty());
}
#[test]
fn an_untouched_account_reports_every_declared_proof_unfilled() {
let d = tempfile::tempdir().unwrap();
let a = claude();
prepare(&a, d.path(), "/home/agent").unwrap();
assert_eq!(unfilled(&a, d.path(), "/home/agent").len(), a.token.len());
}
#[test]
fn ordinary_account_names_are_accepted() {
for name in ["work", "personal", "acme-corp", "user.name", "a_b"] {
validate_name(name).unwrap_or_else(|e| panic!("{name}: {e}"));
}
}
#[test]
fn an_account_name_cannot_escape_its_directory() {
for name in ["..", "../..", "../../..", "a/../..", "work/sub"] {
assert!(validate_name(name).is_err(), "`{name}` must be rejected");
}
}
#[test]
fn an_absolute_account_name_is_rejected() {
for name in ["/", "/etc", "/Users/someone/.claude"] {
assert!(validate_name(name).is_err(), "`{name}` must be rejected");
}
}
#[test]
fn an_empty_or_dot_account_name_is_rejected() {
for name in ["", ".", " "] {
assert!(validate_name(name).is_err(), "`{name:?}` must be rejected");
}
}
#[test]
fn a_runtime_that_failed_is_never_a_successful_login() {
let err = login_outcome(false, &[]).unwrap_err().to_string();
assert!(!err.is_empty());
assert!(
!err.contains("did not complete"),
"must not blame the user for a runtime failure: {err}"
);
}
#[test]
fn an_unfilled_credential_is_reported_with_its_path() {
let err = login_outcome(true, &[PathBuf::from("/acct/.claude/.credentials.json")])
.unwrap_err()
.to_string();
assert!(err.contains(".credentials.json"), "got: {err}");
}
#[test]
fn a_clean_run_that_filled_everything_succeeds() {
assert!(login_outcome(true, &[]).is_ok());
}
#[test]
fn boot_noise_in_the_config_directory_is_not_a_login() {
let (_d, paths) = fixture();
let account = dir(&paths, "claude", "work");
prepare(&claude(), &account, "/home/agent").unwrap();
std::fs::write(account.join(".claude.json"), r#"{"userID":"abc"}"#).unwrap();
std::fs::create_dir_all(account.join(".claude/statsig")).unwrap();
std::fs::write(account.join(".claude/statsig/session.123"), r#"{"s":"1"}"#).unwrap();
assert!(
!unfilled(&claude(), &account, "/home/agent").is_empty(),
"no token was written, so the login is not complete"
);
assert!(
!is_captured(&paths, &claude(), "work"),
"and the account is not usable"
);
assert!(accounts(&paths, &claude()).is_empty(), "nor listed");
}
#[test]
fn a_written_token_is_a_login() {
let (_d, paths) = fixture();
let account = dir(&paths, "claude", "work");
prepare(&claude(), &account, "/home/agent").unwrap();
std::fs::write(account.join(".claude/.credentials.json"), r#"{"t":"x"}"#).unwrap();
assert!(unfilled(&claude(), &account, "/home/agent").is_empty());
assert!(is_captured(&paths, &claude(), "work"));
assert_eq!(accounts(&paths, &claude()), vec!["work"]);
}
#[test]
fn shipped_adapters_declare_what_proves_a_login() {
for name in ["claude", "opencode"] {
let a = Adapter::find(Path::new(ADAPTERS), name).unwrap();
assert!(
!a.token.is_empty(),
"{name} does not say what proves a login"
);
}
}
#[test]
fn captured_means_exactly_nothing_left_unfilled() {
let (_d, paths) = fixture();
let account = dir(&paths, "claude", "work");
prepare(&claude(), &account, "/home/agent").unwrap();
for stage in ["", r#"{"userID":"a"}"#] {
if !stage.is_empty() {
std::fs::write(account.join(".claude.json"), stage).unwrap();
}
assert_eq!(
is_captured(&paths, &claude(), "work"),
unfilled(&claude(), &account, "/home/agent").is_empty(),
"the two answers must never differ"
);
}
}
#[test]
fn an_unreadable_credential_is_not_mistaken_for_an_empty_one() {
let (_d, paths) = fixture();
let account = dir(&paths, "claude", "work");
prepare(&claude(), &account, "/home/agent").unwrap();
std::fs::write(
account.join(".claude/.credentials.json"),
[0xff, 0xfe, 0x00],
)
.unwrap();
assert!(
unfilled(&claude(), &account, "/home/agent").is_empty(),
"a non-UTF-8 token is still a token"
);
assert!(is_captured(&paths, &claude(), "work"));
}
}