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/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_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