Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/aas-cli/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions crates/aas-core/src/share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading