use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::mem::ManuallyDrop;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{RecvTimeoutError, Sender, channel};
use std::thread::{self, JoinHandle};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use crate::claude::{build_claude_settings_json, create_symlink};
use crate::lock::with_state_lock;
use crate::profile::{ClaudeCredentials, Profile, atomic_write, home_dir, profile_dir};
const WATCHDOG_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkMode {
Real,
Fake,
}
fn runtime_dir(name: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join("runtime"))
}
fn sessions_dir(name: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join("sessions"))
}
pub(crate) fn has_live_session(name: &str) -> bool {
let Ok(dir) = sessions_dir(name) else {
return false;
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return false;
};
entries.flatten().any(|e| is_session_alive(&e.path()))
}
fn canonical_credentials(name: &str) -> Result<PathBuf> {
Ok(profile_dir(name)?.join("credentials.json"))
}
pub(crate) fn open_pid_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
pub(crate) struct ProfileRuntime {
runtime: PathBuf,
pid_file: PathBuf,
claude_home: PathBuf,
canonical: PathBuf,
sessions: PathBuf,
mode: LinkMode,
_pid_lock: File,
watchdog_signal: ManuallyDrop<Sender<()>>,
watchdog_handle: Option<JoinHandle<()>>,
}
impl ProfileRuntime {
pub(crate) fn acquire(profile: &Profile) -> Result<Self> {
let name = &profile.name;
let claude_home = home_dir()?.join(".claude");
if !claude_home.exists() {
anyhow::bail!("~/.claude not found; install Claude Code first");
}
let runtime = runtime_dir(name)?;
let sessions = sessions_dir(name)?;
let pid_file = sessions.join(std::process::id().to_string());
let canonical = canonical_credentials(name)?;
let (pid_lock, mode) = with_state_lock(|| {
std::fs::create_dir_all(&sessions)
.with_context(|| format!("failed to create {}", sessions.display()))?;
let active = prune_stale_sessions(&sessions)?;
if active == 0 && runtime.symlink_metadata().is_ok() {
std::fs::remove_dir_all(&runtime)
.with_context(|| format!("failed to clear {}", runtime.display()))?;
}
std::fs::create_dir_all(&runtime)
.with_context(|| format!("failed to create {}", runtime.display()))?;
let mode = detect_link_mode(&runtime)?;
build_runtime_dir(&runtime, &claude_home, profile, &canonical, mode)?;
let file = open_pid_file(&pid_file)
.with_context(|| format!("failed to open {}", pid_file.display()))?;
file.lock()
.with_context(|| format!("failed to lock {}", pid_file.display()))?;
Ok::<_, anyhow::Error>((file, mode))
})?;
let (tx, rx) = channel::<()>();
let watchdog_runtime = runtime.clone();
let watchdog_canonical = canonical.clone();
let watchdog_claude_home = claude_home.clone();
let watchdog_handle = thread::spawn(move || {
while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(WATCHDOG_INTERVAL) {
if let Err(e) = tick(
mode,
&watchdog_runtime,
&watchdog_claude_home,
&watchdog_canonical,
) {
eprintln!("clauth: watchdog tick failed: {e}");
}
}
});
Ok(Self {
runtime,
pid_file,
claude_home,
canonical,
sessions,
mode,
_pid_lock: pid_lock,
watchdog_signal: ManuallyDrop::new(tx),
watchdog_handle: Some(watchdog_handle),
})
}
pub(crate) fn config_dir(&self) -> &Path {
&self.runtime
}
}
impl Drop for ProfileRuntime {
fn drop(&mut self) {
unsafe { ManuallyDrop::drop(&mut self.watchdog_signal) };
if let Some(h) = self.watchdog_handle.take() {
let _ = h.join();
}
if let Err(e) = tick(self.mode, &self.runtime, &self.claude_home, &self.canonical) {
eprintln!("clauth: final sync failed: {e}");
}
if let Err(e) = with_state_lock(|| {
if let Err(e) = std::fs::remove_file(&self.pid_file)
&& e.kind() != std::io::ErrorKind::NotFound
{
eprintln!("clauth: remove pid file failed: {e}");
}
let still_active = prune_stale_sessions(&self.sessions).unwrap_or(1);
if still_active == 0 {
let _ = std::fs::remove_dir_all(&self.runtime);
let _ = std::fs::remove_dir(&self.sessions);
}
Ok::<_, anyhow::Error>(())
}) {
eprintln!("clauth: drop cleanup failed: {e}");
}
}
}
fn detect_link_mode(runtime: &Path) -> Result<LinkMode> {
let probe_target = runtime.join(".clauth-probe-target");
let probe_link = runtime.join(".clauth-probe-link");
let _ = std::fs::remove_file(&probe_target);
let _ = std::fs::remove_file(&probe_link);
std::fs::write(&probe_target, b"")
.with_context(|| format!("failed to write {}", probe_target.display()))?;
let mode = match try_real_symlink(&probe_target, &probe_link) {
Ok(()) => LinkMode::Real,
Err(_) => LinkMode::Fake,
};
let _ = std::fs::remove_file(&probe_link);
let _ = std::fs::remove_file(&probe_target);
Ok(mode)
}
#[cfg(unix)]
fn try_real_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn try_real_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(target, link)
}
#[cfg(not(any(unix, windows)))]
fn try_real_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"no symlink support",
))
}
fn prune_stale_sessions(sessions: &Path) -> Result<usize> {
let entries = match std::fs::read_dir(sessions) {
Ok(e) => e,
Err(_) => return Ok(0),
};
let mut alive = 0;
for entry in entries.flatten() {
let path = entry.path();
if is_session_alive(&path) {
alive += 1;
} else {
let _ = std::fs::remove_file(&path);
}
}
Ok(alive)
}
fn is_session_alive(pid_file: &Path) -> bool {
let Ok(file) = OpenOptions::new().read(true).write(true).open(pid_file) else {
return false;
};
file.try_lock().is_err()
}
fn build_runtime_dir(
runtime: &Path,
claude_home: &Path,
profile: &Profile,
canonical: &Path,
mode: LinkMode,
) -> Result<()> {
for entry in std::fs::read_dir(claude_home)
.with_context(|| format!("failed to read {}", claude_home.display()))?
{
let entry = entry?;
let file_name = entry.file_name();
if file_name == "settings.json" || file_name == ".credentials.json" {
continue;
}
let dst = runtime.join(&file_name);
if dst.symlink_metadata().is_ok() {
continue;
}
materialize_entry(&entry.path(), &dst, mode)?;
}
let settings_src = claude_home.join("settings.json");
let merged = build_claude_settings_json(&settings_src, profile, &[])?;
let settings_dst = runtime.join("settings.json");
let needs_write = std::fs::read(&settings_dst)
.map(|existing| existing != merged.as_bytes())
.unwrap_or(true);
if needs_write {
atomic_write(&settings_dst, merged).context("failed to write runtime settings.json")?;
}
let creds_link = runtime.join(".credentials.json");
reconcile_credentials(&creds_link, canonical, mode)?;
if let Some(home) = claude_home.parent() {
let claude_json = home.join(".claude.json");
let dst = runtime.join(".claude.json");
if claude_json.exists() && dst.symlink_metadata().is_err() {
materialize_entry(&claude_json, &dst, mode)?;
}
}
Ok(())
}
fn materialize_entry(src: &Path, dst: &Path, mode: LinkMode) -> Result<()> {
match mode {
LinkMode::Real => link_entry(src, dst),
LinkMode::Fake => copy_tree(src, dst),
}
}
fn reconcile_credentials(runtime_path: &Path, canonical: &Path, mode: LinkMode) -> Result<()> {
match mode {
LinkMode::Real => {
sync_credentials_unlocked(runtime_path, canonical)?;
let meta = runtime_path.symlink_metadata().ok();
if meta.as_ref().is_some_and(|m| m.file_type().is_symlink())
|| meta.as_ref().is_some_and(|m| m.is_file())
{
return Ok(());
}
if canonical.exists() {
create_symlink(canonical, runtime_path)?;
}
}
LinkMode::Fake => {
mirror_credentials(runtime_path, canonical)?;
}
}
Ok(())
}
fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
let meta = src
.symlink_metadata()
.with_context(|| format!("failed to stat {}", src.display()))?;
if meta.file_type().is_dir() {
std::fs::create_dir_all(dst)
.with_context(|| format!("failed to create {}", dst.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
{
let entry = entry?;
copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
} else {
std::fs::copy(src, dst)
.map(|_| ())
.with_context(|| format!("failed to copy {} -> {}", src.display(), dst.display()))
}
}
fn tick(mode: LinkMode, runtime: &Path, claude_home: &Path, canonical: &Path) -> Result<()> {
match mode {
LinkMode::Real => {
let _ = sync_credentials(runtime, canonical)?;
Ok(())
}
LinkMode::Fake => with_state_lock(|| {
mirror_tree(claude_home, runtime)?;
mirror_credentials(&runtime.join(".credentials.json"), canonical)?;
Ok(())
}),
}
}
pub(crate) fn sync_credentials(runtime: &Path, canonical: &Path) -> Result<bool> {
let link_path = runtime.join(".credentials.json");
with_state_lock(|| sync_credentials_unlocked(&link_path, canonical))
}
fn sync_credentials_unlocked(link_path: &Path, canonical: &Path) -> Result<bool> {
let Ok(meta) = link_path.symlink_metadata() else {
return Ok(false);
};
if meta.file_type().is_symlink() {
return Ok(false);
}
let bytes = std::fs::read(link_path).context("failed to read live credentials")?;
let Ok(creds) = serde_json::from_slice::<ClaudeCredentials>(&bytes) else {
return Ok(false);
};
if creds.claude_ai_oauth.is_none() {
return Ok(false);
}
let canonical_bytes = std::fs::read(canonical).ok();
let differs = canonical_bytes.as_deref() != Some(bytes.as_slice());
if differs {
atomic_write(canonical, &bytes)?;
}
if canonical.exists() {
let tmp = link_path.with_file_name(format!(".credentials.json.tmp.{}", std::process::id()));
let _ = std::fs::remove_file(&tmp);
create_symlink(canonical, &tmp)?;
std::fs::rename(&tmp, link_path)?;
} else {
std::fs::remove_file(link_path)?;
}
Ok(differs)
}
fn mirror_credentials(runtime_path: &Path, canonical: &Path) -> Result<()> {
let runtime_meta = runtime_path.metadata().ok();
let canonical_meta = canonical.metadata().ok();
match (runtime_meta, canonical_meta) {
(Some(rm), Some(cm)) => {
let rt_time = rm.modified().ok();
let ca_time = cm.modified().ok();
match (rt_time, ca_time) {
(Some(rt), Some(ca)) if rt > ca => {
copy_if_valid_creds(runtime_path, canonical)?;
}
(Some(rt), Some(ca)) if ca > rt => {
copy_if_valid_creds(canonical, runtime_path)?;
}
_ => {}
}
}
(Some(_), None) => {
copy_if_valid_creds(runtime_path, canonical)?;
}
(None, Some(_)) => {
copy_if_valid_creds(canonical, runtime_path)?;
}
(None, None) => {}
}
Ok(())
}
fn copy_if_valid_creds(src: &Path, dst: &Path) -> Result<()> {
let bytes = std::fs::read(src).with_context(|| format!("failed to read {}", src.display()))?;
let Ok(creds) = serde_json::from_slice::<ClaudeCredentials>(&bytes) else {
return Ok(());
};
if creds.claude_ai_oauth.is_none() {
return Ok(());
}
if std::fs::read(dst).ok().as_deref() == Some(bytes.as_slice()) {
return Ok(());
}
atomic_write(dst, &bytes).with_context(|| format!("failed to write {}", dst.display()))
}
fn mirror_tree(claude_home: &Path, runtime: &Path) -> Result<()> {
let skip_top: HashSet<&str> = ["settings.json", ".credentials.json"].into_iter().collect();
for name in union_children(claude_home, runtime) {
if name.to_str().is_some_and(|n| skip_top.contains(n)) {
continue;
}
merge_path(&claude_home.join(&name), &runtime.join(&name))?;
}
Ok(())
}
fn union_children(a: &Path, b: &Path) -> Vec<std::ffi::OsString> {
let mut names: HashSet<std::ffi::OsString> = HashSet::new();
for dir in [a, b] {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
names.insert(entry.file_name());
}
}
}
let mut out: Vec<_> = names.into_iter().collect();
out.sort();
out
}
fn merge_path(a: &Path, b: &Path) -> Result<()> {
let a_meta = a.symlink_metadata().ok();
let b_meta = b.symlink_metadata().ok();
let a_is_dir = a_meta.as_ref().is_some_and(|m| m.file_type().is_dir());
let b_is_dir = b_meta.as_ref().is_some_and(|m| m.file_type().is_dir());
if a_is_dir || b_is_dir {
if a_is_dir && !b.exists() {
std::fs::create_dir_all(b)
.with_context(|| format!("failed to create {}", b.display()))?;
}
if b_is_dir && !a.exists() {
std::fs::create_dir_all(a)
.with_context(|| format!("failed to create {}", a.display()))?;
}
for name in union_children(a, b) {
merge_path(&a.join(&name), &b.join(&name))?;
}
return Ok(());
}
match (a_meta, b_meta) {
(Some(am), Some(bm)) => {
let a_time = am.modified().ok();
let b_time = bm.modified().ok();
if files_match(a, b)? {
return Ok(());
}
if mtime_newer(a_time, b_time) {
copy_file(a, b)?;
} else if mtime_newer(b_time, a_time) {
copy_file(b, a)?;
}
}
(Some(_), None) => {
copy_file(a, b)?;
}
(None, Some(_)) => {
copy_file(b, a)?;
}
(None, None) => {}
}
Ok(())
}
fn mtime_newer(a: Option<SystemTime>, b: Option<SystemTime>) -> bool {
match (a, b) {
(Some(a), Some(b)) => a > b,
(Some(_), None) => true,
_ => false,
}
}
fn files_match(a: &Path, b: &Path) -> Result<bool> {
let a_bytes = std::fs::read(a).with_context(|| format!("failed to read {}", a.display()))?;
let b_bytes = std::fs::read(b).with_context(|| format!("failed to read {}", b.display()))?;
Ok(a_bytes == b_bytes)
}
fn copy_file(src: &Path, dst: &Path) -> Result<()> {
std::fs::copy(src, dst)
.map(|_| ())
.with_context(|| format!("failed to copy {} -> {}", src.display(), dst.display()))
}
#[cfg(unix)]
fn link_entry(src: &Path, dst: &Path) -> Result<()> {
std::os::unix::fs::symlink(src, dst)
.with_context(|| format!("failed to symlink {} -> {}", dst.display(), src.display()))
}
#[cfg(windows)]
fn link_entry(src: &Path, dst: &Path) -> Result<()> {
let result = if src.is_dir() {
std::os::windows::fs::symlink_dir(src, dst)
} else {
std::os::windows::fs::symlink_file(src, dst)
};
result.with_context(|| {
format!(
"failed to symlink {} -> {} (enable developer mode or run as admin)",
dst.display(),
src.display()
)
})
}
#[cfg(not(any(unix, windows)))]
fn link_entry(_src: &Path, _dst: &Path) -> Result<()> {
anyhow::bail!("clauth start requires symlink support");
}
#[cfg(test)]
#[path = "../tests/inline/runtime.rs"]
mod tests;