From 8460feec5c63f7202c7f76c372740a9ef6643243 Mon Sep 17 00:00:00 2001 From: Harry Yao Date: Thu, 22 Oct 2026 21:28:26 +0000 Subject: [PATCH 1/3] Never prompt for sudo when UG_NONINTERACTIVE is set A wrapper can start ug in a terminal nobody watches, e.g. a coding-agent host relaunching `ug claude` in tmux after a restart. stdin is a TTY there, so ug decided sudo may prompt and printed "Enter password to configure settings for Claude Code." followed by sudo's Password: prompt. The launch then blocked until the wrapper gave up. UG_NONINTERACTIVE=1 now makes _sudo_may_prompt() false, so the run takes the existing no-terminal path: sudo -n repairs, a compatible launch continues with local settings, and a conflicting stale managed file still fails with the usual message. Signed-off-by: Harry Yao Co-authored-by: Isaac --- README.md | 4 ++++ src/ucode/managed_files.py | 12 ++++++++++-- tests/test_managed_files.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index db55532e5..81ea34be4 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ ug configure --user precedence; later launches use whatever files are present. The choice is saved for launches after a successful configure; `ug configure` without `--user` and `ug revert` return to `--global`. +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. Available coding agents are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`. `cursor` can be included in `--agents` for MCP-only setup; diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index d7f135c40..90d9428dd 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -36,6 +36,9 @@ _SUDO = "/usr/bin/sudo" # Opt out of all OS-managed settings work (read, conflict check, and the privileged sudo write). SKIP_OS_SETTINGS_ENV = "UG_SKIP_OS_SETTINGS" +# Set by a wrapper that starts ug in a terminal nobody watches (e.g. an unattended relaunch): sudo +# then runs without prompting, exactly as when no terminal is attached. +NONINTERACTIVE_ENV = "UG_NONINTERACTIVE" CONFIG_SCOPES = ("global", "user") DEFAULT_CONFIG_SCOPE = "global" _config_scope = DEFAULT_CONFIG_SCOPE @@ -222,8 +225,13 @@ 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 env_bool(NONINTERACTIVE_ENV) + ) def managed_writes_allowed(*, repair_existing: bool = False) -> bool: diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 51292ae6a..6a4982335 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -1645,3 +1645,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(managed_files.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(managed_files.NONINTERACTIVE_ENV, raising=False) + + assert managed_files._sudo_may_prompt() is True From e89279d2fdcbeaf55922b7ab8aff71fea788750f Mon Sep 17 00:00:00 2001 From: Harry Yao Date: Thu, 22 Oct 2026 21:30:10 +0000 Subject: [PATCH 2/3] Share the UG_NONINTERACTIVE check through ucode.env Move the flag into ucode.env as unattended_terminal() so other prompts (the custom OAuth browser sign-in) can honor the same switch. Signed-off-by: Harry Yao Co-authored-by: Isaac --- src/ucode/env.py | 12 ++++++++++++ src/ucode/managed_files.py | 9 ++------- tests/test_managed_files.py | 5 +++-- 3 files changed, 17 insertions(+), 9 deletions(-) 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 90d9428dd..4a281d309 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 @@ -36,9 +36,6 @@ _SUDO = "/usr/bin/sudo" # Opt out of all OS-managed settings work (read, conflict check, and the privileged sudo write). SKIP_OS_SETTINGS_ENV = "UG_SKIP_OS_SETTINGS" -# Set by a wrapper that starts ug in a terminal nobody watches (e.g. an unattended relaunch): sudo -# then runs without prompting, exactly as when no terminal is attached. -NONINTERACTIVE_ENV = "UG_NONINTERACTIVE" CONFIG_SCOPES = ("global", "user") DEFAULT_CONFIG_SCOPE = "global" _config_scope = DEFAULT_CONFIG_SCOPE @@ -229,9 +226,7 @@ def _sudo_may_prompt() -> bool: ``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 env_bool(NONINTERACTIVE_ENV) - ) + 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_managed_files.py b/tests/test_managed_files.py index 6a4982335..4c8352e69 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. @@ -1651,7 +1652,7 @@ def test_sudo_command_derives_prompt_policy(monkeypatch, interactive): 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(managed_files.NONINTERACTIVE_ENV, value) + monkeypatch.setenv(NONINTERACTIVE_ENV, value) assert managed_files._sudo_may_prompt() is False assert managed_files.managed_writes_allowed() is False @@ -1661,6 +1662,6 @@ def test_noninteractive_env_never_lets_sudo_prompt(monkeypatch, value): def test_noninteractive_env_unset_keeps_the_interactive_prompt(monkeypatch): monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: True) - monkeypatch.delenv(managed_files.NONINTERACTIVE_ENV, raising=False) + monkeypatch.delenv(NONINTERACTIVE_ENV, raising=False) assert managed_files._sudo_may_prompt() is True From 3cd0e1af45aebb2b723de6131dedb15524fba618 Mon Sep 17 00:00:00 2001 From: Harry Yao Date: Thu, 22 Oct 2026 21:32:41 +0000 Subject: [PATCH 3/3] Keep a custom OAuth login open and print its link when unattended ensure_custom_oauth_cli_token runs `databricks auth login` for the custom-client profile with a 180 s budget. In a terminal nobody watches (a wrapper relaunching `ug claude` after a restart), the CLI opens a browser the person can't see, nobody finishes consent, and the launch dies with TimeoutExpired. The agent then exits, and the wrapper only sees a dead terminal. With UG_NONINTERACTIVE set, run the login with BROWSER=none so the CLI prints the sign-in link where the wrapper can surface it, and keep the login open for 30 minutes so the person can finish it when they return. Attended runs are unchanged. Signed-off-by: Harry Yao Co-authored-by: Isaac --- src/ucode/custom_oauth.py | 13 +++++++++++- tests/test_custom_oauth.py | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) 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/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"):