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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions crates/aas-cli/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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([
Expand Down
7 changes: 6 additions & 1 deletion crates/aas-cli/src/shim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand Down
Loading