Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/commands_account/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2312,8 +2312,7 @@ fn master_precondition_satisfied(
}

fn read_install_state() -> Option<serde_json::Value> {
let home = dirs::home_dir()?;
let state_path = home.join(".aikey").join("install-state.json");
let state_path = crate::commands_account::resolve_aikey_dir().join("install-state.json");
let content = std::fs::read_to_string(&state_path).ok()?;
serde_json::from_str(&content).ok()
}
Expand Down
174 changes: 170 additions & 4 deletions src/commands_account/shell_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1339,14 +1339,75 @@ pub fn resolve_user_home() -> std::path::PathBuf {
std::path::PathBuf::from(".")
}

/// Resolve the ~/.aikey directory path consistently across platforms.
/// Thin wrapper over `resolve_user_home()` — kept as a named helper so
/// callsites read intent-fully and so this stays the only place the
/// `.aikey` literal appears in the home-resolution path.
/// Resolve this install's AiKey directory, consistently across platforms.
///
/// Honours `AIKEY_HOME` (absolute path) first, then falls back to
/// `resolve_user_home()/.aikey` — the historical behaviour, unchanged for every
/// ordinary install.
///
/// Why `AIKEY_HOME` exists (2026-07-22): the installer has always supported
/// `--install-dir` / `--sandbox`, which put the whole install somewhere other
/// than `~/.aikey`. The CLI had no notion of that at all — every path was
/// derived from the user's home — so a relocated install's `aikey` operated on
/// the DEFAULT install's vault, config and run state. Two installs, one data
/// plane, no error message. The installer already exports `AIKEY_HOME` for its
/// own steps; this makes the CLI agree with it.
///
/// A RELATIVE value is ignored rather than honoured: these paths are handed to
/// services and written into config, where a value that depends on the current
/// working directory is a latent bug (the same class as the CWD-relative
/// `.aikey/...` collapse that bit aikey-local-server under systemd). Ignoring is
/// also the safer failure — it lands on the historical default rather than
/// somewhere arbitrary.
pub fn resolve_aikey_dir() -> std::path::PathBuf {
if let Ok(explicit) = std::env::var("AIKEY_HOME") {
let p = std::path::PathBuf::from(&explicit);
if p.is_absolute() {
return p;
}
}
if let Some(p) = aikey_dir_from_own_exe() {
return p;
}
resolve_user_home().join(".aikey")
}

/// Derive this install's root from where THIS binary lives: `<root>/bin/aikey`
/// → `<root>`. Returns `None` when the layout doesn't match or the candidate
/// doesn't look like an install.
///
/// This is what makes relocation work without the user exporting anything.
/// `AIKEY_HOME` covers the installer's own sub-invocations, but a user typing
/// `aikey` in a plain shell six months later has no such variable — and the
/// binary they invoked already knows which install it belongs to. Deriving from
/// argv[0]'s real path needs no rc edits, no wrapper, and cannot drift out of
/// sync with where the files actually are.
///
/// Two guards keep this from firing wrongly:
/// - the parent directory must be named `bin`, so a dev build under
/// `target/debug/aikey` falls through to the home-derived default;
/// - the candidate root must actually contain an install marker, so a shim
/// dropped in `/usr/local/bin` doesn't make us treat `/usr/local` as an
/// AiKey install and start writing a vault there.
/// `current_exe()` resolves symlinks, so a link in `~/bin` still points back at
/// the real install.
fn aikey_dir_from_own_exe() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let bin_dir = exe.parent()?;
if bin_dir.file_name().and_then(|s| s.to_str()) != Some("bin") {
return None;
}
let root = bin_dir.parent()?;
let looks_like_install = root.join("install-state.json").exists()
|| root.join("config").is_dir()
|| root.file_name().and_then(|s| s.to_str()) == Some(".aikey");
if looks_like_install {
Some(root.to_path_buf())
} else {
None
}
}

// ---------------------------------------------------------------------------
// Third-party CLI config-path helpers (Stage 2.3 windows-compat)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -5426,6 +5487,7 @@ mod path_helper_tests {
#[test]
fn resolve_aikey_dir_appends_dot_aikey() {
let _snap = EnvSnapshot::take();
std::env::remove_var("AIKEY_HOME");
std::env::set_var("HOME", "/tmp/aikey-base");
let dir = resolve_aikey_dir();
// PathBuf join may use different separators on Windows; check via
Expand All @@ -5436,6 +5498,110 @@ mod path_helper_tests {
assert_eq!(parent, Some("/tmp/aikey-base"));
}

// ── AIKEY_HOME: relocated installs (2026-07-22) ─────────────────────────
//
// The installer has always supported --install-dir / --sandbox, but the CLI
// had no notion of it: every path came from the user's home, so a relocated
// install's `aikey` operated on the DEFAULT install's vault, config and run
// state. Two installs, one data plane, silently.

#[test]
fn aikey_home_overrides_the_home_derived_default() {
let _snap = EnvSnapshot::take();
std::env::set_var("HOME", "/tmp/aikey-base");
std::env::set_var("AIKEY_HOME", "/opt/aikey-instance-b");
assert_eq!(
resolve_aikey_dir(),
std::path::PathBuf::from("/opt/aikey-instance-b"),
"a relocated install must use its own directory, not $HOME/.aikey"
);
}

#[test]
fn aikey_home_is_used_verbatim_without_appending_dot_aikey() {
// The installer's AIKEY_HOME already IS the install root (it is
// "<sandbox>/personal", not "<sandbox>/personal/.aikey"), so appending
// would point one level too deep at a directory nothing creates.
let _snap = EnvSnapshot::take();
std::env::set_var("HOME", "/tmp/aikey-base");
std::env::set_var("AIKEY_HOME", "/tmp/sbx/personal");
let dir = resolve_aikey_dir();
assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some("personal"));
assert!(!dir.ends_with(".aikey"));
}

