diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cd4c8..ae14a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable user-facing changes are recorded here. The format follows ## [Unreleased] +### Fixed + +- A shim's re-entry guard no longer reaches the agent it launched. The shim exports `AAS_SHIM` so + `aas exec` cannot resolve its way back into the shim through PATH, but the variable then stayed + set for the agent and everything the agent started — a shell, or a tmux server that holds it for + every shell it will ever spawn afterwards. Each of those took the shim's guard branch on the + first line and ran the bare CLI unrouted, so `claude` and `codex` quietly stopped following + `aas switch` for the rest of that server's life. `aas exec` now drops the guard alongside the + binary hand-off it already dropped, and the shim unsets it before handing over to the real CLI — + the two paths by which the agent can be reached. + ## [0.1.13] - 2026-09-22 ### Fixed diff --git a/crates/aas-cli/src/exec.rs b/crates/aas-cli/src/exec.rs index 63f80d4..8d16fde 100644 --- a/crates/aas-cli/src/exec.rs +++ b/crates/aas-cli/src/exec.rs @@ -73,6 +73,9 @@ pub(crate) fn agent_bin(provider: &str) -> Option<&'static str> { /// Set by a shim to the absolute CLI it resolved at install time. pub(crate) const SHIM_BIN_ENV: &str = "AAS_SHIM_BIN"; +/// Set by a shim to stop `aas exec` re-entering it through PATH. +pub(crate) const SHIM_GUARD_ENV: &str = "AAS_SHIM"; + /// The recorded path, when it can still stand in for `bin`. /// /// The variable describes one launch, so a nested `aas exec` that targets another agent must not @@ -312,6 +315,11 @@ pub async fn cmd_exec(store: &AccountStore, name: &str, rest: &[String]) -> anyh scrub_inherited_credentials(&mut env); // The shim's hand-off covers this launch only; a nested run resolves its own agent. env.remove(SHIM_BIN_ENV); + // The re-entry guard belongs to the hop from the shim to here, not to the agent. Left in + // place it reaches everything the agent starts — a shell, a tmux server that then holds it + // for every shell it will ever spawn — and each of those takes the shim's guard branch and + // runs the bare CLI unrouted, silently ignoring the active account. + env.remove(SHIM_GUARD_ENV); let secret = secure_store::get_secret(&profile_provider, &account_name); // Claude long-lived token → env auth (same-provider claude only). @@ -559,6 +567,24 @@ mod tests { ); } + #[test] + fn the_shims_re_entry_guard_does_not_reach_the_agent() { + let mut env = HashMap::from([ + ("PATH".to_string(), "/usr/bin".to_string()), + (SHIM_GUARD_ENV.to_string(), "1".to_string()), + (SHIM_BIN_ENV.to_string(), "/opt/bin/codex".to_string()), + ]); + scrub_inherited_credentials(&mut env); + env.remove(SHIM_BIN_ENV); + env.remove(SHIM_GUARD_ENV); + + // Both describe the hop from the shim into this process, not the launch itself. A tmux + // server started by the agent would otherwise hold the guard for every later shell. + assert!(!env.contains_key(SHIM_GUARD_ENV)); + assert!(!env.contains_key(SHIM_BIN_ENV)); + assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string())); + } + #[test] fn inherited_credentials_are_removed_before_selected_profile_injection() { let mut env = HashMap::from([ diff --git a/crates/aas-cli/src/shim.rs b/crates/aas-cli/src/shim.rs index 5fdd978..93eb26a 100644 --- a/crates/aas-cli/src/shim.rs +++ b/crates/aas-cli/src/shim.rs @@ -118,8 +118,10 @@ fn shim_body(provider: &str, real: &Path, aas: &Path) -> String { s.push_str( "# Re-entry guard: `aas exec` resolves the agent through PATH and would otherwise\n", ); - s.push_str("# find this shim again, recursing forever.\n"); + s.push_str("# find this shim again, recursing forever. The guard is dropped before the real\n"); + s.push_str("# CLI starts so nothing it spawns inherits it and later bypasses routing.\n"); s.push_str("if [ -n \"${AAS_SHIM:-}\" ]; then\n"); + s.push_str(" unset AAS_SHIM\n"); s.push_str(&format!(" exec {real} \"$@\"\n")); s.push_str("fi\n\n"); s.push_str( @@ -319,6 +321,9 @@ mod tests { Path::new("/usr/local/bin/aas"), ); assert!(body.contains("AAS_SHIM_BIN='/opt/bin/codex'\nexport AAS_SHIM_BIN\n")); + // The guard is dropped on the way to the real CLI: exported into it, every shell and + // tmux server it starts would inherit it and skip routing from then on. + assert!(body.contains("if [ -n \"${AAS_SHIM:-}\" ]; then\n unset AAS_SHIM\n exec ")); // Set after every fall-through exec, so only the `aas exec` hand-off sees it. let handoff = body.find("AAS_SHIM_BIN=").unwrap(); assert!(handoff > body.rfind("exec '/opt/bin/codex' \"$@\"").unwrap());