#[cfg(target_os = "macos")]
use std::path::PathBuf;
use std::{
ffi::OsString,
fs, io,
path::Path,
process::{Command, Stdio},
time::Duration,
};
#[cfg(target_os = "macos")]
use anyhow::Context;
use anyhow::{Result, anyhow, bail};
use rusqlite::{Connection, OpenFlags};
use serde::Deserialize;
#[cfg(target_os = "macos")]
use crate::profile::secure_directory;
use crate::{
indicator,
profile::{
LAUNCHED_TOOL_VARIABLE, NATIVE_ENVIRONMENT, NATIVE_HOME_ENVIRONMENT, Profile, preserved,
},
program, shared,
tools::{self, Home},
};
const OMP_DATABASE: &str = "agent.db";
#[cfg(target_os = "macos")]
const ELECTRON_USER_DATA: &str = "electron-user-data";
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Tool {
Claude,
Codex,
Fx,
Opencode,
Omp,
PrimeAgent,
Pi,
Generic(&'static tools::Spec),
}
impl Tool {
const BUILT_IN: [Self; 7] = [
Self::Claude,
Self::Codex,
Self::Fx,
Self::Opencode,
Self::Omp,
Self::PrimeAgent,
Self::Pi,
];
pub const ALL: [Self; Self::BUILT_IN.len() + tools::ALL.len()] = Self::all();
const fn all() -> [Self; Self::BUILT_IN.len() + tools::ALL.len()] {
let mut all = [Self::Claude; Self::BUILT_IN.len() + tools::ALL.len()];
let mut index = 0;
while index < Self::BUILT_IN.len() {
all[index] = Self::BUILT_IN[index];
index += 1;
}
let mut described = 0;
while described < tools::ALL.len() {
all[index + described] = Self::Generic(&tools::ALL[described]);
described += 1;
}
all
}
pub fn index(self) -> usize {
Self::ALL
.iter()
.position(|tool| *tool == self)
.expect("every Tool is an entry of Tool::ALL")
}
pub fn by_key(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|tool| tool.key() == key)
}
pub fn installed(self) -> bool {
program::installed(&self.executable())
}
pub fn label(self) -> &'static str {
match self {
Self::Claude => "Claude Code",
Self::Codex => "Codex",
Self::Fx => "fx",
Self::Opencode => "opencode",
Self::Omp => "OMP",
Self::PrimeAgent => "Prime Agent",
Self::Pi => "Pi",
Self::Generic(spec) => spec.label,
}
}
pub fn key(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Fx => "fx",
Self::Opencode => "opencode",
Self::Omp => "omp",
Self::PrimeAgent => "prime-agent",
Self::Pi => "pi",
Self::Generic(spec) => spec.key,
}
}
fn executable(self) -> OsString {
let override_variable = match self {
Self::Claude => "DITTO_CLAUDE_BIN",
Self::Codex => "DITTO_CODEX_BIN",
Self::Fx => "DITTO_FX_BIN",
Self::Opencode => "DITTO_OPENCODE_BIN",
Self::Omp => "DITTO_OMP_BIN",
Self::PrimeAgent => "DITTO_PRIME_AGENT_BIN",
Self::Pi => "DITTO_PI_BIN",
Self::Generic(spec) => spec.bin_variable,
};
std::env::var_os(override_variable).unwrap_or_else(|| match self {
Self::Claude => OsString::from("claude"),
Self::Codex => OsString::from("codex"),
Self::Fx => OsString::from("fx"),
Self::Opencode => OsString::from("opencode"),
Self::Omp => OsString::from("omp"),
Self::PrimeAgent => OsString::from("prime-agent"),
Self::Pi => OsString::from("pi"),
Self::Generic(spec) => OsString::from(spec.executable),
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthOperation {
Login,
Logout,
}
impl AuthOperation {
pub fn label(self) -> &'static str {
match self {
Self::Login => "Sign in",
Self::Logout => "Sign out",
}
}
fn args(self, tool: Tool) -> Option<&'static [&'static str]> {
match (self, tool) {
(Self::Login, Tool::Claude) => Some(&["auth", "login"]),
(Self::Login, Tool::Codex) => Some(&["login"]),
(Self::Login, Tool::Fx) => Some(&["login"]),
(Self::Login, Tool::Opencode) => Some(&["auth", "login"]),
(Self::Login, Tool::PrimeAgent) => Some(&["/login"]),
(Self::Logout, Tool::Claude) => Some(&["auth", "logout"]),
(Self::Logout, Tool::Codex) => Some(&["logout"]),
(Self::Logout, Tool::Fx) => Some(&["logout"]),
(Self::Logout, Tool::Opencode) => Some(&["auth", "logout"]),
(Self::Logout, Tool::PrimeAgent) => Some(&["/logout"]),
(_, Tool::Omp | Tool::Pi) => None,
(Self::Login, Tool::Generic(spec)) => spec.login,
(Self::Logout, Tool::Generic(spec)) => spec.logout,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthStatus {
SignedIn,
SignedOut,
Unavailable,
}
impl AuthStatus {
pub fn key(self) -> &'static str {
match self {
Self::SignedIn => "signed_in",
Self::SignedOut => "signed_out",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Deserialize)]
struct ClaudeAuthStatus {
#[serde(rename = "loggedIn")]
logged_in: bool,
}
#[derive(Deserialize)]
struct FxAuthStatus {
auth: String,
}
pub fn build_command(tool: Tool, profile: &Profile, args: &[OsString]) -> Command {
let mut command = base_command(tool, profile);
command.args(args);
command
}
pub fn auth_status(tool: Tool, profile: &Profile) -> AuthStatus {
let status_args: &[&str] = match tool {
Tool::Claude => &["auth", "status", "--json"],
Tool::Codex => &["login", "status"],
Tool::Fx => &["status", "--json"],
Tool::Opencode => &["auth", "list"],
Tool::Omp => return omp_auth_status(profile),
Tool::PrimeAgent => return prime_agent_auth_status(profile),
Tool::Pi => return pi_auth_status(profile),
Tool::Generic(spec) => return generic_auth_status(tool, spec, profile),
};
let output = base_command(tool, profile)
.args(status_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output();
let Ok(output) = output else {
return AuthStatus::Unavailable;
};
match tool {
Tool::Claude => parse_claude_auth_status(&output.stdout),
Tool::Codex => {
parse_codex_auth_status(output.status.success(), &output.stdout, &output.stderr)
}
Tool::Fx => parse_fx_auth_status(output.status.success(), &output.stdout),
Tool::Opencode => parse_opencode_auth_status(output.status.success(), &output.stdout),
Tool::Omp | Tool::PrimeAgent | Tool::Pi | Tool::Generic(_) => {
unreachable!("file-based auth status returned before command execution")
}
}
}
fn generic_auth_status(tool: Tool, spec: &'static tools::Spec, profile: &Profile) -> AuthStatus {
if !tool.installed() || spec.credentials.is_empty() {
return AuthStatus::Unavailable;
}
if spec
.credentials
.iter()
.any(|name| profile.tool_path(spec, name).exists())
{
AuthStatus::SignedIn
} else {
AuthStatus::SignedOut
}
}
fn omp_auth_status(profile: &Profile) -> AuthStatus {
let database = profile.omp_home.join(OMP_DATABASE);
if !database.exists() {
return AuthStatus::SignedOut;
}
match omp_credential_count(&database) {
Ok(0) => AuthStatus::SignedOut,
Ok(_) => AuthStatus::SignedIn,
Err(_) => AuthStatus::Unavailable,
}
}
fn omp_credential_count(database: &Path) -> rusqlite::Result<i64> {
let connection = Connection::open_with_flags(database, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
connection.busy_timeout(Duration::from_millis(250))?;
connection.query_row(
"SELECT count(*) FROM auth_credentials WHERE disabled_cause IS NULL",
[],
|row| row.get(0),
)
}
fn prime_agent_auth_status(profile: &Profile) -> AuthStatus {
json_auth_status(&profile.prime_agent_home.join("auth.json"), |provider| {
!provider.starts_with("mcp:") && provider != "prime-agent-traces" && provider != "serper"
})
}
fn pi_auth_status(profile: &Profile) -> AuthStatus {
json_auth_status(&profile.pi_home.join("auth.json"), |_| true)
}
fn json_auth_status(auth: &Path, counts: impl Fn(&str) -> bool) -> AuthStatus {
let contents = match fs::read(auth) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => return AuthStatus::SignedOut,
Err(_) => return AuthStatus::Unavailable,
};
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&contents) else {
return AuthStatus::Unavailable;
};
let Some(credentials) = value.as_object() else {
return AuthStatus::Unavailable;
};
if credentials.keys().any(|provider| counts(provider)) {
AuthStatus::SignedIn
} else {
AuthStatus::SignedOut
}
}
fn parse_claude_auth_status(stdout: &[u8]) -> AuthStatus {
serde_json::from_slice::<ClaudeAuthStatus>(stdout)
.map(|status| {
if status.logged_in {
AuthStatus::SignedIn
} else {
AuthStatus::SignedOut
}
})
.unwrap_or(AuthStatus::Unavailable)
}
fn parse_codex_auth_status(success: bool, stdout: &[u8], stderr: &[u8]) -> AuthStatus {
if success {
return AuthStatus::SignedIn;
}
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
if stdout.trim() == "Not logged in" || stderr.trim() == "Not logged in" {
AuthStatus::SignedOut
} else {
AuthStatus::Unavailable
}
}
fn parse_fx_auth_status(success: bool, stdout: &[u8]) -> AuthStatus {
if !success {
return AuthStatus::Unavailable;
}
match serde_json::from_slice::<FxAuthStatus>(stdout) {
Ok(status) if status.auth == "missing" => AuthStatus::SignedOut,
Ok(_) => AuthStatus::SignedIn,
Err(_) => AuthStatus::Unavailable,
}
}
fn parse_opencode_auth_status(success: bool, stdout: &[u8]) -> AuthStatus {
if !success {
return AuthStatus::Unavailable;
}
let plain = strip_ansi(&String::from_utf8_lossy(stdout));
match credential_count(&plain) {
Some(0) => AuthStatus::SignedOut,
Some(_) => AuthStatus::SignedIn,
None => AuthStatus::Unavailable,
}
}
fn credential_count(text: &str) -> Option<u64> {
let words = text.split_whitespace().collect::<Vec<_>>();
words
.windows(2)
.rev()
.find(|pair| pair[1].starts_with("credential"))
.and_then(|pair| pair[0].parse().ok())
}
fn strip_ansi(text: &str) -> String {
let mut plain = String::with_capacity(text.len());
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character != '\u{1b}' {
plain.push(character);
continue;
}
if characters.next() == Some('[') {
characters
.by_ref()
.find(|escaped| matches!(escaped, '\u{40}'..='\u{7e}'));
}
}
plain
}
pub fn authenticate(operation: AuthOperation, tool: Tool, profile: &Profile) -> Result<()> {
let Some(args) = operation.args(tool) else {
bail!(
"{} authentication is managed inside {} with `/login` and `/logout`",
tool.label(),
tool.label()
);
};
let interrupts = Interrupts::leave_to_tool();
let started = base_command(tool, profile).args(args).status();
drop(interrupts);
let status = started.map_err(|error| cannot_launch(tool, error))?;
if !status.success() {
bail!(
"{} for {} failed with {status}",
operation.label(),
tool.label()
);
}
Ok(())
}
fn base_command(tool: Tool, profile: &Profile) -> Command {
let mut command = Command::new(program::resolve(&tool.executable()));
preserve_native_environment(&mut command);
command
.env("DITTO_PROFILE", &profile.name)
.env(LAUNCHED_TOOL_VARIABLE, tool.key());
match tool {
Tool::Claude => {
command.env("CLAUDE_CONFIG_DIR", &profile.claude_home);
}
Tool::Codex => {
command.env("CODEX_HOME", &profile.codex_home);
}
Tool::Fx => {
if profile.managed {
command
.env("HOME", &profile.fx_home)
.env("FX_DISABLE_KEYCHAIN", "1");
}
}
Tool::Opencode => {
command
.env("XDG_DATA_HOME", &profile.opencode.data)
.env("XDG_CONFIG_HOME", &profile.opencode.config)
.env("XDG_STATE_HOME", &profile.opencode.state);
}
Tool::Omp => {
command.env_remove("OMP_PROFILE").env_remove("PI_PROFILE");
if profile.managed {
command
.arg("--profile")
.arg(&profile.name)
.env("OMP_PROFILE", &profile.name);
}
}
Tool::PrimeAgent => {
command.env("PRIME_AGENT_CODING_AGENT_DIR", &profile.prime_agent_home);
if profile.managed {
command
.env(
"PRIME_AGENT_SESSION_DIR",
profile.prime_agent_home.join("sessions"),
)
.env_remove("PRIME_AGENT_CODING_AGENT_SESSION_DIR");
}
}
Tool::Pi => {
command.env("PI_CODING_AGENT_DIR", &profile.pi_home);
if profile.managed {
command.env(
"PI_CODING_AGENT_SESSION_DIR",
profile.pi_home.join("sessions"),
);
}
}
Tool::Generic(spec) => {
match spec.home {
Home::Variable { variable, .. } | Home::Parent { variable, .. } => {
command.env(variable, profile.tool_home(spec));
}
Home::Xdg { .. } => {
command
.env("XDG_DATA_HOME", profile.xdg_base(spec, "data"))
.env("XDG_CONFIG_HOME", profile.xdg_base(spec, "config"))
.env("XDG_STATE_HOME", profile.xdg_base(spec, "state"));
}
Home::Private { .. } => {
if profile.managed {
command.env("HOME", profile.tool_home(spec));
}
}
}
if profile.managed {
command.envs(spec.managed_env.iter().copied());
}
}
}
command
}
pub fn launch_codex_desktop(
profile: &Profile,
user_home: &Path,
directory: Option<&Path>,
) -> Result<()> {
#[cfg(not(target_os = "macos"))]
{
let _ = (profile, user_home, directory);
bail!("Codex Desktop launching is only available on macOS");
}
#[cfg(target_os = "macos")]
{
if profile.managed {
let metadata = fs::symlink_metadata(&profile.codex_home)
.with_context(|| format!("could not inspect {}", profile.codex_home.display()))?;
if !metadata.file_type().is_dir() {
bail!(
"Codex profile home is not a real directory: {}",
profile.codex_home.display()
);
}
}
if profile.managed && std::env::var_os("CODEX_ACCESS_TOKEN").is_some() {
bail!(
"CODEX_ACCESS_TOKEN is set; unset it so the selected desktop profile controls authentication"
);
}
let app = codex_desktop_app(user_home)?;
let user_data = profile
.managed
.then(|| profile.codex_home.join(ELECTRON_USER_DATA));
if let Some(path) = &user_data {
ensure_private_directory(path)?;
}
let status = codex_desktop_command(&app, profile, directory, user_data.as_deref())
.status()
.with_context(|| format!("could not launch {}", app.display()))?;
if !status.success() {
bail!(
"could not launch {}: `open` exited with {status}",
app.display()
);
}
eprintln!(
"ditto-cli: requested Codex Desktop launch for profile '{}'",
profile.name
);
Ok(())
}
}
#[cfg(target_os = "macos")]
pub(crate) fn codex_desktop_app(user_home: &Path) -> Result<PathBuf> {
if let Some(path) = std::env::var_os("DITTO_CHATGPT_APP").map(PathBuf::from) {
if path.is_dir() {
return Ok(path);
}
bail!(
"DITTO_CHATGPT_APP does not name an application bundle: {}",
path.display()
);
}
[
PathBuf::from("/Applications/ChatGPT.app"),
user_home.join("Applications/ChatGPT.app"),
PathBuf::from("/Applications/Codex.app"),
user_home.join("Applications/Codex.app"),
]
.into_iter()
.find(|path| path.is_dir())
.ok_or_else(|| {
anyhow!(
"ChatGPT Desktop is not installed; install it in Applications or set DITTO_CHATGPT_APP"
)
})
}
#[cfg(target_os = "macos")]
fn codex_desktop_command(
app: &Path,
profile: &Profile,
directory: Option<&Path>,
user_data: Option<&Path>,
) -> Command {
let mut command = Command::new("open");
if let Some(user_data) = user_data {
command.arg("-n");
let mut environment = base_command(Tool::Codex, profile);
environment.envs(std::env::vars_os().filter(|(name, _)| {
name == "DITTO_HOME" || name.to_string_lossy().starts_with("DITTO_NATIVE_")
}));
environment.env("CODEX_ELECTRON_USER_DATA_PATH", user_data);
for (name, value) in environment.get_envs() {
if let Some(value) = value {
command
.arg("--env")
.arg(environment_assignment(name, value));
}
}
}
command.arg("-a").arg(app);
if let Some(directory) = directory {
command.arg(directory);
}
if let Some(user_data) = user_data {
let mut argument = OsString::from("--user-data-dir=");
argument.push(user_data);
command.arg("--args").arg(argument);
}
command
}
#[cfg(target_os = "macos")]
fn environment_assignment(name: &std::ffi::OsStr, value: &std::ffi::OsStr) -> OsString {
let mut assignment = OsString::from(name);
assignment.push("=");
assignment.push(value);
assignment
}
#[cfg(target_os = "macos")]
fn ensure_private_directory(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
bail!(
"refusing symlinked desktop state directory {}",
path.display()
)
}
Ok(metadata) if !metadata.is_dir() => {
bail!("desktop state path is not a directory: {}", path.display())
}
Ok(_) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {
use std::os::unix::fs::DirBuilderExt;
fs::DirBuilder::new()
.mode(0o700)
.create(path)
.with_context(|| format!("could not create {}", path.display()))?;
}
Err(error) => {
return Err(error).with_context(|| format!("could not inspect {}", path.display()));
}
}
secure_directory(path)
}
fn preserve_native_environment(command: &mut Command) {
if std::env::var_os("DITTO_PROFILE").is_some() {
return;
}
for (variable, preserved) in NATIVE_ENVIRONMENT {
if std::env::var_os(preserved).is_none()
&& let Some(value) = std::env::var_os(variable)
{
command.env(preserved, value);
}
}
for spec in tools::ALL {
if let Home::Variable { variable, .. } | Home::Parent { variable, .. } = spec.home
&& std::env::var_os(preserved(variable)).is_none()
&& let Some(value) = std::env::var_os(variable)
{
command.env(preserved(variable), value);
}
}
if std::env::var_os(NATIVE_HOME_ENVIRONMENT.1).is_none()
&& let Some(value) = std::env::var_os(NATIVE_HOME_ENVIRONMENT.0)
{
command.env(NATIVE_HOME_ENVIRONMENT.1, value);
}
}
#[cfg(unix)]
const NO_PROXY_VARIABLE: &str = "DITTO_NO_PROXY";
#[cfg(not(unix))]
fn show_profile(tool: Tool, profile: &Profile) {
if tool == Tool::Claude {
indicator::enable_quietly(profile);
}
crate::herdr::report_profile(profile);
indicator::announce(tool, profile);
}
#[cfg(unix)]
fn proxy_wanted() -> bool {
use std::io::IsTerminal;
std::env::var_os(NO_PROXY_VARIABLE).is_none()
&& crate::herdr::pane().is_none()
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
}
fn repair_shared_links(tool: Tool, profile: &Profile) {
let repaired = shared::repair_for(tool, profile);
for link in &repaired.links {
eprintln!("ditto-cli: repaired {link}; it was installed pointing at nothing");
}
for (link, reason) in &repaired.failed {
eprintln!("ditto-cli: could not repair {link}: {reason}");
}
}
fn cannot_launch(tool: Tool, error: io::Error) -> anyhow::Error {
if error.kind() == io::ErrorKind::NotFound {
return anyhow!(
"{} is not installed, or its command is not on PATH",
tool.label()
);
}
anyhow::Error::new(error).context(format!("could not launch {}", tool.label()))
}
struct Interrupts;
impl Interrupts {
#[cfg(windows)]
fn leave_to_tool() -> Self {
unsafe {
windows_sys::Win32::System::Console::SetConsoleCtrlHandler(
Some(ignore_interrupt),
windows_sys::Win32::Foundation::TRUE,
);
}
Self
}
#[cfg(not(windows))]
fn leave_to_tool() -> Self {
Self
}
}
impl Drop for Interrupts {
fn drop(&mut self) {
#[cfg(windows)]
unsafe {
windows_sys::Win32::System::Console::SetConsoleCtrlHandler(
Some(ignore_interrupt),
windows_sys::Win32::Foundation::FALSE,
);
}
}
}
#[cfg(windows)]
unsafe extern "system" fn ignore_interrupt(event: u32) -> windows_sys::core::BOOL {
use windows_sys::Win32::{
Foundation::{FALSE, TRUE},
System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT},
};
if matches!(event, CTRL_C_EVENT | CTRL_BREAK_EVENT) {
TRUE
} else {
FALSE
}
}
#[cfg(unix)]
pub fn launch(tool: Tool, profile: &Profile, args: &[OsString]) -> Result<()> {
use std::os::unix::process::CommandExt;
repair_shared_links(tool, profile);
if tool == Tool::Claude {
indicator::enable_quietly(profile);
}
if proxy_wanted() {
match crate::proxy::run(tool, profile, args) {
Ok(status) => std::process::exit(crate::proxy::exit_code(status)),
Err(error) => {
eprintln!("ditto-cli: {error:#}");
eprintln!("ditto-cli: launching {} directly", tool.label());
}
}
}
crate::herdr::report_profile(profile);
indicator::announce(tool, profile);
let error = build_command(tool, profile, args).exec();
Err(cannot_launch(tool, error))
}
#[cfg(not(unix))]
pub fn launch(tool: Tool, profile: &Profile, args: &[OsString]) -> Result<()> {
repair_shared_links(tool, profile);
show_profile(tool, profile);
let interrupts = Interrupts::leave_to_tool();
let started = build_command(tool, profile, args).status();
drop(interrupts);
let status = started.map_err(|error| cannot_launch(tool, error))?;
std::process::exit(status.code().unwrap_or(1));
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::profile::OpencodeHome;
fn profile() -> Profile {
Profile {
name: "work".to_owned(),
claude_home: PathBuf::from("/profiles/work/claude"),
codex_home: PathBuf::from("/profiles/work/codex"),
fx_home: PathBuf::from("/profiles/work/fx-home"),
omp_home: PathBuf::from("/omp/profiles/work/agent"),
opencode: OpencodeHome {
data: PathBuf::from("/profiles/work/opencode/data"),
config: PathBuf::from("/profiles/work/opencode/config"),
state: PathBuf::from("/profiles/work/opencode/state"),
},
pi_home: PathBuf::from("/profiles/work/pi"),
prime_agent_home: PathBuf::from("/profiles/work/prime-agent"),
generic: Vec::new(),
managed: true,
}
}
#[test]
fn claude_uses_the_selected_config_directory() {
let command = build_command(Tool::Claude, &profile(), &[]);
let configured_home = command
.get_envs()
.find(|(name, _)| *name == "CLAUDE_CONFIG_DIR")
.and_then(|(_, value)| value);
assert_eq!(
configured_home,
Some(std::ffi::OsStr::new("/profiles/work/claude"))
);
}
#[test]
fn codex_uses_the_selected_home() {
let command = build_command(Tool::Codex, &profile(), &[]);
let configured_home = command
.get_envs()
.find(|(name, _)| *name == "CODEX_HOME")
.and_then(|(_, value)| value);
assert_eq!(
configured_home,
Some(std::ffi::OsStr::new("/profiles/work/codex"))
);
}
#[cfg(target_os = "macos")]
#[test]
fn desktop_state_cannot_redirect_to_another_profile() {
use std::os::unix::fs::{PermissionsExt, symlink};
let temporary = tempfile::tempdir().unwrap();
let other_profile = temporary.path().join("other");
fs::create_dir(&other_profile).unwrap();
fs::set_permissions(&other_profile, fs::Permissions::from_mode(0o755)).unwrap();
let state = temporary.path().join("electron-user-data");
symlink(&other_profile, &state).unwrap();
assert!(ensure_private_directory(&state).is_err());
assert_eq!(
fs::metadata(&other_profile).unwrap().permissions().mode() & 0o777,
0o755
);
assert!(state.is_symlink());
}
#[test]
fn fx_uses_a_private_home_and_profile_file_credentials() {
let profile = profile();
let command = build_command(Tool::Fx, &profile, &[]);
let environment = command.get_envs().collect::<Vec<_>>();
assert!(environment.contains(&(
std::ffi::OsStr::new("HOME"),
Some(profile.fx_home.as_os_str())
)));
assert!(environment.contains(&(
std::ffi::OsStr::new("FX_DISABLE_KEYCHAIN"),
Some(std::ffi::OsStr::new("1"))
)));
}
#[test]
fn opencode_pins_every_xdg_base_that_holds_account_state() {
let command = build_command(Tool::Opencode, &profile(), &[]);
let environment = command.get_envs().collect::<Vec<_>>();
for (variable, expected) in [
("XDG_DATA_HOME", "/profiles/work/opencode/data"),
("XDG_CONFIG_HOME", "/profiles/work/opencode/config"),
("XDG_STATE_HOME", "/profiles/work/opencode/state"),
] {
assert!(
environment.contains(&(
std::ffi::OsStr::new(variable),
Some(std::ffi::OsStr::new(expected))
)),
"{variable} was not pinned to {expected}"
);
}
assert!(
!environment
.iter()
.any(|(name, _)| *name == std::ffi::OsStr::new("XDG_CACHE_HOME"))
);
}
#[test]
fn prime_agent_uses_isolated_config_and_session_directories() {
let profile = profile();
let expected_sessions = profile.prime_agent_home.join("sessions");
let command = build_command(Tool::PrimeAgent, &profile, &[]);
let environment = command.get_envs().collect::<Vec<_>>();
assert!(environment.contains(&(
std::ffi::OsStr::new("PRIME_AGENT_CODING_AGENT_DIR"),
Some(profile.prime_agent_home.as_os_str())
)));
assert!(environment.contains(&(
std::ffi::OsStr::new("PRIME_AGENT_SESSION_DIR"),
Some(expected_sessions.as_os_str())
)));
assert!(environment.contains(&(
std::ffi::OsStr::new("PRIME_AGENT_CODING_AGENT_SESSION_DIR"),
None
)));
}
#[test]
fn pi_uses_isolated_config_and_session_directories() {
let profile = profile();
let expected_sessions = profile.pi_home.join("sessions");
let command = build_command(Tool::Pi, &profile, &[]);
let environment = command.get_envs().collect::<Vec<_>>();
assert!(environment.contains(&(
std::ffi::OsStr::new("PI_CODING_AGENT_DIR"),
Some(profile.pi_home.as_os_str())
)));
assert!(environment.contains(&(
std::ffi::OsStr::new("PI_CODING_AGENT_SESSION_DIR"),
Some(expected_sessions.as_os_str())
)));
}
#[test]
fn omp_uses_native_named_profile_and_exports_selection() {
let command = build_command(
Tool::Omp,
&profile(),
&[OsString::from("--model"), OsString::from("opus")],
);
let arguments = command.get_args().collect::<Vec<_>>();
let environment = command.get_envs().collect::<Vec<_>>();
assert_eq!(
arguments,
["--profile", "work", "--model", "opus"].map(std::ffi::OsStr::new)
);
assert!(environment.contains(&(
std::ffi::OsStr::new("DITTO_PROFILE"),
Some(std::ffi::OsStr::new("work"))
)));
assert!(environment.contains(&(
std::ffi::OsStr::new("OMP_PROFILE"),
Some(std::ffi::OsStr::new("work"))
)));
}
#[test]
fn omp_default_profile_ignores_inherited_profile_selection() {
let mut default_profile = profile();
default_profile.name = "default".to_owned();
default_profile.managed = false;
let command = build_command(Tool::Omp, &default_profile, &[]);
let omp_profile = command
.get_envs()
.find(|(name, _)| *name == "OMP_PROFILE")
.map(|(_, value)| value);
assert_eq!(command.get_args().next(), None);
assert_eq!(omp_profile, Some(None));
}
#[test]
fn launched_tools_mark_the_environment_as_redirected() {
let command = build_command(Tool::Pi, &profile(), &[]);
let launched_tool = command
.get_envs()
.find(|(name, _)| *name == LAUNCHED_TOOL_VARIABLE)
.and_then(|(_, value)| value);
assert_eq!(launched_tool, Some(std::ffi::OsStr::new("pi")));
}
#[test]
fn stable_tool_keys_include_every_agent() {
assert_eq!(
Tool::BUILT_IN.map(Tool::key),
[
"claude",
"codex",
"fx",
"opencode",
"omp",
"prime-agent",
"pi"
]
);
assert_eq!(
Tool::ALL[Tool::BUILT_IN.len()..]
.iter()
.map(|tool| tool.key())
.collect::<Vec<_>>(),
tools::ALL.iter().map(|spec| spec.key).collect::<Vec<_>>()
);
}
#[test]
fn table_tools_are_pointed_at_the_profile_their_entry_describes() -> anyhow::Result<()> {
use std::collections::HashMap;
use crate::profile::Store;
let dir = tempfile::tempdir()?;
let store = Store::new(dir.path().join(".ditto"), dir.path().to_path_buf());
let profile = store.create_profile("work")?;
for tool in Tool::ALL {
let Tool::Generic(spec) = tool else { continue };
let command = build_command(tool, &profile, &[]);
let environment: HashMap<_, _> = command
.get_envs()
.filter_map(|(name, value)| value.map(|value| (name.to_owned(), value.to_owned())))
.collect();
let pointed_at = |name: &str| {
environment
.get(std::ffi::OsStr::new(name))
.map(PathBuf::from)
};
match spec.home {
Home::Variable { variable, .. } | Home::Parent { variable, .. } => {
assert_eq!(
pointed_at(variable),
Some(profile.tool_home(spec).to_path_buf()),
"{}",
spec.key
);
}
Home::Xdg { .. } => {
assert_eq!(
pointed_at("XDG_CONFIG_HOME"),
Some(profile.xdg_base(spec, "config")),
"{}",
spec.key
);
assert_eq!(
pointed_at("XDG_DATA_HOME"),
Some(profile.xdg_base(spec, "data")),
"{}",
spec.key
);
}
Home::Private { .. } => {
assert_eq!(
pointed_at("HOME"),
Some(profile.tool_home(spec).to_path_buf()),
"{}",
spec.key
);
}
}
for (name, value) in spec.managed_env {
assert_eq!(
environment
.get(std::ffi::OsStr::new(name))
.map(|v| v.to_string_lossy().into_owned())
.as_deref(),
Some(*value),
"{}",
spec.key
);
}
assert!(
profile.tool_root(spec).starts_with(dir.path()),
"{} escapes the profile",
spec.key
);
assert!(
profile.tool_root(spec).is_dir() || matches!(spec.home, Home::Xdg { .. }),
"{} was not created",
spec.key
);
}
Ok(())
}
#[test]
fn authentication_uses_native_cli_commands() {
assert_eq!(
AuthOperation::Login.args(Tool::Claude),
Some(["auth", "login"].as_slice())
);
assert_eq!(
AuthOperation::Login.args(Tool::Codex),
Some(["login"].as_slice())
);
assert_eq!(
AuthOperation::Logout.args(Tool::Claude),
Some(["auth", "logout"].as_slice())
);
assert_eq!(
AuthOperation::Logout.args(Tool::Codex),
Some(["logout"].as_slice())
);
assert_eq!(
AuthOperation::Login.args(Tool::Fx),
Some(["login"].as_slice())
);
assert_eq!(
AuthOperation::Logout.args(Tool::Fx),
Some(["logout"].as_slice())
);
assert_eq!(
AuthOperation::Login.args(Tool::Opencode),
Some(["auth", "login"].as_slice())
);
assert_eq!(
AuthOperation::Logout.args(Tool::Opencode),
Some(["auth", "logout"].as_slice())
);
assert_eq!(AuthOperation::Login.args(Tool::Omp), None);
assert_eq!(AuthOperation::Logout.args(Tool::Omp), None);
assert_eq!(
AuthOperation::Login.args(Tool::PrimeAgent),
Some(["/login"].as_slice())
);
assert_eq!(
AuthOperation::Logout.args(Tool::PrimeAgent),
Some(["/logout"].as_slice())
);
assert_eq!(AuthOperation::Login.args(Tool::Pi), None);
assert_eq!(AuthOperation::Logout.args(Tool::Pi), None);
}
#[test]
fn reads_pi_provider_credentials() {
let temporary = tempfile::tempdir().unwrap();
let mut profile = profile();
profile.pi_home = temporary.path().join("pi");
fs::create_dir_all(&profile.pi_home).unwrap();
assert_eq!(pi_auth_status(&profile), AuthStatus::SignedOut);
fs::write(profile.pi_home.join("auth.json"), "{}").unwrap();
assert_eq!(pi_auth_status(&profile), AuthStatus::SignedOut);
fs::write(
profile.pi_home.join("auth.json"),
r#"{"anthropic":{"type":"oauth"}}"#,
)
.unwrap();
assert_eq!(pi_auth_status(&profile), AuthStatus::SignedIn);
fs::write(profile.pi_home.join("auth.json"), "not json").unwrap();
assert_eq!(pi_auth_status(&profile), AuthStatus::Unavailable);
}
#[test]
fn reads_prime_agent_provider_credentials_without_counting_services() {
let temporary = tempfile::tempdir().unwrap();
let mut profile = profile();
profile.prime_agent_home = temporary.path().join("prime-agent");
fs::create_dir_all(&profile.prime_agent_home).unwrap();
assert_eq!(prime_agent_auth_status(&profile), AuthStatus::SignedOut);
fs::write(profile.prime_agent_home.join("auth.json"), "{}").unwrap();
assert_eq!(prime_agent_auth_status(&profile), AuthStatus::SignedOut);
fs::write(
profile.prime_agent_home.join("auth.json"),
r#"{
"mcp:notion": {"type": "oauth"},
"prime-agent-traces": {"type": "api_key"},
"serper": {"type": "api_key"}
}"#,
)
.unwrap();
assert_eq!(prime_agent_auth_status(&profile), AuthStatus::SignedOut);
fs::write(
profile.prime_agent_home.join("auth.json"),
r#"{"anthropic":{"type":"oauth"}}"#,
)
.unwrap();
assert_eq!(prime_agent_auth_status(&profile), AuthStatus::SignedIn);
fs::write(profile.prime_agent_home.join("auth.json"), "not json").unwrap();
assert_eq!(prime_agent_auth_status(&profile), AuthStatus::Unavailable);
}
#[test]
fn parses_decorated_opencode_credential_summary() {
let signed_in = "\u{1b}[0m\n┌ Credentials \u{1b}[90m~/.local/share/opencode/auth.json\n\
│\n● Anthropic \u{1b}[90moauth\n│\n└ 3 credentials\n";
let signed_out = "\u{1b}[0m\n┌ Credentials \u{1b}[90m/tmp/p/opencode/auth.json\n│\n\
└ 0 credentials\n";
assert_eq!(
parse_opencode_auth_status(true, signed_in.as_bytes()),
AuthStatus::SignedIn
);
assert_eq!(
parse_opencode_auth_status(true, signed_out.as_bytes()),
AuthStatus::SignedOut
);
assert_eq!(
parse_opencode_auth_status(true, b"\xe2\x94\x94 1 credential\n"),
AuthStatus::SignedIn
);
assert_eq!(
parse_opencode_auth_status(false, signed_in.as_bytes()),
AuthStatus::Unavailable
);
assert_eq!(
parse_opencode_auth_status(true, b"command not found\n"),
AuthStatus::Unavailable
);
}
#[test]
fn strips_ansi_without_eating_surrounding_text() {
assert_eq!(strip_ansi("\u{1b}[90mdim\u{1b}[0m text"), "dim text");
assert_eq!(strip_ansi("plain"), "plain");
assert_eq!(strip_ansi("a\u{1b}Xb"), "ab");
}
#[test]
fn parses_fx_auth_status_output() {
assert_eq!(
parse_fx_auth_status(true, br#"{"kind":"status","auth":"missing"}"#),
AuthStatus::SignedOut
);
assert_eq!(
parse_fx_auth_status(true, br#"{"kind":"status","auth":"Codex subscription"}"#),
AuthStatus::SignedIn
);
assert_eq!(parse_fx_auth_status(false, b"{}"), AuthStatus::Unavailable);
assert_eq!(
parse_fx_auth_status(true, b"not json"),
AuthStatus::Unavailable
);
}
#[test]
fn parses_native_auth_status_output() {
assert_eq!(
parse_claude_auth_status(br#"{"loggedIn":true}"#),
AuthStatus::SignedIn
);
assert_eq!(
parse_claude_auth_status(br#"{"loggedIn":false}"#),
AuthStatus::SignedOut
);
assert_eq!(
parse_codex_auth_status(false, b"", b"Not logged in\n"),
AuthStatus::SignedOut
);
assert_eq!(
parse_codex_auth_status(true, b"Logged in using ChatGPT\n", b""),
AuthStatus::SignedIn
);
assert_eq!(
parse_codex_auth_status(false, b"", b"configuration error\n"),
AuthStatus::Unavailable
);
}
}