#[test]
fn relative_aikey_home_is_ignored() {
// A CWD-relative install root would resolve differently depending on
// where the binary happened to be launched from, and these paths get
// written into config and handed to services. Falling back to the
// historical default is the safer of the two failures.
let _snap = EnvSnapshot::take();
std::env::set_var("HOME", "/tmp/aikey-base");
std::env::set_var("AIKEY_HOME", "relative/path");
assert_eq!(
resolve_aikey_dir(),
std::path::PathBuf::from("/tmp/aikey-base/.aikey"),
"a relative AIKEY_HOME must be ignored, not honoured"
);
}

#[test]
fn empty_aikey_home_falls_back_to_home() {
let _snap = EnvSnapshot::take();
std::env::set_var("HOME", "/tmp/aikey-base");
std::env::set_var("AIKEY_HOME", "");
assert_eq!(
resolve_aikey_dir(),
std::path::PathBuf::from("/tmp/aikey-base/.aikey")
);
}

#[test]
fn own_exe_derivation_requires_a_bin_parent_and_an_install_marker() {
let _snap = EnvSnapshot::take();
std::env::remove_var("AIKEY_HOME");
// A dev build lives at target/debug/aikey — parent is "debug", so the
// derivation must decline and leave the home-derived default in place.
let tmp = tempfile::tempdir().unwrap();
let devish = tmp.path().join("target").join("debug");
std::fs::create_dir_all(&devish).unwrap();
assert!(
devish.file_name().and_then(|s| s.to_str()) != Some("bin"),
"fixture sanity: a dev build's parent dir is not named bin"
);

// A shim in /usr/local/bin has a "bin" parent but /usr/local is not an
// install — without the marker check we would treat it as one and start
// writing a vault there.
let shim_root = tmp.path().join("usr-local");
std::fs::create_dir_all(shim_root.join("bin")).unwrap();
assert!(
!shim_root.join("install-state.json").exists()
&& !shim_root.join("config").is_dir(),
"fixture sanity: the shim root carries no install marker"
);

// A real install does carry one.
let real_root = tmp.path().join("personal");
std::fs::create_dir_all(real_root.join("bin")).unwrap();
std::fs::write(real_root.join("install-state.json"), "{}").unwrap();
assert!(real_root.join("install-state.json").exists());
}

#[test]
fn aikey_home_wins_over_exe_derivation() {
let _snap = EnvSnapshot::take();
std::env::set_var("HOME", "/tmp/aikey-base");
std::env::set_var("AIKEY_HOME", "/opt/explicit-install");
// Explicit beats inferred: the installer knows which install it is
// configuring even when the binary running is somewhere else.
assert_eq!(
resolve_aikey_dir(),
std::path::PathBuf::from("/opt/explicit-install")
);
}

// ── kimi_config_paths / codex_config_paths ──────────────────────────────

#[test]
Expand Down
21 changes: 8 additions & 13 deletions src/commands_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,9 @@ const DEFAULT_CONFIG_NAME: &str = "aikey-proxy.yaml";
/// user typed wrong vault password; vault decrypt error was lost
/// because stderr went to /dev/null.
fn startup_log_path() -> PathBuf {
dirs::home_dir()
.map(|h| {
h.join(".aikey")
.join("logs")
.join("aikey-proxy-startup.log")
})
.unwrap_or_else(|| PathBuf::from("/tmp/aikey-proxy-startup.log"))
crate::commands_account::resolve_aikey_dir()
.join("logs")
.join("aikey-proxy-startup.log")
}

/// Build a `StartOptions` struct from the existing CLI config-loading
Expand Down Expand Up @@ -1375,8 +1371,7 @@ pub fn handle_restart(

/// Returns `~/.aikey/run/proxy.pid`.
fn pid_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let home = dirs::home_dir().ok_or("cannot determine home directory")?;
Ok(home.join(".aikey").join("run").join(PID_FILENAME))
Ok(crate::commands_account::resolve_aikey_dir().join("run").join(PID_FILENAME))
}

fn read_pid() -> Option<u32> {
Expand Down Expand Up @@ -1546,8 +1541,8 @@ pub(crate) fn find_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>>
}

