diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index e5e61f33..bac9e2c7 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -75,7 +75,15 @@ def login( def body(state: AppState, json_mode: bool) -> None: profile = state.resolve_profile() - env = environments.active().name + # A bare `assembly login` defaults to production rather than inheriting the + # profile's previously-stored env: signing in again must not silently re-target + # a sandbox the profile happened to be bound to. An explicit --env/--sandbox (or + # AAI_ENV) still selects the environment — and becomes the one this login binds + # the profile to. The browser OAuth + AMS flow reads environments.active(), so + # re-set the process global here, not just the stored/reported name. + env_obj = environments.resolve(state.env, None) + environments.set_active(env_obj) + env = env_obj.name # `api_key is not None` (not truthiness): --api-key "" combined with # --with-api-key must report the conflict, not fall through to stdin. mutually_exclusive( diff --git a/aai_cli/skills/aai-cli/references/account.md b/aai_cli/skills/aai-cli/references/account.md index 84b46d5f..189a2913 100644 --- a/aai_cli/skills/aai-cli/references/account.md +++ b/aai_cli/skills/aai-cli/references/account.md @@ -7,8 +7,11 @@ All commands accept `--json`. ## `assembly login` — authenticate Opens a browser OAuth flow and stores the resulting CLI API key in the OS -keyring, bound to the active `--env`; pass `--api-key` to authenticate -non-interactively (CI). +keyring. Login defaults to **production** — unlike other commands it does not +inherit the profile's previously-stored env, so a bare re-login never silently +re-targets a sandbox; pass `--sandbox`/`--env` (or set `AAI_ENV`) to sign in +elsewhere, and that becomes the env the profile is bound to. Pass `--api-key` +to authenticate non-interactively (CI). Key options: diff --git a/tests/test_login.py b/tests/test_login.py index 4cdf6d84..3eb14b1a 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -234,6 +234,45 @@ def test_login_binds_env_to_profile(monkeypatch): assert config.get_profile_env("default") == "sandbox000" +def test_login_defaults_to_production_ignoring_stored_sandbox_env(monkeypatch): + # A bare `assembly login` must default to production even when the profile was + # previously bound to the sandbox — re-signing-in shouldn't silently re-target it. + config.set_profile_env("default", "sandbox000") + seen = {} + + def fake(*, json_mode=False): + from aai_cli.core import environments + + # The browser OAuth/AMS flow reads environments.active(); capture what it sees. + seen["active"] = environments.active().name + return _login_result() + + monkeypatch.setattr("aai_cli.auth.run_login_flow", fake) + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert seen["active"] == "production" # the flow ran against prod, not the stored sandbox + assert config.get_profile_env("default") == "production" # and re-bound the profile to prod + + +def test_login_explicit_sandbox_still_overrides_production_default(monkeypatch): + # The production default is only the fallback: an explicit --sandbox still wins, and + # the flow runs against (and the profile binds to) the sandbox. + config.set_profile_env("default", "production") + seen = {} + + def fake(*, json_mode=False): + from aai_cli.core import environments + + seen["active"] = environments.active().name + return _login_result() + + monkeypatch.setattr("aai_cli.auth.run_login_flow", fake) + result = runner.invoke(app, ["--sandbox", "login"]) + assert result.exit_code == 0 + assert seen["active"] == "sandbox000" + assert config.get_profile_env("default") == "sandbox000" + + def test_sandbox_flag_is_shortcut_for_env(monkeypatch): monkeypatch.setattr( "aai_cli.auth.run_login_flow", lambda *, json_mode=False: _login_result("sk_x")