Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion src/ucode/custom_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/ucode/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 6 additions & 3 deletions src/ucode/managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_custom_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
20 changes: 20 additions & 0 deletions tests/test_managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Loading