From 7dd6b0223d14f07b1a27e16257f7a0068cffe5ed Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Tue, 22 Sep 2026 19:03:49 +0900 Subject: [PATCH] fix(exec): keep one Codex install behind every profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex derives where its standalone package lives from `CODEX_HOME` CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" but writes the launcher to `$HOME/.local/bin/codex` regardless, pointing it at `$STANDALONE_ROOT/current/bin/codex`. `aas exec` sets `CODEX_HOME` to the profile home, so `codex update` accepted from the update notice inside a session unpacked the release into that one account's profile and repointed the launcher every other account shares into it. Profiles then drifted to whichever version each had last updated itself to, and the shared launcher dangled the moment that account was renamed or removed. Link the profile's package root to the native install's, so one install sits behind every profile and an in-session update moves them together. The link is deliberately not a shared-state category. Sharing describes what a profile chooses to have in common with the system install — sessions, skills, settings — and an account that opts out of all of it still runs the same binary as everyone else. Making the runtime's own package root opt-in would just restore the bug for isolated profiles. Only ever mirrors a package root that exists, never invents one, and a real directory already in a profile (an update that landed there before this fix) is left alone rather than replaced. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 ++++++ crates/aas-cli/src/exec.rs | 3 ++ crates/aas-core/src/share.rs | 83 ++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c868f..437d7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable user-facing changes are recorded here. The format follows ## [Unreleased] +### Fixed + +- `codex update` run from inside `aas exec` no longer installs into the one account's profile. + Codex derives its package root from `CODEX_HOME` (`$CODEX_HOME/packages/standalone`) but always + writes the launcher to `~/.local/bin/codex`, so an in-session update unpacked the release into + that profile and repointed the launcher every other account shares into it — each profile + drifting to whichever version it last happened to update itself to, and the shared launcher + dangling if that account was ever renamed or removed. A profile home is now linked to the + package root of the native install, so one install sits behind every profile and an in-session + update moves them all together. Unlike the shared-state categories the link is not opt-in: a + profile that shares nothing still has to update the runtime it is running, not a private copy. + ## [0.1.12] - 2026-09-22 ### Added diff --git a/crates/aas-cli/src/exec.rs b/crates/aas-cli/src/exec.rs index bad2e97..63f80d4 100644 --- a/crates/aas-cli/src/exec.rs +++ b/crates/aas-cli/src/exec.rs @@ -349,6 +349,9 @@ pub async fn cmd_exec(store: &AccountStore, name: &str, rest: &[String]) -> anyh // After the symlinks exist: a shared Codex config.toml is trusted per config *path*, // so a fresh (or renamed) profile would re-prompt "Hooks need review" without this. seed_codex_hook_trust(&normalize_provider_key(&agent_provider), &home); + // Codex resolves its package root from CODEX_HOME, so `codex update` run inside a + // session would install into this profile alone and repoint the shared launcher at it. + share::link_codex_package_root(&normalize_provider_key(&agent_provider), &home); } } else { let home = cross_session_home(&agent_provider, &account_name); diff --git a/crates/aas-core/src/share.rs b/crates/aas-core/src/share.rs index e69c80f..28bd743 100644 --- a/crates/aas-core/src/share.rs +++ b/crates/aas-core/src/share.rs @@ -314,10 +314,93 @@ pub fn link_shared_state( ) { } +/// Point a Codex profile home at the package root the native install actually lives in. +/// +/// Codex's installer derives where the standalone package goes from `CODEX_HOME` +/// (`$CODEX_HOME/packages/standalone`) while always writing the launcher to `~/.local/bin`. Run +/// `codex update` from inside `aas exec` and the update therefore lands in that one account's +/// profile, and the launcher every other account shares is repointed into it — each profile +/// drifting to whichever version it last updated itself to. +/// +/// Linking the directory keeps one install behind every profile, so an in-session update moves +/// them all together. Unlike the shared-state categories this is not opt-in: a profile that opts +/// out of sharing still has to update the runtime it is running, not a private copy of it. +#[cfg(unix)] +pub fn link_codex_package_root(provider: &str, home: &std::path::Path) { + use std::os::unix::fs::symlink; + + if crate::naming::normalize_provider_key(provider) != "codex" { + return; + } + let Some(base) = crate::platform::system_home_for("codex") else { + return; + }; + let target = base.join("packages"); + let link = home.join("packages"); + // A system profile runs out of the native home already. + let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + if canon(&base) == canon(home) { + return; + } + // Only ever mirror an install that exists; never invent a package root. + if !target.is_dir() { + return; + } + match std::fs::symlink_metadata(&link) { + Ok(meta) if meta.file_type().is_symlink() => { + let _ = std::fs::remove_file(&link); // replace a stale link + } + Ok(_) => return, // a real directory is an update that already landed here — leave it + Err(_) => {} + } + let _ = symlink(&target, &link); +} + +#[cfg(not(unix))] +pub fn link_codex_package_root(_provider: &str, _home: &std::path::Path) {} + #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + #[test] + fn codex_package_root_is_linked_regardless_of_sharing() { + let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let root = std::env::temp_dir().join(format!("aas-pkgroot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let codex_home = root.join("codex"); + let home = root.join("profile"); + std::fs::create_dir_all(codex_home.join("packages/standalone")).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + std::env::set_var("CODEX_HOME", &codex_home); + + // `Some(&[])` shares nothing; the package root is still linked. + link_shared_state("codex", &home, false, Some(&[])); + link_codex_package_root("codex", &home); + assert_eq!( + std::fs::read_link(home.join("packages")).unwrap(), + codex_home.join("packages"), + "an isolated profile still has to update the runtime it runs" + ); + + // A real directory is an update that already landed here — never clobbered. + let other = root.join("profile2"); + std::fs::create_dir_all(other.join("packages/standalone")).unwrap(); + link_codex_package_root("codex", &other); + assert!(other.join("packages").is_dir()); + assert!(std::fs::read_link(other.join("packages")).is_err()); + + // Another provider's home is left alone. + let claude = root.join("claude-profile"); + std::fs::create_dir_all(&claude).unwrap(); + link_codex_package_root("claude", &claude); + assert!(!claude.join("packages").exists()); + + std::env::remove_var("CODEX_HOME"); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn supported_categories() { assert_eq!(