diff --git a/README.md b/README.md index e6ae86135..c16d55760 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ and `ug revert` return to `--global`. `--user` also fails for a workspace that publishes a managed config, which must be installed machine-wide; use `--global` for it. +A wrapper that starts `ug` in a terminal nobody watches (an unattended relaunch) can set +`UG_NONINTERACTIVE=1`: sudo then never prompts for a password, as when no terminal is attached, and +a launch whose managed file can't be updated continues with the per-user settings when they are +compatible. + ### Extra headers for one session ```bash diff --git a/src/ucode/custom_oauth.py b/src/ucode/custom_oauth.py index 8ba64667e..e347d2c73 100644 --- a/src/ucode/custom_oauth.py +++ b/src/ucode/custom_oauth.py @@ -21,6 +21,7 @@ run, save_databricks_cli_oauth_profile, ) +from ucode.env import unattended_terminal from ucode.os_compatibility.file_lock_cross_os import ( acquire_exclusive_file_lock, release_file_lock, @@ -32,6 +33,9 @@ DEFAULT_CLI_SCOPES = ("offline_access", "all-apis") # Custom OAuth may need a human to finish browser consent, not just a token fetch. CUSTOM_OAUTH_TIMEOUT_MS = 180_000 +# An unattended terminal (UG_NONINTERACTIVE) has nobody to finish consent within that budget. The +# login stays open this long so the person can sign in from the printed link once they return. +UNATTENDED_CUSTOM_OAUTH_LOGIN_TIMEOUT_S = 30 * 60 CUSTOM_OAUTH_CLI_MIN_VERSION = (1, 17, 0) CUSTOM_OAUTH_CLI_ENV_VAR = "ENABLE_CUSTOM_OAUTH_FROM_CLI" @@ -162,8 +166,15 @@ def ensure_custom_oauth_cli_token( "--scopes", ",".join(scope for scope in config["scopes"] if scope != "offline_access"), ] + timeout = CUSTOM_OAUTH_TIMEOUT_MS // 1000 + env = None + if unattended_terminal(): + # BROWSER=none makes the CLI print the sign-in link instead of opening a browser on a + # screen nobody watches, so the wrapper can offer the link to the person. + timeout = UNATTENDED_CUSTOM_OAUTH_LOGIN_TIMEOUT_S + env = {**os.environ, "BROWSER": "none"} try: - run(login_args, timeout=CUSTOM_OAUTH_TIMEOUT_MS // 1000) + run(login_args, env=env, timeout=timeout) finally: clear_databricks_token_cache() return get_databricks_token(workspace, profile) diff --git a/src/ucode/env.py b/src/ucode/env.py index 1a82c96f1..4a2c24492 100644 --- a/src/ucode/env.py +++ b/src/ucode/env.py @@ -7,3 +7,15 @@ def env_bool(name: str) -> bool: return os.environ.get(name, "").strip().lower() in TRUE_ENV_VALUES + + +# Set by a wrapper that starts ug in a terminal nobody watches (e.g. an unattended relaunch). +NONINTERACTIVE_ENV = "UG_NONINTERACTIVE" + + +def unattended_terminal() -> bool: + """Whether a wrapper marked this run's terminal as one nobody is watching. + + Prompts there (a sudo password, a browser sign-in) would block until the wrapper gives up. + """ + return env_bool(NONINTERACTIVE_ENV) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 6e6d780e2..aa22cacae 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -28,7 +28,7 @@ from typing import Any, cast from ucode.config_io import APP_DIR, is_dry_run -from ucode.env import env_bool +from ucode.env import env_bool, unattended_terminal from ucode.os_compatibility import subprocess_cross_os, system_cross_os from ucode.ui import console, print_note, print_success, print_warning @@ -222,8 +222,11 @@ def managed_file_scope(state: dict, tool: str) -> str: def _sudo_may_prompt() -> bool: - """A terminal is attached, so sudo can prompt and the persistent worker is usable.""" - return current_os() is not OS.WINDOWS and sys.stdin.isatty() + """A person is at an attached terminal, so sudo can prompt and the persistent worker is usable. + + ``UG_NONINTERACTIVE`` marks a terminal nobody watches: a password prompt there would block the + launch until its caller gives up.""" + return current_os() is not OS.WINDOWS and sys.stdin.isatty() and not unattended_terminal() def managed_writes_allowed(*, repair_existing: bool = False) -> bool: diff --git a/tests/test_custom_oauth.py b/tests/test_custom_oauth.py index 8fc832b05..632d651ed 100644 --- a/tests/test_custom_oauth.py +++ b/tests/test_custom_oauth.py @@ -13,19 +13,61 @@ from typer.testing import CliRunner import ucode.cli as cli_mod +import ucode.custom_oauth as custom_oauth_mod import ucode.databricks as db_mod from ucode.cli import app from ucode.custom_oauth import ( + CUSTOM_OAUTH_TIMEOUT_MS, + UNATTENDED_CUSTOM_OAUTH_LOGIN_TIMEOUT_S, _custom_oauth_lock, build_custom_auth_token_argv, + create_custom_oauth_config, + ensure_custom_oauth_cli_token, get_custom_client_token, ) +from ucode.env import NONINTERACTIVE_ENV WS = "https://example.databricks.com" TEST_SCOPES = ("offline_access", "catalog.catalogs:read") runner = CliRunner() +class TestCustomOAuthCliLoginBudget: + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch): + monkeypatch.setattr(custom_oauth_mod, "ensure_databricks_cli_version", lambda _v: None) + monkeypatch.setattr(custom_oauth_mod, "databricks_cli_path", lambda: "/tools/databricks") + monkeypatch.setattr(custom_oauth_mod, "external_bearer_configured", lambda: False) + monkeypatch.setattr(custom_oauth_mod, "has_valid_databricks_auth", lambda *_a: False) + monkeypatch.setattr(custom_oauth_mod, "get_databricks_token", lambda *_a: "fresh-token") + monkeypatch.setattr(custom_oauth_mod, "clear_databricks_token_cache", lambda: None) + self.login = Mock() + monkeypatch.setattr(custom_oauth_mod, "run", self.login) + self.config = create_custom_oauth_config("client-1", TEST_SCOPES) + + def test_attended_login_keeps_the_interactive_budget_and_browser(self, monkeypatch): + monkeypatch.delenv(NONINTERACTIVE_ENV, raising=False) + + assert ensure_custom_oauth_cli_token(WS, self.config) == "fresh-token" + + kwargs = self.login.call_args.kwargs + assert kwargs == {"env": None, "timeout": CUSTOM_OAUTH_TIMEOUT_MS // 1000} + + def test_unattended_login_prints_the_link_and_waits_for_the_person(self, monkeypatch): + """Nobody can finish consent within 180 s, so the link is printed and the login kept open.""" + monkeypatch.setenv(NONINTERACTIVE_ENV, "1") + monkeypatch.setenv("BROWSER", "firefox") + + assert ensure_custom_oauth_cli_token(WS, self.config) == "fresh-token" + + args = self.login.call_args.args[0] + kwargs = self.login.call_args.kwargs + assert args[1:3] == ["auth", "login"] + assert kwargs["timeout"] == UNATTENDED_CUSTOM_OAUTH_LOGIN_TIMEOUT_S + assert kwargs["env"]["BROWSER"] == "none" + assert kwargs["env"][NONINTERACTIVE_ENV] == "1" + + class TestCustomOAuthLock: def test_releases_lock_when_login_fails(self, tmp_path): with pytest.raises(ValueError, match="login failed"): diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 8c36c53e4..5825193b9 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -18,6 +18,7 @@ import ucode.codex_config as codex_config import ucode.config_io as config_io from ucode import managed_files +from ucode.env import NONINTERACTIVE_ENV _REAL_SUDO_REPLACE = managed_files._sudo_replace # Captured before the autouse ``_supported`` fixture pins the module attribute to True. @@ -1686,3 +1687,22 @@ def test_sudo_command_derives_prompt_policy(monkeypatch, interactive): expected = ["/usr/bin/sudo", *([] if interactive else ["-n"]), "cp", "a", "b"] assert managed_files._sudo_command("cp", "a", "b") == expected + + +@pytest.mark.parametrize("value", ["1", "true"]) +def test_noninteractive_env_never_lets_sudo_prompt(monkeypatch, value): + """A wrapper's unattended terminal gets sudo's non-prompting mode, as if no terminal were attached.""" + monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True) + monkeypatch.setenv(NONINTERACTIVE_ENV, value) + + assert managed_files._sudo_may_prompt() is False + assert managed_files.managed_writes_allowed() is False + assert managed_files.managed_writes_allowed(repair_existing=True) is True + assert managed_files._sudo_command("cp", "a", "b") == ["/usr/bin/sudo", "-n", "cp", "a", "b"] + + +def test_noninteractive_env_unset_keeps_the_interactive_prompt(monkeypatch): + monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True) + monkeypatch.delenv(NONINTERACTIVE_ENV, raising=False) + + assert managed_files._sudo_may_prompt() is True