From 8460feec5c63f7202c7f76c372740a9ef6643243 Mon Sep 17 00:00:00 2001 From: Harry Yao Date: Thu, 22 Oct 2026 21:28:26 +0000 Subject: [PATCH 1/2] 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/2] 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