// 3. ~/.aikey/bin/aikey-proxy
if let Some(home) = dirs::home_dir() {
let candidate = home.join(".aikey").join("bin").join(bin_name);
{
let candidate = crate::commands_account::resolve_aikey_dir().join("bin").join(bin_name);
if candidate.exists() {
return Ok(candidate);
}
Expand Down Expand Up @@ -1874,8 +1869,8 @@ fn resolve_config(explicit: Option<&str>) -> Result<PathBuf, Box<dyn std::error:
}

// ~/.aikey/config/aikey-proxy.yaml (system layer — single source of truth)
if let Some(home) = dirs::home_dir() {
let home_cfg = home.join(".aikey").join("config").join(DEFAULT_CONFIG_NAME);
{
let home_cfg = crate::commands_account::resolve_aikey_dir().join("config").join(DEFAULT_CONFIG_NAME);
if home_cfg.exists() {
return Ok(home_cfg);
}
Expand Down
13 changes: 6 additions & 7 deletions src/commands_service/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,18 +371,17 @@ mod trust_local {
/// `~/.aikey/bin/trust-local` (Unix) or `%USERPROFILE%\.aikey\bin\trust-local.exe`
/// (Windows). Mirrors install_service.ps1's `$BinaryPath` resolution.
fn aikey_home_bin_path() -> std::path::PathBuf {
let home = if cfg!(windows) {
std::env::var("USERPROFILE").unwrap_or_default()
} else {
std::env::var("HOME").unwrap_or_default()
};
// resolve_aikey_dir() rather than a local HOME/USERPROFILE read: it is
// the single place that knows about AIKEY_HOME, so a relocated install
// (--install-dir / --sandbox) finds ITS trust-local instead of the
// default install's. The platform split it used to do by hand is the
// same one resolve_user_home() already does behind that helper.
let binary_name = if cfg!(windows) {
"trust-local.exe"
} else {
"trust-local"
};
std::path::PathBuf::from(home)
.join(".aikey")
crate::commands_account::resolve_aikey_dir()
.join("bin")
.join(binary_name)
}
Expand Down
3 changes: 1 addition & 2 deletions src/global_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ pub fn config_path() -> Result<PathBuf, String> {
return Ok(PathBuf::from(path));
}

let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?;
let new_path = home.join(".aikey").join("config").join("config.json");
let new_path = crate::commands_account::resolve_aikey_dir().join("config").join("config.json");

// Auto-migrate from legacy path (dirs::config_dir()/aikey/config.json)
if !new_path.exists() {
Expand Down
3 changes: 2 additions & 1 deletion src/local_server_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,8 @@ fn start_service(edition: Edition) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
let bin_file = bin_name.to_string();

let bin = home.join(".aikey").join("bin").join(&bin_file);
let _ = &home;
let bin = crate::commands_account::resolve_aikey_dir().join("bin").join(&bin_file);

if !bin.exists() {
return Err(format!(
Expand Down
4 changes: 1 addition & 3 deletions src/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,7 @@ struct LogRecord<'a> {
static LOG_FILE: OnceLock<Mutex<std::fs::File>> = OnceLock::new();

fn log_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".aikey")
crate::commands_account::resolve_aikey_dir()
.join("logs")
.join("aikey-cli")
}
Expand Down
3 changes: 1 addition & 2 deletions src/proxy_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ pub struct TransitionEvent<'a> {
/// Resolve the events log path. Returns None if home dir unavailable
/// (best-effort; events are dropped silently in that case).
fn events_path() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let dir = home.join(".aikey").join("logs");
let dir = crate::commands_account::resolve_aikey_dir().join("logs");
let _ = std::fs::create_dir_all(&dir);
Some(dir.join(EVENTS_FILENAME))
}
Expand Down
3 changes: 2 additions & 1 deletion src/proxy_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,8 @@ fn run_dir() -> std::io::Result<PathBuf> {
"cannot determine home directory for ~/.aikey/run",
)
})?;
let dir = home.join(".aikey").join("run");
let _ = &home; // home retained for the error message above
let dir = crate::commands_account::resolve_aikey_dir().join("run");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
Expand Down
6 changes: 4 additions & 2 deletions src/proxy_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ pub fn meta_path() -> std::io::Result<std::path::PathBuf> {
"cannot determine home directory for proxy-meta.json",
)
})?;
Ok(home.join(".aikey").join("run").join(SIDECAR_META_FILENAME))
let _ = &home; // kept: its absence is still the error reported above
Ok(crate::commands_account::resolve_aikey_dir().join("run").join(SIDECAR_META_FILENAME))
}

/// Read + parse the sidecar meta file at the given path.
Expand Down Expand Up @@ -809,7 +810,8 @@ pub fn pidfile_path() -> std::io::Result<std::path::PathBuf> {
"cannot determine home directory for proxy.pid",
)
})?;
Ok(home.join(".aikey").join("run").join("proxy.pid"))
let _ = &home; // kept: its absence is still the error reported above
Ok(crate::commands_account::resolve_aikey_dir().join("run").join("proxy.pid"))
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const KEYRING_USER: &str = "master-password";
// ---------------------------------------------------------------------------

fn aikey_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".aikey"))
Some(crate::commands_account::resolve_aikey_dir())
}

fn session_meta_path() -> Option<PathBuf> {
Expand Down
Loading