From c90e27ca0fd6c577adc306d9db6b576bc4f8b76a Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 20 Aug 2026 10:11:29 -0700 Subject: [PATCH 1/8] feat(cloud): add `reflex cloud whoami` and `reflex cloud token` Inspecting which Reflex Cloud credentials a machine is actually using required reading `hosting_v1.json` by hand. Two commands make it first-class: - `reflex cloud whoami` resolves the token with the control plane and reports the account, org, tier and where the token was loaded from. It never starts a browser login and never prints the token. - `reflex cloud token --print/--set/--clear` reads, replaces or removes the stored token. `--set` validates before saving, so a bad token cannot silently replace a working one. `get_existing_access_token_with_source` exposes the existing config-file over-`REFLEX_ACCESS_TOKEN` precedence so both commands can report which source won, with `get_existing_access_token` delegating to it. Also isolates the hosting config in tests: `test_save_token_to_config` and `test_authenticated_token_found_but_invalid` wrote to and emptied the developer's real token file, logging them out on every test run. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/reflex_cli/utils/hosting.py | 47 ++- .../src/reflex_cli/v2/auth.py | 190 ++++++++++++ .../src/reflex_cli/v2/deployments.py | 9 + tests/units/reflex_cli/conftest.py | 24 ++ tests/units/reflex_cli/utils/test_hosting.py | 37 +++ tests/units/reflex_cli/v2/test_auth.py | 287 ++++++++++++++++++ 6 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py create mode 100644 tests/units/reflex_cli/v2/test_auth.py diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 49ba13f263c..cbc59ca32a5 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -7,6 +7,7 @@ import importlib.metadata import json import logging +import os import platform import re import subprocess @@ -348,16 +349,26 @@ def open(self, url: str, new: int = 0, autoraise: bool = True): webbrowser.BackgroundBrowser = SilentBackgroundBrowser -def get_existing_access_token() -> str: - """Fetch the access token from the existing config if applicable. +class TokenSource(str, Enum): + """Where an access token was loaded from.""" + + CONFIG = "config file" + ENVIRONMENT = "REFLEX_ACCESS_TOKEN environment variable" + OPTION = "--token option" + NONE = "none" + + +def get_existing_access_token_with_source() -> tuple[str, TokenSource]: + """Fetch the access token from the existing config if applicable, and say where it came from. + + The config file takes precedence: ``REFLEX_ACCESS_TOKEN`` is consulted only + when the config file holds no token. Returns: - The access token. - If not found, return empty string for it instead. + The access token and the source it was loaded from. + If not found, return empty string and ``TokenSource.NONE`` instead. """ - import os - logger.debug("Fetching token from existing config...") access_token = "" try: @@ -369,12 +380,26 @@ def get_existing_access_token() -> str: f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) - if not access_token: - access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") - if access_token: - logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + if access_token: + return access_token, TokenSource.CONFIG - return access_token + access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") + if access_token: + logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + return access_token, TokenSource.ENVIRONMENT + + return "", TokenSource.NONE + + +def get_existing_access_token() -> str: + """Fetch the access token from the existing config if applicable. + + Returns: + The access token. + If not found, return empty string for it instead. + + """ + return get_existing_access_token_with_source()[0] def is_reflex_enterprise_installed() -> bool: diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py new file mode 100644 index 00000000000..e14e2d5f0b7 --- /dev/null +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -0,0 +1,190 @@ +"""Authentication inspection commands for the Reflex Cloud CLI.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os + +import click +from reflex_base.utils import log + +from reflex_cli import constants +from reflex_cli.utils import console +from reflex_cli.utils.exceptions import TokenValidationError + +logger = logging.getLogger(__name__) + +# Identity fields copied from the control plane response, in display order. +_IDENTITY_FIELDS = ("email", "user_id", "org_id", "tier", "is_service_account") + + +def token_fingerprint(token: str) -> str: + """Derive a non-reversible identifier for an access token. + + The same token always produces the same fingerprint, so two machines can be + compared without revealing the token itself. + + Args: + token: The access token to fingerprint. + + Returns: + A short sha256-derived fingerprint, or an empty string if there is no token. + + """ + if not token: + return "" + return f"sha256:{hashlib.sha256(token.encode()).hexdigest()[:16]}" + + +_loglevel_option = click.option( + "--loglevel", + type=click.Choice([level.value for level in constants.LogLevel]), + default=constants.LogLevel.INFO.value, + help="The log level to use.", +) + + +@click.command() +@click.option("--token", help="The authentication token.") +@_loglevel_option +@click.option( + "--json/--no-json", + "-j", + "as_json", + is_flag=True, + help="Whether to output the result in json format.", +) +def whoami_command(token: str | None, loglevel: str, as_json: bool): + """Show which account the Reflex Cloud CLI is authenticating as. + + Reports the identity the control plane resolves the access token to, along + with where that token was loaded from. Never starts a browser login and + never prints the token itself. + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + if token: + access_token, source = token, hosting.TokenSource.OPTION + else: + access_token, source = hosting.get_existing_access_token_with_source() + + if not access_token: + logger.error("Not logged in. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + + try: + validated_info = hosting.validate_token(access_token) + except TokenValidationError as err: + logger.error( + f"The access token from the {source.value} was rejected: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + identity = { + field: validated_info[field] + for field in _IDENTITY_FIELDS + if field in validated_info + } + identity["token_source"] = source.value + identity["token_fingerprint"] = token_fingerprint(access_token) + + if as_json: + console.print(json.dumps(identity)) + return + + console.print_table( + [[field, str(value)] for field, value in identity.items()], + headers=["field", "value"], + ) + + +@click.command() +@click.option( + "--print", + "print_token", + is_flag=True, + help="Print the stored access token to stdout.", +) +@click.option( + "--set", + "set_token", + metavar="TOKEN", + help="Validate TOKEN and store it as the access token.", +) +@click.option("--clear", is_flag=True, help="Remove the stored access token.") +@_loglevel_option +def token_command(print_token: bool, set_token: str | None, clear: bool, loglevel: str): + """Inspect or replace the stored Reflex Cloud access token. + + Exactly one of --print, --set or --clear must be given. --print writes the + raw token to stdout so it can be captured, e.g. + `export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`. + + Raises: + UsageError: If the requested operations are not exactly one. + + """ + from reflex_cli.utils import hosting + + console.set_log_level(loglevel) + + requested = [ + name + for name, chosen in ( + ("--print", print_token), + ("--set", set_token), + ("--clear", clear), + ) + if chosen + ] + if len(requested) != 1: + raise click.UsageError( + f"Specify exactly one of --print, --set or --clear (got {', '.join(requested) or 'none'})." + ) + + if print_token: + access_token, source = hosting.get_existing_access_token_with_source() + if not access_token: + logger.error("No access token stored. Run `reflex login` to authenticate.") + raise click.exceptions.Exit(1) + logger.debug(f"Access token loaded from the {source.value}.") + # Bypass the console so the token is never wrapped or styled. + click.echo(access_token) + return + + if set_token: + try: + validated_info = hosting.validate_token(set_token) + except TokenValidationError as err: + logger.error( + f"Token rejected, nothing was saved: {err} " + f"(auth request id: {err.request_id})" + ) + raise click.exceptions.Exit(1) from err + + hosting.save_token_to_config(set_token) + stored, stored_source = hosting.get_existing_access_token_with_source() + if stored != set_token or stored_source is not hosting.TokenSource.CONFIG: + logger.error( + f"Unable to persist the token to {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + + owner = validated_info.get("email") or validated_info.get("user_id") + logger.log( + log.SUCCESS, + f"Saved the access token for {owner} ({token_fingerprint(set_token)}).", + ) + return + + hosting.delete_token_from_config() + logger.log(log.SUCCESS, "Cleared the stored access token.") + if os.environ.get("REFLEX_ACCESS_TOKEN"): + logger.info( + "REFLEX_ACCESS_TOKEN is still set; the CLI will authenticate with it." + ) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py index acc11f15462..08d8ba31106 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/deployments.py @@ -13,6 +13,7 @@ from reflex_cli import constants from reflex_cli.v2.apps import apps_cli +from reflex_cli.v2.auth import token_command, whoami_command from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command from reflex_cli.v2.project import project_cli from reflex_cli.v2.providers import providers_cli @@ -94,6 +95,14 @@ def hosting_cli(ctx: click.Context) -> None: scan_command, name="scan", ) +hosting_cli.add_command( + whoami_command, + name="whoami", +) +hosting_cli.add_command( + token_command, + name="token", +) for name, command in vm_types_regions_cli.commands.items(): # Add the command to the hosting CLI hosting_cli.add_command(command, name=name) diff --git a/tests/units/reflex_cli/conftest.py b/tests/units/reflex_cli/conftest.py index f01f3f74a1f..7affb639330 100644 --- a/tests/units/reflex_cli/conftest.py +++ b/tests/units/reflex_cli/conftest.py @@ -2,6 +2,7 @@ import pytest from pytest_mock import MockFixture +from reflex_cli import constants @pytest.fixture(autouse=True) @@ -12,3 +13,26 @@ def mock_check_version(mocker: MockFixture) -> None: causing `check_version` to emit a warning and exit(1). """ mocker.patch("reflex_cli.v2.deployments.check_version") + + +@pytest.fixture(autouse=True) +def isolate_hosting_config( + monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory +) -> None: + """Point the hosting config at a temporary directory. + + Several code paths under test write or delete the token file for real, so + without this a test run destroys the developer's own `reflex login` state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + tmp_path_factory: The pytest temporary directory factory. + """ + reflex_dir = tmp_path_factory.mktemp("reflex_data") + monkeypatch.setattr(constants.Reflex, "DIR", str(reflex_dir)) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON", reflex_dir / "hosting_v1.json" + ) + monkeypatch.setattr( + constants.Hosting, "HOSTING_JSON_V0", reflex_dir / "hosting_v0.json" + ) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 07783941567..00b66b41c8e 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -14,6 +14,7 @@ ScaleParams, ScaleType, SecurityReviewError, + TokenSource, authenticated_token, create_app, create_deployment, @@ -24,6 +25,7 @@ get_auth_request_id, get_authenticated_client, get_existing_access_token, + get_existing_access_token_with_source, get_gcp_provider_status, get_security_review, get_selected_project, @@ -67,6 +69,41 @@ def test_get_existing_access_token( assert get_existing_access_token() == "" +def test_get_existing_access_token_prefers_the_config_file( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch( + "pathlib.Path.open", mock_open(read_data='{"access_token": "config_token"}') + ) + + assert get_existing_access_token_with_source() == ( + "config_token", + TokenSource.CONFIG, + ) + + +def test_get_existing_access_token_falls_back_to_the_environment( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch("pathlib.Path.open", mock_open(read_data='{"another_key": "value"}')) + + assert get_existing_access_token_with_source() == ( + "env_token", + TokenSource.ENVIRONMENT, + ) + + +def test_get_existing_access_token_with_no_token_anywhere( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + mocker.patch("pathlib.Path.open", side_effect=FileNotFoundError("Test exception")) + + assert get_existing_access_token_with_source() == ("", TokenSource.NONE) + + @pytest.mark.parametrize( "file_exists, config_content", [ diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py new file mode 100644 index 00000000000..fd7cd9be80c --- /dev/null +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -0,0 +1,287 @@ +"""Tests for the `reflex cloud whoami` and `reflex cloud token` commands.""" + +import json +import logging + +import pytest +from click.testing import CliRunner +from pytest_mock import MockFixture +from reflex_base.utils.log import SUCCESS +from reflex_cli.utils import hosting +from reflex_cli.utils.exceptions import TokenAccessDeniedError, TokenValidationError +from reflex_cli.v2.auth import token_fingerprint +from reflex_cli.v2.deployments import hosting_cli +from typer import Typer +from typer.main import get_command + +hosting_cli = ( + get_command(hosting_cli) if isinstance(hosting_cli, Typer) else hosting_cli +) + +runner = CliRunner() + +VALIDATED_INFO = { + "email": "user@example.com", + "user_id": "user-uuid", + "org_id": "org-uuid", + "tier": "Pro", + "is_service_account": False, + "_memo": {}, +} + + +def _messages(caplog: pytest.LogCaptureFixture, level: int) -> list[str]: + """Return the captured log messages emitted at the given level. + + Args: + caplog: The pytest log capture fixture. + level: The numeric log level to filter records by. + + Returns: + The formatted messages of the matching records. + """ + return [r.getMessage() for r in caplog.records if r.levelno == level] + + +def test_token_fingerprint_is_stable_and_hides_the_token(): + token = "super-secret-token" + assert token_fingerprint(token) == token_fingerprint(token) + assert token_fingerprint(token) != token_fingerprint(token + "x") + assert token not in token_fingerprint(token) + assert token_fingerprint("") == "" + + +def test_whoami_reports_identity_and_token_source(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["email"] == "user@example.com" + assert payload["org_id"] == "org-uuid" + assert payload["token_source"] == "config file" + assert payload["token_fingerprint"] == token_fingerprint("valid_token") + # Private control-plane fields stay out of the output. + assert "_memo" not in payload + # The token itself is never printed. + assert "valid_token" not in result.output + + +def test_whoami_table_output_hides_the_token(mocker: MockFixture): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + print_table = mocker.patch("reflex_cli.utils.console.print_table") + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 0 + rows = print_table.call_args.args[0] + assert ["email", "user@example.com"] in rows + assert ["token_source", "config file"] in rows + assert all("valid_token" not in cell for row in rows for cell in row) + + +def test_whoami_prefers_the_token_option(mocker: MockFixture): + from_config = mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source" + ) + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["whoami", "--json", "--token", "cli_token"]) + + assert result.exit_code == 0 + validate.assert_called_once_with("cli_token") + from_config.assert_not_called() + assert json.loads(result.output)["token_source"] == "--token option" + + +def test_whoami_without_a_token_does_not_open_a_browser( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + authenticate = mocker.patch("reflex_cli.utils.hosting.authenticate_on_browser") + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + authenticate.assert_not_called() + assert any( + "Not logged in" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_whoami_surfaces_the_auth_request_id( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("stale_token", hosting.TokenSource.ENVIRONMENT), + ) + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenAccessDeniedError("access denied", request_id="req-123"), + ) + + result = runner.invoke(hosting_cli, ["whoami"]) + + assert result.exit_code == 1 + errors = _messages(caplog, logging.ERROR) + assert any("req-123" in message for message in errors) + assert any("REFLEX_ACCESS_TOKEN" in message for message in errors) + + +def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture): + long_token = "t" * 300 + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=(long_token, hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 0 + # Captured verbatim on one line, so `$(reflex cloud token --print)` is exact. + assert result.output == long_token + "\n" + + +def test_token_print_without_a_token_fails( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) + + result = runner.invoke(hosting_cli, ["token", "--print"]) + + assert result.exit_code == 1 + assert any( + "No access token stored" in message + for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_set_validates_before_saving( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("new_token", hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + save.assert_called_once_with("new_token") + assert any("user@example.com" in message for message in _messages(caplog, SUCCESS)) + + +def test_token_set_keeps_the_old_token_when_the_new_one_is_rejected( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", + side_effect=TokenValidationError("server error", request_id="req-456"), + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--set", "bad_token"]) + + assert result.exit_code == 1 + # A bad --set must not clobber or delete a working token. + save.assert_not_called() + delete.assert_not_called() + assert any("req-456" in message for message in _messages(caplog, logging.ERROR)) + + +def test_token_set_reports_a_failed_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + # save_token_to_config swallows write errors, so the command reads back. + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("new_token", hosting.TokenSource.ENVIRONMENT), + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to persist" in message for message in _messages(caplog, logging.ERROR) + ) + + +def test_token_clear_removes_the_token( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + delete.assert_called_once_with() + assert any("Cleared" in message for message in _messages(caplog, SUCCESS)) + + +def test_token_clear_warns_when_the_env_var_still_applies( + mocker: MockFixture, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 0 + assert any( + "REFLEX_ACCESS_TOKEN is still set" in message + for message in _messages(caplog, logging.INFO) + ) + + +@pytest.mark.parametrize( + "args", + [ + [], + ["--print", "--clear"], + ["--print", "--set", "tok"], + ["--set", "tok", "--clear"], + ], +) +def test_token_requires_exactly_one_operation(args: list[str], mocker: MockFixture): + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + + result = runner.invoke(hosting_cli, ["token", *args]) + + assert result.exit_code == 2 + assert "exactly one of --print, --set or --clear" in result.output + save.assert_not_called() + delete.assert_not_called() From 8b672b6b7ae5fc62d50ccd15b1b0b58afae5b95f Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 20 Aug 2026 14:46:04 -0700 Subject: [PATCH 2/8] fix(cloud): make hosting config writes atomic and verify clears Review found `save_token_to_config` and `delete_token_from_config` open the config with mode "w", which truncates before the write is attempted. A failing write (disk full, I/O error) therefore destroyed the existing token and project, and both helpers swallow the exception, so the caller saw only a warning. `token --set` read back and reported the failure, but only after the damage was done. Both now serialize to a temporary file alongside the target and move it into place, so a failed write leaves the previous credentials untouched and the temporary file is cleaned up. `token --clear` now reads back too: `delete_token_from_config` swallows filesystem errors, so success was previously reported without evidence. `token --print` forces the log level to ERROR while resolving. The shared console writes everything below ERROR to stdout, so `$(reflex cloud token --print --loglevel debug)` captured two Debug lines along with the token. Errors go to stderr, so stdout now carries the token or nothing. The config-file tests were rewritten against the real (now isolated) filesystem instead of asserting mock call counts, which pinned the old non-atomic write sequence, and cover the failed-write paths. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/reflex_cli/utils/hosting.py | 62 ++++++--- .../src/reflex_cli/v2/auth.py | 24 +++- tests/units/reflex_cli/utils/test_hosting.py | 123 +++++++++++++----- tests/units/reflex_cli/v2/test_auth.py | 38 ++++++ 4 files changed, 192 insertions(+), 55 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index cbc59ca32a5..37408b2653c 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -12,6 +12,7 @@ import re import subprocess import sys +import tempfile import time import uuid import webbrowser @@ -491,15 +492,55 @@ def validate_token(token: str) -> dict[str, Any]: raise TokenValidationError("internal errors", request_id=request_id) from ex +def _read_hosting_config() -> dict[str, Any]: + """Read the hosting config file. + + Returns: + The stored config, or an empty dict if it is missing or unreadable. + + """ + try: + with constants.Hosting.HOSTING_JSON.open() as config_file: + return json.load(config_file) + except (OSError, ValueError) as ex: + logger.debug(f"Unable to read {constants.Hosting.HOSTING_JSON} due to: {ex}") + return {} + + +def _write_hosting_config(hosting_config: dict[str, Any]): + """Write the hosting config file atomically. + + The config is written to a temporary file alongside the target and moved + into place, so a failed or interrupted write leaves the previous + credentials intact rather than truncating them. + + Args: + hosting_config: The config to persist. + + """ + target = constants.Hosting.HOSTING_JSON + target.parent.mkdir(parents=True, exist_ok=True) + # Close the handle before replacing: Windows cannot rename an open file. + temp_fd, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.") + temp_path = Path(temp_name) + try: + with os.fdopen(temp_fd, "w") as config_file: + json.dump(hosting_config, config_file) + config_file.flush() + os.fsync(config_file.fileno()) + temp_path.replace(target) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + + def delete_token_from_config(): """Delete the invalid token from the config file if applicable.""" if constants.Hosting.HOSTING_JSON.exists(): try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) + hosting_config = _read_hosting_config() hosting_config.pop("access_token", None) - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: # Best efforts removing invalid token is OK logger.debug( @@ -518,18 +559,9 @@ def save_token_to_config(token: str): """ try: - if not Path(constants.Reflex.DIR).exists(): - Path(constants.Reflex.DIR).mkdir(parents=True, exist_ok=True) - hosting_config: dict[str, str] = {} - if constants.Hosting.HOSTING_JSON.exists(): - try: - with constants.Hosting.HOSTING_JSON.open("r") as config_file: - hosting_config = json.load(config_file) - except (OSError, ValueError): - hosting_config = {} + hosting_config = _read_hosting_config() hosting_config["access_token"] = token - with constants.Hosting.HOSTING_JSON.open("w") as config_file: - json.dump(hosting_config, config_file) + _write_hosting_config(hosting_config) except Exception as ex: logger.warning( f"Unable to save token to {constants.Hosting.HOSTING_JSON} due to: {ex}" diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index e14e2d5f0b7..7959780261f 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -123,11 +123,9 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve Exactly one of --print, --set or --clear must be given. --print writes the raw token to stdout so it can be captured, e.g. - `export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`. - - Raises: - UsageError: If the requested operations are not exactly one. - + `export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`; stdout carries + the token or nothing at all, so use `reflex cloud whoami` to inspect where + the token came from. """ from reflex_cli.utils import hosting @@ -148,11 +146,14 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve ) if print_token: - access_token, source = hosting.get_existing_access_token_with_source() + # The shared console writes everything below ERROR to stdout, which + # would land inside `$(reflex cloud token --print)` alongside the + # token. Errors still go to stderr, so stdout stays exact either way. + console.set_log_level(constants.LogLevel.ERROR) + access_token, _ = hosting.get_existing_access_token_with_source() if not access_token: logger.error("No access token stored. Run `reflex login` to authenticate.") raise click.exceptions.Exit(1) - logger.debug(f"Access token loaded from the {source.value}.") # Bypass the console so the token is never wrapped or styled. click.echo(access_token) return @@ -183,6 +184,15 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve return hosting.delete_token_from_config() + # delete_token_from_config swallows filesystem errors, so confirm the + # token is really gone rather than reporting an unverified success. + _, stored_source = hosting.get_existing_access_token_with_source() + if stored_source is hosting.TokenSource.CONFIG: + logger.error( + f"Unable to remove the access token from {constants.Hosting.HOSTING_JSON}." + ) + raise click.exceptions.Exit(1) + logger.log(log.SUCCESS, "Cleared the stored access token.") if os.environ.get("REFLEX_ACCESS_TOKEN"): logger.info( diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 00b66b41c8e..69f66177279 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -8,6 +8,7 @@ import httpx import pytest from pytest_mock import MockerFixture, MockFixture +from reflex_cli import constants from reflex_cli.utils.exceptions import NotAuthenticatedError, TokenValidationError from reflex_cli.utils.hosting import ( AuthenticatedClient, @@ -105,52 +106,108 @@ def test_get_existing_access_token_with_no_token_anywhere( @pytest.mark.parametrize( - "file_exists, config_content", + "config_content, expected", [ - (True, '{"access_token": "valid_token"}'), - (True, '{"another_key": "value"}'), - (False, ""), + ('{"access_token": "valid_token"}', {}), + ('{"access_token": "valid_token", "project": "p1"}', {"project": "p1"}), + ('{"another_key": "value"}', {"another_key": "value"}), ], ) -def test_delete_token_from_config( +def test_delete_token_from_config(config_content: str, expected: dict): + """Only the token is removed; everything else in the config survives. + + Args: + config_content: The starting contents of the config file. + expected: The config expected to remain afterwards. + """ + constants.Hosting.HOSTING_JSON.write_text(config_content) + + delete_token_from_config() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == expected + + +def test_delete_token_from_config_without_a_config_file(): + """Deleting when no config exists is a no-op rather than an error.""" + assert not constants.Hosting.HOSTING_JSON.exists() + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON.exists() + + +def test_delete_token_from_config_keeps_the_config_when_the_write_fails( mocker: MockerFixture, - file_exists: bool, - config_content: str, ): - mocker.patch("pathlib.Path.exists", return_value=file_exists) - mock_os_remove = mocker.patch("pathlib.Path.unlink") + """A failed delete must leave the existing config readable, not truncated. - mocked_open = mock_open(read_data=config_content) - mocker.patch("pathlib.Path.open", mocked_open) - mock_json_load = mocker.patch( - "json.load", return_value=json.loads(config_content or "{}") - ) - mock_json_dump = mocker.patch("json.dump") + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) delete_token_from_config() - if file_exists: - assert mocked_open.call_count == 2 - mock_json_load.assert_called_once() - mock_json_dump.assert_called_once() - assert "access_token" not in mock_json_dump.call_args.args[0] - mock_os_remove.assert_called_once() - else: - mocked_open.assert_not_called() - mock_os_remove.assert_not_called() + assert constants.Hosting.HOSTING_JSON.read_text() == original + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] -def test_save_token_to_config(mocker: MockFixture): - mocker.patch("pathlib.Path.exists", return_value=False) - mock_makedirs = mocker.patch("pathlib.Path.mkdir") - save_token_to_config("test_token") - mock_makedirs.assert_called_once() +def test_delete_token_from_config_removes_the_legacy_file(): + """The pre-v1 hosting file is removed alongside the token.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + + delete_token_from_config() + + assert not constants.Hosting.HOSTING_JSON_V0.exists() + - mocker.patch("pathlib.Path.exists", return_value=True) - mock_json_dump = mocker.patch("json.dump") - mocker.patch("pathlib.Path.open", mock_open()) +def test_save_token_to_config_creates_the_config(): + """Saving works when neither the directory nor the file exists yet.""" save_token_to_config("test_token") - mock_json_dump.assert_called_once() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "test_token" + } + + +def test_save_token_to_config_preserves_other_keys(): + """Saving a token leaves unrelated config entries untouched.""" + constants.Hosting.HOSTING_JSON.write_text( + '{"access_token": "old_token", "project": "p1"}' + ) + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token", + "project": "p1", + } + + +def test_save_token_to_config_keeps_the_old_token_when_the_write_fails( + mocker: MockerFixture, +): + """A failed write must not truncate the credentials already on disk. + + Args: + mocker: Pytest mocker fixture. + """ + original = '{"access_token": "good_token", "project": "p1"}' + constants.Hosting.HOSTING_JSON.write_text(original) + mocker.patch("json.dump", side_effect=OSError("disk full")) + + save_token_to_config("new_token") + + assert constants.Hosting.HOSTING_JSON.read_text() == original + # The temporary file used for the atomic replace is cleaned up. + assert list(constants.Hosting.HOSTING_JSON.parent.iterdir()) == [ + constants.Hosting.HOSTING_JSON + ] def test_authenticated_token_found_and_valid(mocker: MockFixture): diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index fd7cd9be80c..ae15832dc72 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -161,6 +161,19 @@ def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture): assert result.output == long_token + "\n" +def test_token_print_keeps_stdout_free_of_diagnostics(mocker: MockFixture): + """Debug logging must not land inside `$(reflex cloud token --print)`.""" + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("quiet_token", hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--print", "--loglevel", "debug"]) + + assert result.exit_code == 0 + assert result.output == "quiet_token\n" + + def test_token_print_without_a_token_fails( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): @@ -241,6 +254,10 @@ def test_token_clear_removes_the_token( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("", hosting.TokenSource.NONE), + ) result = runner.invoke(hosting_cli, ["token", "--clear"]) @@ -266,6 +283,27 @@ def test_token_clear_warns_when_the_env_var_still_applies( ) +def test_token_clear_reports_a_failed_removal( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A swallowed delete failure must not be reported as success.""" + # delete_token_from_config swallows filesystem errors, so the token can + # still be in the config file when it returns. + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("still_here", hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to remove" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + @pytest.mark.parametrize( "args", [ From 48f5f7b835a41b513b8d1f4c3b3d5115aa776802 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 20 Aug 2026 14:48:05 -0700 Subject: [PATCH 3/8] docs(cloud): add news fragments for the whoami/token commands Co-Authored-By: Claude Opus 5 (1M context) --- packages/reflex-hosting-cli/news/6918.bugfix.md | 1 + packages/reflex-hosting-cli/news/6918.feature.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 packages/reflex-hosting-cli/news/6918.bugfix.md create mode 100644 packages/reflex-hosting-cli/news/6918.feature.md diff --git a/packages/reflex-hosting-cli/news/6918.bugfix.md b/packages/reflex-hosting-cli/news/6918.bugfix.md new file mode 100644 index 00000000000..2b92f0e3413 --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.bugfix.md @@ -0,0 +1 @@ +The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Both helpers swallow write errors, so this surfaced only as a warning. They now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. This also covers `reflex login` and `reflex logout`, which share these helpers. diff --git a/packages/reflex-hosting-cli/news/6918.feature.md b/packages/reflex-hosting-cli/news/6918.feature.md new file mode 100644 index 00000000000..bc6302fd6fd --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.feature.md @@ -0,0 +1 @@ +Added `reflex cloud whoami` and `reflex cloud token`. `whoami` reports the account, org, tier and token source that the CLI is authenticating as, resolving the token against the control plane without ever starting a browser login and without printing the token — it shows a non-reversible fingerprint instead, so two machines can be compared without anyone sharing a secret. `reflex cloud token` takes exactly one of `--print`, `--set TOKEN` or `--clear`: `--print` writes the raw token to stdout for capture (`export REFLEX_ACCESS_TOKEN=$(reflex cloud token --print)`), `--set` validates the token with the control plane before storing it and leaves the previous one in place if it is rejected, and `--clear` removes the stored token, noting when `REFLEX_ACCESS_TOKEN` remains set and will take over. From c49c07ff7c68aeabbafc165adfe7a62c8605845c Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 20 Aug 2026 15:07:35 -0700 Subject: [PATCH 4/8] fix(cloud): preserve an unreadable hosting config instead of replacing it Follow-up review caught a regression from the atomic-write change: `_read_hosting_config` swallowed read errors and returned `{}`, so `delete_token_from_config` replaced a malformed config with `{}`, destroying the token and project it could not parse. On main the read and the write shared one try block, so a parse failure aborted before the write. Confirmed against a main worktree: main leaves the malformed file untouched, this branch emptied it. `_read_hosting_config` now returns `{}` only for a missing file and propagates read and parse errors. `delete_token_from_config` lets those reach its existing handler, so an unreadable config is left alone. `save_token_to_config` keeps its previous fallback of starting from an empty config, so a corrupt file cannot block re-authenticating. `token --clear` verified removal through a lookup that treats an unreadable config as "no token", so it could report success while the token was still on disk. It now reads the config directly through `stored_access_token`, which distinguishes absent from unparseable, and fails on either kind of unconfirmed removal. The `--print` stdout test mocked the very helper whose debug records contaminate stdout, so it passed with or without the fix. It now writes a real config and exercises the lookup; verified it fails without the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../reflex-hosting-cli/news/6918.bugfix.md | 2 +- .../src/reflex_cli/utils/hosting.py | 40 +++++++++++++++-- .../src/reflex_cli/v2/auth.py | 16 +++++-- tests/units/reflex_cli/utils/test_hosting.py | 43 +++++++++++++++++++ tests/units/reflex_cli/v2/test_auth.py | 42 ++++++++++++------ 5 files changed, 122 insertions(+), 21 deletions(-) diff --git a/packages/reflex-hosting-cli/news/6918.bugfix.md b/packages/reflex-hosting-cli/news/6918.bugfix.md index 2b92f0e3413..8d47f28591a 100644 --- a/packages/reflex-hosting-cli/news/6918.bugfix.md +++ b/packages/reflex-hosting-cli/news/6918.bugfix.md @@ -1 +1 @@ -The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Both helpers swallow write errors, so this surfaced only as a warning. They now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. This also covers `reflex login` and `reflex logout`, which share these helpers. +The hosting config file (`hosting_v1.json`) is now written atomically. `save_token_to_config` and `delete_token_from_config` opened it with mode `"w"`, truncating it before writing, so a failed write — a full disk, an I/O error, an interrupted process — left an empty file and destroyed the stored access token and selected project. Neither helper reports write failures to the caller (`save_token_to_config` logs a warning, `delete_token_from_config` only a debug message), so this was easy to miss. Both now serialize to a temporary file alongside the target and move it into place, leaving the existing credentials untouched when a write fails. A config that exists but cannot be read is no longer treated as empty either, so `delete_token_from_config` leaves a malformed file alone instead of replacing it; `save_token_to_config` still starts fresh from one, so a corrupt config cannot block re-authenticating. This also covers `reflex login` and `reflex logout`, which share these helpers. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 37408b2653c..e29c0a0cc18 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -495,18 +495,42 @@ def validate_token(token: str) -> dict[str, Any]: def _read_hosting_config() -> dict[str, Any]: """Read the hosting config file. + A config that exists but cannot be read is reported rather than treated as + empty, so callers do not overwrite entries they were unable to see. + Returns: - The stored config, or an empty dict if it is missing or unreadable. + The stored config, or an empty dict if the file does not exist. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold valid JSON. """ try: with constants.Hosting.HOSTING_JSON.open() as config_file: return json.load(config_file) - except (OSError, ValueError) as ex: - logger.debug(f"Unable to read {constants.Hosting.HOSTING_JSON} due to: {ex}") + except FileNotFoundError: return {} +def stored_access_token() -> str: + """Read the access token held in the config file. + + Unlike ``get_existing_access_token`` this ignores ``REFLEX_ACCESS_TOKEN`` + and reports read failures, so callers can tell "no token stored" apart from + "cannot tell what is stored". + + Returns: + The stored token, or an empty string if the config holds none. + + Raises: + OSError: If the config exists but cannot be read. + ValueError: If the config exists but does not hold valid JSON. + + """ + return _read_hosting_config().get("access_token", "") + + def _write_hosting_config(hosting_config: dict[str, Any]): """Write the hosting config file atomically. @@ -559,7 +583,15 @@ def save_token_to_config(token: str): """ try: - hosting_config = _read_hosting_config() + try: + hosting_config = _read_hosting_config() + except (OSError, ValueError) as ex: + # An unreadable config must not block re-authenticating; the token + # is what makes the file useful, so start over from an empty one. + logger.debug( + f"Discarding unreadable {constants.Hosting.HOSTING_JSON}: {ex}" + ) + hosting_config = {} hosting_config["access_token"] = token _write_hosting_config(hosting_config) except Exception as ex: diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index 7959780261f..e6fbc30fb6f 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -184,10 +184,18 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve return hosting.delete_token_from_config() - # delete_token_from_config swallows filesystem errors, so confirm the - # token is really gone rather than reporting an unverified success. - _, stored_source = hosting.get_existing_access_token_with_source() - if stored_source is hosting.TokenSource.CONFIG: + # delete_token_from_config swallows filesystem errors, so confirm the token + # is really gone rather than reporting an unverified success. A config that + # cannot be read is not evidence of removal either. + try: + remaining = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was removed from " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if remaining: logger.error( f"Unable to remove the access token from {constants.Hosting.HOSTING_JSON}." ) diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 69f66177279..cbfcefc1f13 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -42,6 +42,7 @@ set_app_full_deploy, set_app_provider, set_instance_bounds, + stored_access_token, submit_security_review, update_deployment_description, validate_token, @@ -156,6 +157,48 @@ def test_delete_token_from_config_keeps_the_config_when_the_write_fails( ] +def test_delete_token_from_config_keeps_an_unreadable_config( + mocker: MockerFixture, +): + """A config that cannot be parsed is left alone rather than replaced. + + Args: + mocker: Pytest mocker fixture. + """ + malformed = '{"access_token": "good_token", "project": "p1"' + constants.Hosting.HOSTING_JSON.write_text(malformed) + + delete_token_from_config() + + assert constants.Hosting.HOSTING_JSON.read_text() == malformed + + +def test_save_token_to_config_recovers_from_an_unreadable_config(): + """Re-authenticating still works when the config is malformed.""" + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "good_token"') + + save_token_to_config("new_token") + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_stored_access_token_distinguishes_absent_from_unreadable(): + """A missing config reads as no token; a malformed one is an error.""" + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"project": "p1"}') + assert stored_access_token() == "" + + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "tok"}') + assert stored_access_token() == "tok" + + constants.Hosting.HOSTING_JSON.write_text("{not json") + with pytest.raises(ValueError): + stored_access_token() + + def test_delete_token_from_config_removes_the_legacy_file(): """The pre-v1 hosting file is removed alongside the token.""" constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index ae15832dc72..0e0a79359c8 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -7,6 +7,7 @@ from click.testing import CliRunner from pytest_mock import MockFixture from reflex_base.utils.log import SUCCESS +from reflex_cli import constants from reflex_cli.utils import hosting from reflex_cli.utils.exceptions import TokenAccessDeniedError, TokenValidationError from reflex_cli.v2.auth import token_fingerprint @@ -161,12 +162,14 @@ def test_token_print_writes_the_raw_token_to_stdout(mocker: MockFixture): assert result.output == long_token + "\n" -def test_token_print_keeps_stdout_free_of_diagnostics(mocker: MockFixture): - """Debug logging must not land inside `$(reflex cloud token --print)`.""" - mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("quiet_token", hosting.TokenSource.CONFIG), - ) +def test_token_print_keeps_stdout_free_of_diagnostics(): + """Debug logging must not land inside `$(reflex cloud token --print)`. + + The real lookup is exercised rather than mocked: the debug records that + would contaminate stdout come from inside the lookup helper, so a mock + would emit nothing and the test would pass even without the fix. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "quiet_token"}') result = runner.invoke(hosting_cli, ["token", "--print", "--loglevel", "debug"]) @@ -254,10 +257,7 @@ def test_token_clear_removes_the_token( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): delete = mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") - mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("", hosting.TokenSource.NONE), - ) + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") result = runner.invoke(hosting_cli, ["token", "--clear"]) @@ -283,6 +283,25 @@ def test_token_clear_warns_when_the_env_var_still_applies( ) +def test_token_clear_reports_an_unreadable_config( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read is not evidence the token was removed.""" + mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + def test_token_clear_reports_a_failed_removal( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): @@ -291,8 +310,7 @@ def test_token_clear_reports_a_failed_removal( # still be in the config file when it returns. mocker.patch("reflex_cli.utils.hosting.delete_token_from_config") mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("still_here", hosting.TokenSource.CONFIG), + "reflex_cli.utils.hosting.stored_access_token", return_value="still_here" ) result = runner.invoke(hosting_cli, ["token", "--clear"]) From 4c3b5957eacece8ad208702cce28ccecdbe51a39 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Thu, 20 Aug 2026 15:21:26 -0700 Subject: [PATCH 5/8] fix(cloud): keep `token --clear` failures reportable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths could escape the command's error handling as tracebacks. `delete_token_from_config` removed the legacy `hosting_v0.json` outside its try block, so an unlink failure propagated out and aborted `--clear` before the readback ran. The cleanup is now best-effort like the rest of the function, and uses `missing_ok=True` to close the exists/unlink race. The legacy file holds no token the CLI reads, so failing to remove it must not fail the removal that already succeeded. `stored_access_token` indexed whatever `json.load` returned, so a config holding valid JSON that is not an object raised `AttributeError` — not one of the types `--clear` catches. `_read_hosting_config` now rejects non-object JSON as a `ValueError`, which every caller already handles, making its dict return type honest. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/reflex_cli/utils/hosting.py | 18 ++++++++++----- tests/units/reflex_cli/utils/test_hosting.py | 22 +++++++++++++++++++ tests/units/reflex_cli/v2/test_auth.py | 17 ++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index e29c0a0cc18..864486b16f4 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -503,14 +503,19 @@ def _read_hosting_config() -> dict[str, Any]: Raises: OSError: If the config exists but cannot be read. - ValueError: If the config exists but does not hold valid JSON. + ValueError: If the config exists but does not hold a JSON object. """ try: with constants.Hosting.HOSTING_JSON.open() as config_file: - return json.load(config_file) + hosting_config = json.load(config_file) except FileNotFoundError: return {} + # Valid JSON is not necessarily the object every caller indexes into. + if not isinstance(hosting_config, dict): + msg = f"{constants.Hosting.HOSTING_JSON} does not hold a JSON object" + raise ValueError(msg) + return hosting_config def stored_access_token() -> str: @@ -570,9 +575,12 @@ def delete_token_from_config(): logger.debug( f"Unable to delete the invalid token from config file, err: {ex}" ) - # Delete the previous hosting service data if present. - if constants.Hosting.HOSTING_JSON_V0.exists(): - constants.Hosting.HOSTING_JSON_V0.unlink() + # Delete the previous hosting service data if present. Best efforts, like + # the rest of this function: the legacy file holds no token the CLI reads. + try: + constants.Hosting.HOSTING_JSON_V0.unlink(missing_ok=True) + except OSError as ex: + logger.debug(f"Unable to remove {constants.Hosting.HOSTING_JSON_V0}: {ex}") def save_token_to_config(token: str): diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index cbfcefc1f13..9f4b597bc53 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -198,6 +198,28 @@ def test_stored_access_token_distinguishes_absent_from_unreadable(): with pytest.raises(ValueError): stored_access_token() + # Valid JSON that is not an object is still unusable, not empty. + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + with pytest.raises(ValueError): + stored_access_token() + + +def test_delete_token_from_config_tolerates_an_unremovable_legacy_file( + mocker: MockerFixture, +): + """The legacy cleanup must not abort the token removal it follows. + + Args: + mocker: Pytest mocker fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "valid_token"}') + constants.Hosting.HOSTING_JSON_V0.write_text("{}") + mocker.patch("pathlib.Path.unlink", side_effect=PermissionError("denied")) + + delete_token_from_config() + + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == {} + def test_delete_token_from_config_removes_the_legacy_file(): """The pre-v1 hosting file is removed alongside the token.""" diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index 0e0a79359c8..ccd2365bf2e 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -302,6 +302,23 @@ def test_token_clear_reports_an_unreadable_config( assert not _messages(caplog, SUCCESS) +def test_token_clear_reports_a_non_object_config(caplog: pytest.LogCaptureFixture): + """Valid JSON that is not an object is reported, not raised as a traceback. + + Args: + caplog: The pytest log capture fixture. + """ + constants.Hosting.HOSTING_JSON.write_text('["not", "an", "object"]') + + result = runner.invoke(hosting_cli, ["token", "--clear"]) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + + def test_token_clear_reports_a_failed_removal( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): From 3db2961ee04f0d2929a1a46cd34177863259e828 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 21 Aug 2026 13:32:15 -0700 Subject: [PATCH 6/8] fix(cloud): keep credentials off the command line and output intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from @masenf, all confirmed against the code: `--set TOKEN` put a live credential in shell history and in the process list. It now accepts `-`, or a bare `--set`, to read the token from stdin, prompting without echo when stdin is a terminal. `--set ""` was reported as "specify exactly one of --print, --set or --clear (got none)", which reads as though --set had not been passed. The operations are now counted with `is None`, and an empty token is rejected on its own terms. `whoami` printed through the shared console, which applies rich markup and wraps to the terminal width. At 40 columns `print_table` truncated `1532f93f-41b6-4a78-893d-a…`, and the identifiers it hands back are the whole point of the command; `--json` was wrapped across lines, which happens to still parse but breaks anything reading a line at a time. Both paths now write directly. `get_existing_access_token_with_source` had its own copy of the config read; it uses `stored_access_token` now. Config reads and writes pin utf-8 rather than inheriting a platform default. Co-Authored-By: Claude Opus 5 (1M context) --- packages/reflex-hosting-cli/news/6918.misc.md | 1 + .../src/reflex_cli/utils/hosting.py | 12 ++- .../src/reflex_cli/v2/auth.py | 63 +++++++++++++--- tests/units/reflex_cli/v2/test_auth.py | 75 +++++++++++++++++-- 4 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 packages/reflex-hosting-cli/news/6918.misc.md diff --git a/packages/reflex-hosting-cli/news/6918.misc.md b/packages/reflex-hosting-cli/news/6918.misc.md new file mode 100644 index 00000000000..484e9ffde4c --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.misc.md @@ -0,0 +1 @@ +`reflex cloud token --set` accepts the token on stdin — pass `-`, or omit the value entirely — so live credentials need not appear in shell history or the process list. When stdin is a terminal it prompts without echoing. `reflex cloud whoami` writes its output directly rather than through the shared console, which applies rich markup and wraps to the terminal width: identifiers now print in full instead of being truncated to fit, and `--json` stays on one line so it can be piped. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index 864486b16f4..c8e309a43cd 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -371,15 +371,13 @@ def get_existing_access_token_with_source() -> tuple[str, TokenSource]: """ logger.debug("Fetching token from existing config...") - access_token = "" try: - with constants.Hosting.HOSTING_JSON.open() as config_file: - hosting_config = json.load(config_file) - access_token = hosting_config.get("access_token", "") - except Exception as ex: + access_token = stored_access_token() + except (OSError, ValueError) as ex: logger.debug( f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) + access_token = "" if access_token: return access_token, TokenSource.CONFIG @@ -507,7 +505,7 @@ def _read_hosting_config() -> dict[str, Any]: """ try: - with constants.Hosting.HOSTING_JSON.open() as config_file: + with constants.Hosting.HOSTING_JSON.open(encoding="utf-8") as config_file: hosting_config = json.load(config_file) except FileNotFoundError: return {} @@ -553,7 +551,7 @@ def _write_hosting_config(hosting_config: dict[str, Any]): temp_fd, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.") temp_path = Path(temp_name) try: - with os.fdopen(temp_fd, "w") as config_file: + with os.fdopen(temp_fd, "w", encoding="utf-8") as config_file: json.dump(hosting_config, config_file) config_file.flush() os.fsync(config_file.fileno()) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index e6fbc30fb6f..9fb7cc7b7b4 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -6,6 +6,7 @@ import json import logging import os +import sys import click from reflex_base.utils import log @@ -38,6 +39,40 @@ def token_fingerprint(token: str) -> str: return f"sha256:{hashlib.sha256(token.encode()).hexdigest()[:16]}" +# Sentinel --set value meaning "read the token from stdin". +_STDIN = "-" + + +def _resolve_set_token(value: str) -> str: + """Resolve the `--set` value, reading from stdin when asked to. + + A token passed on the command line lands in shell history and is readable + from the process list, so `-` (or a bare `--set`) takes it from stdin, or + prompts without echo when stdin is a terminal. + + Args: + value: The raw value given to `--set`. + + Returns: + The token to validate and store. + + Raises: + UsageError: If the resolved token is empty. + + """ + if value == _STDIN: + value = ( + # err=True keeps the prompt off stdout. + click.prompt("Access token", hide_input=True, err=True) + if sys.stdin.isatty() + else sys.stdin.readline() + ) + token = value.strip() + if not token: + raise click.UsageError("--set was given an empty token.") + return token + + _loglevel_option = click.option( "--loglevel", type=click.Choice([level.value for level in constants.LogLevel]), @@ -93,14 +128,16 @@ def whoami_command(token: str | None, loglevel: str, as_json: bool): identity["token_source"] = source.value identity["token_fingerprint"] = token_fingerprint(access_token) + # Both paths bypass the console: it applies rich markup and wraps at the + # terminal width, which corrupts JSON and truncates the identifiers this + # command exists to hand back. if as_json: - console.print(json.dumps(identity)) + click.echo(json.dumps(identity)) return - console.print_table( - [[field, str(value)] for field, value in identity.items()], - headers=["field", "value"], - ) + width = max(map(len, identity)) + for field, value in identity.items(): + click.echo(f"{field:<{width}} {value}") @click.command() @@ -108,13 +145,19 @@ def whoami_command(token: str | None, loglevel: str, as_json: bool): "--print", "print_token", is_flag=True, - help="Print the stored access token to stdout.", + help="Print the active access token to stdout.", ) @click.option( "--set", "set_token", metavar="TOKEN", - help="Validate TOKEN and store it as the access token.", + is_flag=False, + flag_value=_STDIN, + default=None, + help=( + "Validate TOKEN and store it as the access token. Pass `-`, or omit " + "the value, to read the token from stdin instead of the command line." + ), ) @click.option("--clear", is_flag=True, help="Remove the stored access token.") @_loglevel_option @@ -135,7 +178,8 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve name for name, chosen in ( ("--print", print_token), - ("--set", set_token), + # `--set ""` is a malformed --set, not an absent one. + ("--set", set_token is not None), ("--clear", clear), ) if chosen @@ -158,7 +202,8 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve click.echo(access_token) return - if set_token: + if set_token is not None: + set_token = _resolve_set_token(set_token) try: validated_info = hosting.validate_token(set_token) except TokenValidationError as err: diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index ccd2365bf2e..f8cc5648a10 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -75,7 +75,8 @@ def test_whoami_reports_identity_and_token_source(mocker: MockFixture): assert "valid_token" not in result.output -def test_whoami_table_output_hides_the_token(mocker: MockFixture): +def test_whoami_table_output_is_complete_and_hides_the_token(mocker: MockFixture): + """Identifiers print in full: they are the reason to run the command.""" mocker.patch( "reflex_cli.utils.hosting.get_existing_access_token_with_source", return_value=("valid_token", hosting.TokenSource.CONFIG), @@ -83,15 +84,32 @@ def test_whoami_table_output_hides_the_token(mocker: MockFixture): mocker.patch( "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) ) - print_table = mocker.patch("reflex_cli.utils.console.print_table") - result = runner.invoke(hosting_cli, ["whoami"]) + result = runner.invoke(hosting_cli, ["whoami"], terminal_width=40) assert result.exit_code == 0 - rows = print_table.call_args.args[0] - assert ["email", "user@example.com"] in rows - assert ["token_source", "config file"] in rows - assert all("valid_token" not in cell for row in rows for cell in row) + fields = dict(line.split(maxsplit=1) for line in result.output.splitlines()) + # A rich table would have truncated these to fit the 40-column terminal. + assert fields["user_id"] == VALIDATED_INFO["user_id"] + assert fields["email"] == "user@example.com" + assert fields["token_source"] == "config file" + assert "valid_token" not in result.output + + +def test_whoami_json_output_is_exact(mocker: MockFixture): + """`--json` must survive piping: one line, no markup, no wrapping.""" + wide = dict(VALIDATED_INFO, email="a-very-long-address@a-long-example-domain.com") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("valid_token", hosting.TokenSource.CONFIG), + ) + mocker.patch("reflex_cli.utils.hosting.validate_token", return_value=wide) + + result = runner.invoke(hosting_cli, ["whoami", "--json"], terminal_width=40) + + assert result.exit_code == 0 + assert len(result.output.splitlines()) == 1 + assert json.loads(result.output)["email"] == wide["email"] def test_whoami_prefers_the_token_option(mocker: MockFixture): @@ -213,6 +231,49 @@ def test_token_set_validates_before_saving( assert any("user@example.com" in message for message in _messages(caplog, SUCCESS)) +@pytest.mark.parametrize("args", [["--set", "-"], ["--set"]]) +def test_token_set_reads_the_token_from_stdin(args: list[str], mocker: MockFixture): + """A token on the command line leaks into shell history and the process list. + + Args: + args: The invocation form under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.get_existing_access_token_with_source", + return_value=("piped_token", hosting.TokenSource.CONFIG), + ) + + result = runner.invoke(hosting_cli, ["token", *args], input="piped_token\n") + + assert result.exit_code == 0 + validate.assert_called_once_with("piped_token") + save.assert_called_once_with("piped_token") + + +@pytest.mark.parametrize("value", ["", " ", "\n"]) +def test_token_set_rejects_an_empty_token(value: str, mocker: MockFixture): + """An empty --set is a malformed --set, not a missing one. + + Args: + value: The empty value under test. + mocker: Pytest mocker fixture. + """ + validate = mocker.patch("reflex_cli.utils.hosting.validate_token") + + result = runner.invoke(hosting_cli, ["token", "--set", value]) + + assert result.exit_code == 2 + assert "empty token" in result.output + # Not reported as "specify exactly one", which reads as though --set was absent. + assert "exactly one" not in result.output + validate.assert_not_called() + + def test_token_set_keeps_the_old_token_when_the_new_one_is_rejected( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): From 27b71f7b2f78a5f15e129d64b471d7d2367bc62d Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 21 Aug 2026 13:34:32 -0700 Subject: [PATCH 7/8] fix(cloud)!: prefer REFLEX_ACCESS_TOKEN over the stored token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @masenf pointed out the precedence is backwards. Exporting REFLEX_ACCESS_TOKEN to run a script against a specific account did nothing on a machine that had ever run `reflex login`, because the stored token won and the environment variable was consulted only when no token was stored. It failed silently, with no way to tell which credential was in use — the failure mode that motivated this PR. Exporting the variable is an explicit choice scoped to one invocation; the config file is ambient state left behind by an earlier login. This changes behavior only when both are present and differ, and `reflex cloud whoami` now reports which source won. Kept as its own commit so it can be dropped if this should ship separately from the new commands. Co-Authored-By: Claude Opus 5 (1M context) --- .../reflex-hosting-cli/news/6918.breaking.md | 1 + .../src/reflex_cli/utils/hosting.py | 19 ++++---- tests/units/reflex_cli/utils/test_hosting.py | 45 ++++++++++++++----- 3 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 packages/reflex-hosting-cli/news/6918.breaking.md diff --git a/packages/reflex-hosting-cli/news/6918.breaking.md b/packages/reflex-hosting-cli/news/6918.breaking.md new file mode 100644 index 00000000000..e6aa870a4d4 --- /dev/null +++ b/packages/reflex-hosting-cli/news/6918.breaking.md @@ -0,0 +1 @@ +`REFLEX_ACCESS_TOKEN` now takes precedence over the token stored by `reflex login`. Previously the stored token won and the environment variable was consulted only when no token was stored, so exporting it to run a script against a different account had no effect on a machine that had ever logged in — silently, and with no way to tell which credential was in use. Exporting the variable is an explicit choice for that invocation; the config file is ambient state left behind by an earlier login. This changes behavior only when both are present and differ. `reflex cloud whoami` reports which source is in use. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py index c8e309a43cd..5755fbb6a3b 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/utils/hosting.py @@ -360,16 +360,22 @@ class TokenSource(str, Enum): def get_existing_access_token_with_source() -> tuple[str, TokenSource]: - """Fetch the access token from the existing config if applicable, and say where it came from. + """Fetch the access token from the environment or existing config, and say where it came from. - The config file takes precedence: ``REFLEX_ACCESS_TOKEN`` is consulted only - when the config file holds no token. + ``REFLEX_ACCESS_TOKEN`` takes precedence: exporting it is an explicit + choice for this invocation, while the config file is ambient state left + behind by an earlier ``reflex login``. Returns: The access token and the source it was loaded from. If not found, return empty string and ``TokenSource.NONE`` instead. """ + access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") + if access_token: + logger.debug("Using REFLEX_ACCESS_TOKEN from environment") + return access_token, TokenSource.ENVIRONMENT + logger.debug("Fetching token from existing config...") try: access_token = stored_access_token() @@ -377,16 +383,11 @@ def get_existing_access_token_with_source() -> tuple[str, TokenSource]: logger.debug( f"Unable to fetch token from {constants.Hosting.HOSTING_JSON} due to: {ex}" ) - access_token = "" + return "", TokenSource.NONE if access_token: return access_token, TokenSource.CONFIG - access_token = os.environ.get("REFLEX_ACCESS_TOKEN", "") - if access_token: - logger.debug("Using REFLEX_ACCESS_TOKEN from environment") - return access_token, TokenSource.ENVIRONMENT - return "", TokenSource.NONE diff --git a/tests/units/reflex_cli/utils/test_hosting.py b/tests/units/reflex_cli/utils/test_hosting.py index 9f4b597bc53..fc805ba26d3 100644 --- a/tests/units/reflex_cli/utils/test_hosting.py +++ b/tests/units/reflex_cli/utils/test_hosting.py @@ -71,29 +71,54 @@ def test_get_existing_access_token( assert get_existing_access_token() == "" -def test_get_existing_access_token_prefers_the_config_file( - mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +def test_get_existing_access_token_prefers_the_environment( + monkeypatch: pytest.MonkeyPatch, ): + """An exported token is an explicit choice; the config file is ambient state. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") - mocker.patch( - "pathlib.Path.open", mock_open(read_data='{"access_token": "config_token"}') + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + + assert get_existing_access_token_with_source() == ( + "env_token", + TokenSource.ENVIRONMENT, ) + +def test_get_existing_access_token_falls_back_to_the_config_file( + monkeypatch: pytest.MonkeyPatch, +): + """Without the environment variable the stored token is used. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.delenv("REFLEX_ACCESS_TOKEN", raising=False) + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') + assert get_existing_access_token_with_source() == ( "config_token", TokenSource.CONFIG, ) -def test_get_existing_access_token_falls_back_to_the_environment( - mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +def test_get_existing_access_token_ignores_an_empty_environment_variable( + monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") - mocker.patch("pathlib.Path.open", mock_open(read_data='{"another_key": "value"}')) + """An empty export is not a token and must not shadow the config file. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "") + constants.Hosting.HOSTING_JSON.write_text('{"access_token": "config_token"}') assert get_existing_access_token_with_source() == ( - "env_token", - TokenSource.ENVIRONMENT, + "config_token", + TokenSource.CONFIG, ) From a5d3e7a6c1832c4d62ab157f91ca103c5f620649 Mon Sep 17 00:00:00 2001 From: Kastier1 <40179067+Kastier1@users.noreply.github.com.> Date: Fri, 21 Aug 2026 13:50:24 -0700 Subject: [PATCH 8/8] fix(cloud): verify `token --set` against the config, not the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The precedence flip broke `--set` for anyone with REFLEX_ACCESS_TOKEN exported. The write-verification read went through `get_existing_access_token_with_source`, which now returns the environment token first, so the guard saw `TokenSource.ENVIRONMENT` and reported "Unable to persist" and exit 1 — while the token had in fact been written to hosting_v1.json. Verification now uses `stored_access_token`, which reads the config alone and cannot be shadowed by the environment, matching how `--clear` already confirms removal. An unreadable config is reported separately from a token that failed to land. Caught in review by greptile and cubic; reproduced with the environment variable set, where the config held the new token and the command still exited 1. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/reflex_cli/v2/auth.py | 13 +++- tests/units/reflex_cli/v2/test_auth.py | 60 ++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py index 9fb7cc7b7b4..c9a4a7b0f09 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/auth.py @@ -214,8 +214,17 @@ def token_command(print_token: bool, set_token: str | None, clear: bool, logleve raise click.exceptions.Exit(1) from err hosting.save_token_to_config(set_token) - stored, stored_source = hosting.get_existing_access_token_with_source() - if stored != set_token or stored_source is not hosting.TokenSource.CONFIG: + # Verify against the config alone: the resolution order prefers + # REFLEX_ACCESS_TOKEN, which would mask the write we are confirming. + try: + stored = hosting.stored_access_token() + except (OSError, ValueError) as err: + logger.error( + f"Unable to confirm the token was written to " + f"{constants.Hosting.HOSTING_JSON}: {err}" + ) + raise click.exceptions.Exit(1) from err + if stored != set_token: logger.error( f"Unable to persist the token to {constants.Hosting.HOSTING_JSON}." ) diff --git a/tests/units/reflex_cli/v2/test_auth.py b/tests/units/reflex_cli/v2/test_auth.py index f8cc5648a10..7b83854af27 100644 --- a/tests/units/reflex_cli/v2/test_auth.py +++ b/tests/units/reflex_cli/v2/test_auth.py @@ -220,8 +220,7 @@ def test_token_set_validates_before_saving( ) save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("new_token", hosting.TokenSource.CONFIG), + "reflex_cli.utils.hosting.stored_access_token", return_value="new_token" ) result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) @@ -244,8 +243,7 @@ def test_token_set_reads_the_token_from_stdin(args: list[str], mocker: MockFixtu ) save = mocker.patch("reflex_cli.utils.hosting.save_token_to_config") mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("piped_token", hosting.TokenSource.CONFIG), + "reflex_cli.utils.hosting.stored_access_token", return_value="piped_token" ) result = runner.invoke(hosting_cli, ["token", *args], input="piped_token\n") @@ -274,6 +272,55 @@ def test_token_set_rejects_an_empty_token(value: str, mocker: MockFixture): validate.assert_not_called() +def test_token_set_succeeds_while_the_environment_variable_is_set( + mocker: MockFixture, monkeypatch: pytest.MonkeyPatch +): + """The write is confirmed against the config, which the environment shadows. + + Args: + mocker: Pytest mocker fixture. + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_ACCESS_TOKEN", "env_token") + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 0 + assert json.loads(constants.Hosting.HOSTING_JSON.read_text()) == { + "access_token": "new_token" + } + + +def test_token_set_reports_an_unconfirmable_write( + mocker: MockFixture, caplog: pytest.LogCaptureFixture +): + """A config that cannot be read back is not evidence the token was saved. + + Args: + mocker: Pytest mocker fixture. + caplog: The pytest log capture fixture. + """ + mocker.patch( + "reflex_cli.utils.hosting.validate_token", return_value=dict(VALIDATED_INFO) + ) + mocker.patch("reflex_cli.utils.hosting.save_token_to_config") + mocker.patch( + "reflex_cli.utils.hosting.stored_access_token", + side_effect=OSError("permission denied"), + ) + + result = runner.invoke(hosting_cli, ["token", "--set", "new_token"]) + + assert result.exit_code == 1 + assert any( + "Unable to confirm" in message for message in _messages(caplog, logging.ERROR) + ) + assert not _messages(caplog, SUCCESS) + + def test_token_set_keeps_the_old_token_when_the_new_one_is_rejected( mocker: MockFixture, caplog: pytest.LogCaptureFixture ): @@ -301,10 +348,7 @@ def test_token_set_reports_a_failed_write( ) # save_token_to_config swallows write errors, so the command reads back. mocker.patch("reflex_cli.utils.hosting.save_token_to_config") - mocker.patch( - "reflex_cli.utils.hosting.get_existing_access_token_with_source", - return_value=("new_token", hosting.TokenSource.ENVIRONMENT), - ) + mocker.patch("reflex_cli.utils.hosting.stored_access_token", return_value="") result = runner.invoke(hosting_cli, ["token", "--set", "new_token"])