use std::path::{Path, PathBuf};
use link_assistant_router::cli::ClaudeCredentials;
use link_assistant_router::env_paths::from_value;
const CREDENTIAL_FILE: &str = ".credentials.json";
pub(super) const LABEL_SUFFIX: &str = "claude-credentials";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Provision {
Isolated,
Shared {
home: PathBuf,
owner: Option<(u32, u32)>,
},
Refused(String),
}
impl Provision {
pub(super) fn assess(mode: ClaudeCredentials, data: &Path) -> Self {
Self::assess_in(
mode,
std::env::var_os("CLAUDE_CONFIG_DIR"),
std::env::var_os("HOME"),
data,
link_assistant_router::platform_keychain::has_entry,
)
}
pub(super) fn assess_in(
mode: ClaudeCredentials,
config_dir: Option<std::ffi::OsString>,
user_home: Option<std::ffi::OsString>,
data: &Path,
has_entry: impl FnOnce(&str) -> bool,
) -> Self {
match mode {
ClaudeCredentials::Isolated => Self::Isolated,
ClaudeCredentials::Share => {
let config_dir = from_value(config_dir);
let service = link_assistant_router::platform_keychain::claude_service_for(
config_dir.as_deref().map(Path::as_os_str),
);
let home =
config_dir.or_else(|| from_value(user_home).map(|home| home.join(".claude")));
share(home.as_deref(), || has_entry(&service), data)
}
}
}
pub(super) fn label(&self) -> String {
match self {
Self::Isolated => "isolated".to_string(),
Self::Shared { home, .. } => format!("shared:{}", home.display()),
Self::Refused(_) => "refused".to_string(),
}
}
pub(super) fn status_line(&self, root: &Path) -> String {
match self {
Self::Isolated => {
let credentials = root.join("credentials");
let empty = std::fs::read_dir(&credentials)
.map_or(true, |mut entries| entries.next().is_none());
format!(
"anthropic_credential=skipped source={} reason={}the host Claude Code login \
was not requested; pass --claude-credentials share to use it",
credentials.display(),
if empty {
"the directory is empty and "
} else {
""
}
)
}
Self::Shared { home, owner } => format!(
"anthropic_credential=imported method=shared-mount source={} user={} \
refresh_tokens_copied=0",
home.display(),
owner.map_or_else(
|| "container-default".to_string(),
|(uid, gid)| format!("{uid}:{gid}")
)
),
Self::Refused(reason) => format!("anthropic_credential=refused reason={reason}"),
}
}
}
pub(super) fn share(
home: Option<&Path>,
keychain: impl FnOnce() -> bool,
data: &Path,
) -> Provision {
let Some(home) = home else {
return Provision::Refused(
"HOME and CLAUDE_CONFIG_DIR are unset, so there is no Claude Code home".to_string(),
);
};
let home = match home.canonicalize() {
Ok(home) if home.is_dir() => home,
Ok(_) | Err(_) => {
return Provision::Refused(format!(
"there is no Claude Code home at {}; run `claude` and log in first",
home.display()
));
}
};
if keychain() {
return Provision::Refused(format!(
"Claude Code keeps this login in the macOS Keychain, which a container can neither \
read nor update; {} holds a snapshot nothing rotates, and copying the Keychain \
entry would fork its rotating refresh chain. Run `router deploy --mode host` to move this \
deployment to a host Router that reads the Keychain in place",
home.join(CREDENTIAL_FILE).display()
));
}
let file = home.join(CREDENTIAL_FILE);
let bytes = match std::fs::read(&file) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Provision::Refused(format!(
"{} holds no Claude Code login ({CREDENTIAL_FILE} is absent); run `claude` and \
log in first",
home.display()
));
}
Err(error) => {
return Provision::Refused(format!(
"{} cannot be read: {}",
file.display(),
error.kind()
));
}
};
if let Err(reason) = oauth_login(&bytes) {
return Provision::Refused(format!("{} {reason}", file.display()));
}
if let Err(error) = tempfile::NamedTempFile::new_in(&home) {
return Provision::Refused(format!(
"{} is not writable ({}); the credential is replaced by rename beside it",
home.display(),
error.kind()
));
}
let owner = owner(&file);
if let Some((uid, gid)) = owner
&& let Some(stranger) = foreign_entry(data, uid)
{
return Provision::Refused(format!(
"{} belongs to another user (an earlier backend ran as root); the shared backend \
runs as {uid}:{gid} and could not write it. Run `sudo chown -R {uid}:{gid} {}` first",
stranger.display(),
data.display()
));
}
Provision::Shared { home, owner }
}
fn oauth_login(bytes: &[u8]) -> Result<(), &'static str> {
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| "is not JSON")?;
if value.get("_link_assistant_router").is_some() {
return Err("is a Router pointer, not Claude Code's own login");
}
let oauth = value
.get("claudeAiOauth")
.ok_or("holds no Claude.ai OAuth login")?;
let present = |key: &str| {
oauth
.get(key)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.is_empty())
};
if !present("refreshToken") {
return Err("holds no refresh token, so it would stop working at its first expiry");
}
if !present("accessToken") {
return Err("holds no access token");
}
Ok(())
}
#[cfg(unix)]
fn owner(file: &Path) -> Option<(u32, u32)> {
use std::os::unix::fs::MetadataExt as _;
std::fs::metadata(file)
.ok()
.map(|metadata| (metadata.uid(), metadata.gid()))
}
#[cfg(not(unix))]
const fn owner(_file: &Path) -> Option<(u32, u32)> {
None
}
#[cfg(unix)]
pub(super) fn foreign_entry(data: &Path, uid: u32) -> Option<PathBuf> {
use std::os::unix::fs::MetadataExt as _;
let mut pending = vec![data.to_path_buf()];
while let Some(path) = pending.pop() {
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.uid() != uid {
return Some(path);
}
if metadata.is_dir()
&& let Ok(entries) = std::fs::read_dir(&path)
{
pending.extend(entries.flatten().map(|entry| entry.path()));
}
}
None
}
#[cfg(not(unix))]
pub(super) const fn foreign_entry(_data: &Path, _uid: u32) -> Option<PathBuf> {
None
}
#[cfg(test)]
#[path = "claude_share_tests.rs"]
mod tests;