From 836d0883893016ffb9e3c2eb17e86b62c67b497f Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:00:46 +0000 Subject: [PATCH 1/8] Install the workspace policy machine-wide at ug configure Explicit ug configure now installs the workspace policy at /etc/unitygateway/managed-settings.json on Linux and macOS, using a non-interactive-sudo-capable writer. Unchanged policy writes nothing, and no-config or feature-disabled removes the installed file. A failed install raises ManagedPolicyInstallDenied with an actionable message. Launches keep reading the per-user cache and never touch the machine file. Co-authored-by: Isaac --- docs/managed-workspace-policy.md | 51 +++ scripts/run_integration.py | 2 + src/ucode/managed_config.py | 85 ++++- src/ucode/managed_files.py | 87 ++++- src/ucode/managed_replace.sh | 52 ++- tests/README.md | 12 +- tests/conftest.py | 25 ++ tests/e2e_cuj/conftest.py | 7 +- tests/e2e_cuj/helpers/constants.py | 3 + tests/e2e_cuj/helpers/session.py | 4 + tests/e2e_cuj/test_cuj7_model_discovery.py | 4 + tests/integration/README.md | 4 +- tests/integration/conftest.py | 6 + .../integration/test_ug_configure_managed.py | 25 ++ tests/integration/utils/managed.py | 10 +- tests/test_integration_runner.py | 20 ++ tests/test_managed_config.py | 151 +++++++++ tests/test_managed_files.py | 306 ++++++++++++++++++ 18 files changed, 840 insertions(+), 14 deletions(-) create mode 100644 docs/managed-workspace-policy.md diff --git a/docs/managed-workspace-policy.md b/docs/managed-workspace-policy.md new file mode 100644 index 000000000..e95fc3e96 --- /dev/null +++ b/docs/managed-workspace-policy.md @@ -0,0 +1,51 @@ +# Managed workspace policy + +`ug configure` reads the workspace's published `CodingAgentConfig` through +`GET /api/ai-gateway/v2/coding-agent-configs` and installs it machine-wide at +`/etc/unitygateway/managed-settings.json` on Linux and macOS. + +The installed file contains only: + +```json +{ + "workspace": "https://your-workspace", + "config": {} +} +``` + +`config` is the raw remote configuration, not normalized agent settings. The empty object above +only illustrates the shape: a workspace without a published policy does not leave a policy file. +The directory is root-owned `0755` and the file is root-owned `0644`, so ordinary users can read it +without sudo. Authentication tokens and profiles stay in user-owned state. + +**Never put secrets in managed policy headers.** The policy is readable by every local user, and +ug does not redact raw fields. Do not put passwords, bearer tokens, API keys, or session cookies in +`http_headers` or anywhere else in the configuration. Routing headers such as a workspace ID are +appropriate. + +## Configure + +- `ug configure` forces a fresh read of the workspace policy, so it never installs a stale one. +- Changed policy is written with sudo from a terminal. Where sudo cannot prompt (for example in a + script), ug uses non-interactive sudo and fails if a password would be needed. +- Root-run configure installs directly, without sudo. +- Unchanged policy writes nothing. A re-read that differs only in `retrieved_time` is not a change. +- A workspace that publishes no policy, or has the feature disabled, removes the installed file. + Configure decides the machine's workspace, so this also removes a policy installed for a different + workspace. +- If the installation or removal fails, configure stops with an error that says the machine-wide + file needs sudo. Rerun `ug configure` from a terminal where you can use sudo, or ask your IT team + or an administrator to run it. +- Windows has no machine-wide policy file. `UG_SKIP_OS_SETTINGS=1` and `ug configure --user` also + skip it, so no file is written. + +Launches never install or remove the machine-wide policy. Per-user caching of the fetched policy +(`~/.ucode/managed-config.json`) is unchanged by this write path. + +## Limits + +- The policy describes one workspace for the whole machine. Configuring another workspace replaces it + for all users. +- Installation needs sudo on Linux and macOS. On MDM machines, ask IT to run the provisioning step + rather than attempting administrator-only configure yourself. +- Live sudo and ownership checks need a disposable clean machine, not a developer workstation. diff --git a/scripts/run_integration.py b/scripts/run_integration.py index ab65b9d86..c40493304 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -93,6 +93,8 @@ def managed_policy_paths( ) paths.append(Path(program_files) / "ClaudeCode" / "managed-settings.json") elif platform_name == "posix": + # Unity Gateway's installed workspace policy affects every agent. + paths.append(Path("/etc/unitygateway/managed-settings.json")) if "codex" in selected: paths.extend( [Path("/etc/codex/managed_config.toml"), Path("/etc/codex/requirements.toml")] diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 9adc74327..65727235c 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -11,8 +11,10 @@ - re-reading it on each launch (reusing a read younger than :data:`MANAGED_CONFIG_TTL` rather than re-fetching), falling back to the persisted copy when the read fails. -There is deliberately one file: the workspace is the source of truth, so the pulled copy lives in -``managed-config.json`` and a launch re-reads it from there. +An explicit ``ug configure`` also installs the workspace policy machine-wide at +``/etc/unitygateway/managed-settings.json`` (Linux and macOS) through +:func:`ucode.managed_files.update_workspace_policy`. Launches never write that file; they read and +refresh only the per-user cache above. :func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery, because the manifest decides whether that discovery is needed at all; the launch path then hands the @@ -37,10 +39,19 @@ fetch_model_recommendation, get_databricks_token, ) +from ucode.managed_files import ( + OS, + UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + current_os, + managed_settings_skipped, + update_workspace_policy, +) from ucode.time_utils import parse_update_time -from ucode.ui import console, print_warning +from ucode.ui import console, normalize_workspace_url, print_warning MANAGED_CONFIG_PATH = config_io.APP_DIR / "managed-config.json" +# The machine-wide workspace policy that explicit ``ug configure`` installs (Linux and macOS only). +MACHINE_POLICY_PATH = UNITY_GATEWAY_MANAGED_SETTINGS_PATH # Shown to a developer when their workspace has no admin-defined managed config yet — the normal # case, not an error. Kept here so the CLI (which surfaces it) uses one consistent message. @@ -378,6 +389,10 @@ class FetchedManagedConfig(NamedTuple): reason: str | None +class ManagedPolicyInstallDenied(RuntimeError): + """``ug configure`` could not install, replace, or remove the machine-wide workspace policy.""" + + class ManagedConfigResult(NamedTuple): """The launch-path refresh outcome: the ``manifest`` to apply (None when absent or dropped) and ``feature_disabled``, True when the coding-agent-configs feature is off server-side.""" @@ -666,6 +681,67 @@ def save_managed_state(workspace: str, config: dict, *, outcome: str | None = No _restrict_permissions(MANAGED_CONFIG_PATH) +def _machine_policy_installable() -> bool: + """Whether this run can hold the machine-wide policy: Linux or macOS, not opted out with + ``UG_SKIP_OS_SETTINGS`` or the ``user`` scope. Windows has no machine policy file.""" + return not managed_settings_skipped() and current_os() in (OS.LINUX, OS.MACOS) + + +def _policy_content(config: dict) -> dict: + """The config compared for changes; ``retrieved_time`` changes on every read, so it is ignored.""" + return {key: value for key, value in config.items() if key != "retrieved_time"} + + +def _install_machine_policy(workspace: str, config: dict) -> None: + """Make the machine policy match an explicit configure's result. + + An empty ``config`` removes the installed policy, whichever workspace it was installed for: the + configured workspace now decides the machine's workspace. Otherwise the file is rewritten only + when its workspace or configuration differs. Raises RuntimeError when sudo is unavailable. + """ + if not config: + update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) + return + workspace = normalize_workspace_url(workspace) + installed = config_io.read_json_safe(MACHINE_POLICY_PATH) + installed_workspace = installed.get("workspace") + installed_config = installed.get("config") + if ( + isinstance(installed_workspace, str) + and installed_workspace.strip() + and normalize_workspace_url(installed_workspace) == workspace + and isinstance(installed_config, dict) + and _policy_content(installed_config) == _policy_content(config) + ): + return + payload = {"workspace": workspace, "config": config} + update_workspace_policy( + MACHINE_POLICY_PATH, + json.dumps(payload, indent=2) + "\n", + interactive=True, + allow_sudo=True, + ) + + +def _sync_machine_policy(workspace: str, config: dict, *, force_refresh: bool) -> None: + """Install or remove the machine policy for an explicit configure; launches never write it. + + A failed install raises :class:`ManagedPolicyInstallDenied`, so configure reports it instead of + implying the machine is managed for this workspace. + """ + if not force_refresh or not _machine_policy_installable(): + return + try: + _install_machine_policy(workspace, config) + except RuntimeError as exc: + message = ( + f"Could not install the workspace policy at {MACHINE_POLICY_PATH}: {exc} " + "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where you " + "can use sudo, or ask your IT team or an administrator to run it." + ) + raise ManagedPolicyInstallDenied(message) from exc + + def _restrict_permissions(path: Path) -> None: """Best-effort chmod 0600. No-op where unsupported (e.g. Windows), where the effective read-only guarantee is left to a later change.""" @@ -783,6 +859,7 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag if reason is not None: if _is_feature_disabled(reason): save_managed_state(workspace, {}, outcome=_OUTCOME_FEATURE_DISABLED) + _sync_machine_policy(workspace, {}, force_refresh=force_refresh) return ManagedConfigResult(None, True) fallback = _persisted_fallback(workspace, reason, refused=_is_permission_denied(reason)) return ManagedConfigResult(fallback, False) @@ -791,9 +868,11 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag # the file doubles as the fallback above, so a removed policy would otherwise come back # into force after the next transient outage. save_managed_state(workspace, {}, outcome=_OUTCOME_NONE) + _sync_machine_policy(workspace, {}, force_refresh=force_refresh) return ManagedConfigResult(None, False) # Persist the raw config verbatim; hand callers the normalized manifest they expect. save_managed_state(workspace, raw, outcome=_OUTCOME_PUBLISHED) + _sync_machine_policy(workspace, raw, force_refresh=force_refresh) return ManagedConfigResult(normalize_managed_config(raw), False) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 6e6d780e2..9009ef37e 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -42,6 +42,7 @@ MANAGED_BACKUP_DIR = APP_DIR / "managed-backups" MANAGED_BACKUP_MANIFEST_PATH = MANAGED_BACKUP_DIR / "manifest.json" MANAGED_FINGERPRINT_VERSION = 1 +UNITY_GATEWAY_MANAGED_SETTINGS_PATH = Path("/etc/unitygateway/managed-settings.json") _MISSING = object() _managed_write_batch: tuple[str, ...] = () _managed_write_notice_shown = False @@ -72,12 +73,14 @@ class OS(Enum): { Path("/etc/claude-code/managed-settings.json"), Path("/etc/codex/managed_config.toml"), + UNITY_GATEWAY_MANAGED_SETTINGS_PATH, } ), OS.MACOS: frozenset( { Path("/Library/Application Support/ClaudeCode/managed-settings.json"), Path("/etc/codex/managed_config.toml"), + UNITY_GATEWAY_MANAGED_SETTINGS_PATH, } ), } @@ -1195,8 +1198,8 @@ def _merge_shared_list(doc: dict, path: list[str], admin_items: list, *, withdra ) -def _sudo_replace_command(mode: str, *args: str) -> list[str]: - return _sudo_command( +def _sudo_replace_command(mode: str, *args: str, non_interactive: bool = False) -> list[str]: + command = [ "/bin/sh", "-c", _SUDO_REPLACE_SCRIPT, @@ -1204,7 +1207,85 @@ def _sudo_replace_command(mode: str, *args: str) -> list[str]: mode, current_os().value, *args, - ) + ] + if non_interactive: + if os.geteuid() == 0: + return command + return [_SUDO, "-n", *command] + return _sudo_command(*command) + + +def update_workspace_policy( + path: Path, + desired_text: str | None, + *, + interactive: bool = False, + allow_sudo: bool = False, +) -> None: + """Install or remove changed policy; unprivileged launches never invoke sudo.""" + if path != UNITY_GATEWAY_MANAGED_SETTINGS_PATH: + raise RuntimeError(f"Refusing unexpected workspace-policy target: {path}") + if current_os() not in (OS.LINUX, OS.MACOS): + raise ManagedFileWriteUnavailable( + "Machine-wide workspace policy is supported only on Linux and macOS." + ) + if managed_settings_skipped(): + raise ManagedFileWriteUnavailable("Machine-wide workspace policy is skipped for this run.") + _validate_sudo_replace_target(path) + current_text = read_managed_file(path) + if current_text == desired_text: + return + # Checked before the dry-run preview, so a dry run never shows a write the real run refuses. + if not allow_sudo and os.geteuid() != 0: + raise ManagedFileWriteUnavailable( + "Workspace policy updates require an administrator or root provisioning run." + ) + if is_dry_run(): + console.print( + f"\n[bold]\\[dry run] {path} (via sudo)[/bold]\n" + f"{desired_text if desired_text is not None else '(remove)'}" + ) + return + running_as_root = os.geteuid() == 0 + can_prompt = interactive and managed_writes_allowed() and not running_as_root + # Run directly as root (no sudo), the script still checks the temp source's owner against + # SUDO_UID. Under `sudo ug configure` that variable names the invoking user while root owns the + # source, so point it at root for the direct run. + script_env = {**os.environ, "SUDO_UID": "0"} if running_as_root else None + try: + if desired_text is not None and can_prompt: + _sudo_replace(path, desired_text) + else: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as source: + source.write(desired_text if desired_text is not None else current_text or "") + source.flush() + subprocess_cross_os.run( + _sudo_replace_command( + "once" if desired_text is not None else "remove", + source.name, + str(path), + non_interactive=not can_prompt, + ), + capture_output=True, + text=True, + check=True, + # A password prompt waits on the person typing; only unattended runs time out. + timeout=None if can_prompt else 30, + env=script_env, + ) + except (OSError, subprocess.SubprocessError) as exc: + stderr = getattr(exc, "stderr", None) + if isinstance(stderr, bytes): + # TimeoutExpired carries bytes even when the run used text mode. + stderr = stderr.decode("utf-8", "replace") + detail = (stderr or "").strip() + raise ManagedFileWriteUnavailable( + f"Could not update workspace policy at {path}." + (f" {detail}" if detail else "") + ) from exc + if read_managed_file(path) != desired_text: + raise ManagedFileWriteUnavailable( + f"Workspace policy at {path} changed during installation; run `ug configure` again." + ) def _encode_worker_arg(value: str) -> str: diff --git a/src/ucode/managed_replace.sh b/src/ucode/managed_replace.sh index 64f5e3343..fca862699 100644 --- a/src/ucode/managed_replace.sh +++ b/src/ucode/managed_replace.sh @@ -30,8 +30,10 @@ target_is_allowed() { case "$platform:$1" in "linux:/etc/claude-code/managed-settings.json"|\ "linux:/etc/codex/managed_config.toml"|\ + "linux:/etc/unitygateway/managed-settings.json"|\ "macos:/Library/Application Support/ClaudeCode/managed-settings.json"|\ - "macos:/etc/codex/managed_config.toml") return 0 ;; + "macos:/etc/codex/managed_config.toml"|\ + "macos:/etc/unitygateway/managed-settings.json") return 0 ;; *) return 1 ;; esac } @@ -70,12 +72,12 @@ replace_one() ( source_is_valid() { [ -f "$source_path" ] && [ ! -L "$source_path" ] || return 1 - [ -n "${SUDO_UID:-}" ] || return 1 if [ "$platform" = macos ]; then source_owner=$(/usr/bin/stat -f %u "$source_path" 2>/dev/null) || return 1 else source_owner=$(stat -c %u "$source_path" 2>/dev/null) || return 1 fi + [ -n "${SUDO_UID:-}" ] || return 1 [ "$source_owner" = "$SUDO_UID" ] } @@ -135,6 +137,10 @@ replace_one() ( run_step chown 0:0 "$parent" run_step chmod 0755 "$parent" fi + if [ "$target" = /etc/unitygateway/managed-settings.json ]; then + run_step chown 0:0 "$parent" + run_step chmod 0755 "$parent" + fi staging=$(mktemp "$staging_template") || exit "$?" @@ -191,6 +197,10 @@ replace_one() ( run_step chown 0:0 "$staging" run_step chmod 0644 "$staging" fi + if [ "$target" = /etc/unitygateway/managed-settings.json ]; then + run_step chown 0:0 "$staging" + run_step chmod 0644 "$staging" + fi if [ -L "$parent" ] || [ -L "$target" ] || { [ -e "$target" ] && [ ! -f "$target" ]; }; then printf '%s\n' "Refusing to replace symlinked managed settings: $target" >&2 @@ -203,10 +213,48 @@ replace_one() ( clear_flags= ) +remove_one() ( + set -eu + source_path=$1 + target=$2 + parent=${target%/*} + if [ "$target" != /etc/unitygateway/managed-settings.json ] || ! target_is_allowed "$target"; then + printf '%s\n' "Refusing unexpected workspace-policy removal: $target" >&2 + exit 1 + fi + if [ -L "$parent" ] || [ -L "$target" ] || [ ! -f "$target" ]; then + printf '%s\n' "Refusing unsafe workspace-policy removal: $target" >&2 + exit 1 + fi + if [ ! -f "$source_path" ] || [ -L "$source_path" ]; then + printf '%s\n' "Refusing invalid workspace-policy source: $source_path" >&2 + exit 1 + fi + if [ "$platform" = macos ]; then + source_owner=$(/usr/bin/stat -f %u "$source_path") || { + printf '%s\n' "Cannot inspect workspace-policy source: $source_path" >&2 + exit 1 + } + else + source_owner=$(stat -c %u "$source_path") || { + printf '%s\n' "Cannot inspect workspace-policy source: $source_path" >&2 + exit 1 + } + fi + if [ -z "${SUDO_UID:-}" ] || [ "$source_owner" != "$SUDO_UID" ] || ! cmp -s "$source_path" "$target"; then + printf '%s\n' "Workspace policy changed before removal: $target" >&2 + exit 1 + fi + rm -f "$target" +) + case "$mode" in once) replace_one "$1" "$2" ;; + remove) + remove_one "$1" "$2" + ;; session) while IFS=' ' read -r operation request_id source_arg target_arg; do case "$operation" in diff --git a/tests/README.md b/tests/README.md index 01d4f604e..ded800103 100644 --- a/tests/README.md +++ b/tests/README.md @@ -533,6 +533,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured | | `test_ug_configure_claude_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace | | `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup | +| `test_ug_configure_installs_workspace_policy_machine_wide` | Run a managed `ug configure` with no installed workspace policy | `/etc/unitygateway/managed-settings.json` is root-owned `0644` in a root-owned `0755` directory and contains exactly `workspace` and `config`; the suite removes it at teardown (marker `managed_fixture`) | | `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure on the managed workspace with the stubbed `managed_workspace_default.json` CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch. The Codex case also checks the shared catalog pointer, restart guidance, and a fresh bare app-server's visible model list | | `e2e_cuj/test_cuj5_budget_defaults.py` | Launch bare `ug` with separate low-spend and above-tier principals against the fixed 1% tier (`spending_percentage=0.01`); explicitly launch `ug claude` above the tier | Bare launches check Claude/Sonnet below the tier and the Codex/Luna recommendation above it; `ug usage` agrees with backend spend, threshold, and percentage. Explicit `ug claude` displays the backend's Codex/Luna recommendation while its generated model setting and native header select Sonnet. No budget writes or inference tasks. Runs on its own `E2E CUJs · CUJ 5 · Budget-driven defaults` runner with both credential pairs documented in `integration/README.md`. | | `test_case_01_*` | Launch managed Claude without defaults after configure and from fresh state | Claude receives the admin MPS header; its gateway cache and replacement picker match the independently fetched provider model IDs; catalog labels are preserved and a model appears in a numbered picker row | @@ -553,7 +554,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi With Claude and Codex selected there are **87 live cases** (11 marked TUI cases), **1 two-workspace case** (marker `workspace_switch`), -**43 managed-fixture cases** (marker `managed_fixture`, with only +**44 managed-fixture cases** (marker `managed_fixture`, with only the CodingAgentConfig input injected from a JSON file in `fixtures/managed_config/`), and **7 installation checks**. The 14 retained numbered scenarios comprise **24 explicit journeys**: 12 managed configured/fresh executions and 12 unmanaged executions. The remaining managed-fixture cases cover focused model, MCP, skills, @@ -734,6 +735,15 @@ and real-shell copy/rename failure handling in temporary directories. `test_cli.py` covers the workspace configure session boundary. These are unit/component checks; they do not establish live sudo password-prompt behavior. +`test_managed_config.py::TestMachinePolicyInstall` covers `ug configure` installing the machine-wide +workspace policy: unchanged content writes nothing, a changed policy is reinstalled, no-config and +feature-disabled remove it (for the same or another workspace), launches never write it, denied sudo +raises `ManagedPolicyInstallDenied`, and Windows, other OSes, and `UG_SKIP_OS_SETTINGS` never write. +`test_managed_files.py` covers the workspace-policy writer (non-root launch never invokes sudo, sudo +password failures, root direct runs, dry run, symlink refusal) and runs the real managed shell script +for install and matching removal. These are component checks against a faked installer; they do not +establish live sudo behavior. + Codex desktop OTEL component tests cover the systemd unit, the macOS LaunchAgent, and the Windows scheduled task (`launchctl` and `schtasks` are simulated), reuse of CLI proxy/auth, fixed-port collisions, setup failures that only warn, unchanged launches that keep the diff --git a/tests/conftest.py b/tests/conftest.py index fb5901ddd..29b324f3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os from types import SimpleNamespace @@ -51,6 +52,30 @@ def _isolate_ucode_state(tmp_path, monkeypatch): monkeypatch.setattr( managed_config_mod, "MANAGED_CONFIG_PATH", state_dir / "managed-config.json" ) + # The machine-wide workspace policy lives outside APP_DIR. Redirect it, and fake its installer, + # so no test installs or removes the real /etc/unitygateway/managed-settings.json. + monkeypatch.setattr( + managed_config_mod, + "MACHINE_POLICY_PATH", + tmp_path / "etc" / "unitygateway" / "managed-settings.json", + ) + + def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=False): + if path != managed_config_mod.MACHINE_POLICY_PATH or not path.is_relative_to(tmp_path): + pytest.fail(f"test attempted to install machine-wide policy at {path}") + if config_io_mod.is_dry_run(): + return + if desired_text is None: + path.unlink(missing_ok=True) + else: + path.parent.mkdir(parents=True, exist_ok=True) + config_io_mod.atomic_write_json(path, json.loads(desired_text)) + path.chmod(0o644) + + monkeypatch.setattr(managed_config_mod, "update_workspace_policy", install_test_policy) + # The installer is faked, so let every host take the Linux/macOS install path; tests for + # Windows or UG_SKIP_OS_SETTINGS override this. + monkeypatch.setattr(managed_config_mod, "current_os", lambda: managed_files_mod.OS.LINUX) backup_dir = state_dir / "managed-backups" monkeypatch.setattr(managed_files_mod, "MANAGED_BACKUP_DIR", backup_dir) monkeypatch.setattr( diff --git a/tests/e2e_cuj/conftest.py b/tests/e2e_cuj/conftest.py index a9aab171a..ff92da509 100644 --- a/tests/e2e_cuj/conftest.py +++ b/tests/e2e_cuj/conftest.py @@ -9,7 +9,7 @@ from databricks.sdk.errors import DatabricksError from .base import bearer -from .helpers.constants import CLAUDE, CODEX, MANAGED_PATHS +from .helpers.constants import CLAUDE, CODEX, MANAGED_PATHS, WORKSPACE_POLICY_PATH from .helpers.session import ( MACHINE_WIDE_LEAK, UserSession, @@ -34,6 +34,11 @@ def _scrubbed(error: DatabricksError) -> RuntimeError: @pytest.fixture(scope="class") def cuj(request, setup_workspace, tmp_path_factory): assert os.name == "posix", "Full TUI CUJs require a disposable POSIX runner" + # Teardown removes the workspace policy, so never start on one this run didn't install. + assert not WORKSPACE_POLICY_PATH.exists(), ( + f"Existing workspace policy {WORKSPACE_POLICY_PATH}; use a clean disposable runner. " + "Nothing was changed." + ) assert not any(path.exists() for path in MANAGED_PATHS), dirty_runner_message( request.config.stash.get(MACHINE_WIDE_LEAK, None) ) diff --git a/tests/e2e_cuj/helpers/constants.py b/tests/e2e_cuj/helpers/constants.py index 132ee0e48..211598d4b 100644 --- a/tests/e2e_cuj/helpers/constants.py +++ b/tests/e2e_cuj/helpers/constants.py @@ -11,6 +11,9 @@ Path("/etc/codex/requirements.toml"), Path("/Library/Application Support/ClaudeCode/managed-settings.json"), ) +# Unity Gateway's installed workspace policy. Any `ug configure` may install it (with sudo), so it +# is not a leaked agent setting; CUJ teardown removes it. +WORKSPACE_POLICY_PATH = Path("/etc/unitygateway/managed-settings.json") INFERENCE_PATHS = { CLAUDE: "/ai-gateway/anthropic/v1/messages", CODEX: "/ai-gateway/codex/v1/responses", diff --git a/tests/e2e_cuj/helpers/session.py b/tests/e2e_cuj/helpers/session.py index 163957600..a4cd8dc30 100644 --- a/tests/e2e_cuj/helpers/session.py +++ b/tests/e2e_cuj/helpers/session.py @@ -79,4 +79,8 @@ def revert_machine_wide(self, name, message): if any((state_dir / file).is_file() for file in MANAGED_STATE_FILES): with TerminalProcess(self, "ug", [str(self.binary), "revert"], name) as terminal: terminal.finish() + # `ug revert` leaves the installed workspace policy, like an uninstall would not. + policy = constants.WORKSPACE_POLICY_PATH + if policy.exists(): + self.run("rm", "-f", str(policy), binary=self.which("sudo")) assert not any(path.exists() for path in constants.MANAGED_PATHS), message diff --git a/tests/e2e_cuj/test_cuj7_model_discovery.py b/tests/e2e_cuj/test_cuj7_model_discovery.py index 01693f092..e0f4cd091 100644 --- a/tests/e2e_cuj/test_cuj7_model_discovery.py +++ b/tests/e2e_cuj/test_cuj7_model_discovery.py @@ -25,6 +25,7 @@ CODEX_LUNA_MODEL_SERVICE, MANAGED_PATHS, MODEL_SERVICE_SCHEMA, + WORKSPACE_POLICY_PATH, ) from .helpers.evidence import ( SessionEvidence, @@ -70,6 +71,9 @@ def _assert_unmanaged(self): def live_session(self, unmanaged_workspace, tmp_path, request): """Keep each launch unconfigured; the class-scoped cuj fixture requires a published config.""" assert os.name == "posix", "CUJ7 requires a disposable POSIX runner" + assert not WORKSPACE_POLICY_PATH.exists(), ( + f"Existing workspace policy {WORKSPACE_POLICY_PATH}; use a clean disposable runner." + ) assert not any(path.exists() for path in MANAGED_PATHS), dirty_runner_message( request.config.stash.get(MACHINE_WIDE_LEAK, None) ) diff --git a/tests/integration/README.md b/tests/integration/README.md index 9d9dde8e9..e9429770e 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -699,7 +699,7 @@ journeys check setup and launch with existing services, without inference. There are **87 live cases** (including 11 marked TUI journeys) and **7 installation checks** with Claude and Codex; selecting OpenCode adds one live headless case and two `managed_fixture` cases; selecting Copilot adds ten live cases. One **`workspace_switch` case** uses two real workspaces and checks skills MCP cleanup and a completed -Claude task. A further **43 `managed_fixture` cases** (twelve of them also `live`) run on the +Claude task. A further **44 `managed_fixture` cases** (twelve of them also `live`) run on the managed workspace with a checked-in JSON CodingAgentConfig from `tests/fixtures/managed_config/` injected through `UCODE_MANAGED_CONFIG_STUB`; there are no cases that read a published config. Twelve explicit configured/fresh Claude and Codex discovery and source-override journeys use the @@ -718,7 +718,7 @@ bearer; no second workspace or extra secret is involved. Across integration and dedicated CUJ7, the 14 numbered scenarios comprise 24 journeys: 12 managed and 12 unmanaged. Integration contains 22 of them (12 managed, 10 unmanaged); configured unmanaged cases 7 and 8 run in `e2e_cuj/test_cuj7_model_discovery.py`. -The complete integration suite collects 129 executions (126 with Claude and Codex selected). +The complete integration suite collects 130 executions (127 with Claude and Codex selected). See the named coverage and gaps matrix in [../README.md](../README.md). diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7cff7d4fb..452945301 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -163,6 +163,12 @@ def session(request, installed_binary): # Installation checks are noninteractive; if a regression # creates state, still clean it through the installed CLI. user.run("revert", timeout=120) + # `ug revert` keeps the machine's workspace policy; remove it like an uninstall so a + # later fresh-home case, or the next run's policy preflight, starts clean. + policy = Path("/etc/unitygateway/managed-settings.json") + sudo = shutil.which("sudo") + if os.name == "posix" and policy.exists() and sudo: + user.run("-n", "rm", "-f", str(policy), binary=Path(sudo), timeout=30) @pytest.fixture diff --git a/tests/integration/test_ug_configure_managed.py b/tests/integration/test_ug_configure_managed.py index 8341c54fe..d2d888f4e 100644 --- a/tests/integration/test_ug_configure_managed.py +++ b/tests/integration/test_ug_configure_managed.py @@ -10,6 +10,7 @@ import json import tomllib +from pathlib import Path import pytest from utils.managed import use_managed_config_fixture @@ -116,6 +117,30 @@ def test_ug_configure_managed_is_idempotent(live_session, workspace): assert runs == [expected, expected], runs +@pytest.mark.managed_fixture +@pytest.mark.claude +def test_ug_configure_installs_workspace_policy_machine_wide(live_session, workspace): + """Scenario: run a managed `ug configure` on a machine with no installed workspace policy. + + Expected: configure installs the workspace policy at /etc/unitygateway/managed-settings.json as + a root-owned 0644 file in a root-owned 0755 directory, containing exactly the `workspace` and + `config` keys. Launches read the per-user cache as before, so this test checks only the + installed file, which the test suite removes at teardown. + """ + session = live_session + use_managed_config_fixture(session, "managed_workspace_default") + policy = Path("/etc/unitygateway/managed-settings.json") + + session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) + + installed = policy.stat() + assert installed.st_uid == 0 + assert installed.st_mode & 0o777 == 0o644 + assert policy.parent.stat().st_uid == 0 + assert policy.parent.stat().st_mode & 0o777 == 0o755 + assert set(json.loads(policy.read_text(encoding="utf-8"))) == {"workspace", "config"} + + @pytest.mark.managed_fixture @pytest.mark.claude def test_ug_managed_config_launch_reuses_cache_within_ttl(live_session, workspace): diff --git a/tests/integration/utils/managed.py b/tests/integration/utils/managed.py index 6b173b949..735e7c106 100644 --- a/tests/integration/utils/managed.py +++ b/tests/integration/utils/managed.py @@ -38,8 +38,14 @@ def assert_no_managed_config(payload: object) -> None: def is_managed_config_control_plane_cache(home: Path, path: Path) -> bool: - """Whether ``path`` is ug's expected fetched-config cache, not agent-owned state.""" - return path == home / ".ucode" / "managed-config.json" + """Whether ``path`` is ug's workspace-policy state, not agent-owned configuration. + + That is the machine-wide installed policy, or the per-user cache of the fetched policy. + """ + return path in ( + home / ".ucode" / "managed-config.json", + Path("/etc/unitygateway/managed-settings.json"), + ) def use_smart_routing_fixture( diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 87d555c08..37861b039 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -139,6 +139,26 @@ def test_windows_policy_paths_match_pinned_agent_locations(): ) +@pytest.mark.parametrize( + ("system_platform", "claude_path"), + [ + ("linux", "/etc/claude-code/managed-settings.json"), + ("darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"), + ], +) +def test_posix_policy_paths_include_the_installed_workspace_policy(system_platform, claude_path): + paths = runner.managed_policy_paths( + ["claude", "codex"], {}, platform_name="posix", system_platform=system_platform + ) + + assert paths == ( + Path("/etc/unitygateway/managed-settings.json"), + Path("/etc/codex/managed_config.toml"), + Path("/etc/codex/requirements.toml"), + Path(claude_path), + ) + + def test_windows_policy_preflight_fails_closed_without_required_roots(): with pytest.raises(RuntimeError, match="PROGRAMDATA"): runner.managed_policy_paths(["codex"], {}, platform_name="nt", system_platform="win32") diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index 782ffeefc..87112ced3 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -14,6 +14,7 @@ import ucode.databricks as db_mod import ucode.managed_config as mc_mod from ucode.managed_config import ( + ManagedPolicyInstallDenied, get_managed_config, load_managed_configuration, load_managed_state, @@ -24,6 +25,7 @@ refresh_managed_config, save_managed_state, ) +from ucode.managed_files import OS, ManagedFileWriteUnavailable from ucode.managed_setup import serialize_managed_config from ucode.telemetry import client_platform @@ -1075,3 +1077,152 @@ def test_unparseable_decimals_become_none(self, monkeypatch): ) rec, _ = mc_mod.get_model_recommendation("https://w", "tok") assert rec is not None and rec["current_spend"] is None + + +OTHER_WORKSPACE = "https://other.example.com" + + +class TestMachinePolicyInstall: + """`ug configure` installs the workspace policy machine-wide; a launch never writes it.""" + + @pytest.fixture(autouse=True) + def _stub_token(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") + + @staticmethod + def _recording_installer(monkeypatch) -> list[object]: + """Record every installer write, still applying it to the redirected machine path.""" + calls: list[object] = [] + install = mc_mod.update_workspace_policy + + def record(path, desired_text, **kwargs): + calls.append(desired_text) + install(path, desired_text, **kwargs) + + monkeypatch.setattr(mc_mod, "update_workspace_policy", record) + return calls + + @staticmethod + def _installed() -> dict | None: + path = mc_mod.MACHINE_POLICY_PATH + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None + + def test_configure_installs_workspace_and_config(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + assert self._installed() == {"workspace": WORKSPACE, "config": RAW_MANIFEST} + # The per-user cache keeps main's behavior: it is still written on configure. + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + + def test_unchanged_policy_writes_nothing(self, monkeypatch): + calls = self._recording_installer(monkeypatch) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + # A re-read differs only in retrieved_time, which is not policy content. + reread = {**RAW_MANIFEST, "retrieved_time": "2030-01-01T00:00:00Z"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (reread, None)) + refresh_managed_config(_state(), force_refresh=True) + assert len(calls) == 1 + + def test_changed_policy_is_reinstalled(self, monkeypatch): + calls = self._recording_installer(monkeypatch) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + changed = {**RAW_MANIFEST, "mcp_servers": {"names": ["system.ai.other"]}} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (changed, None)) + refresh_managed_config(_state(), force_refresh=True) + assert len(calls) == 2 + assert (self._installed() or {})["config"] == changed + + def test_configure_without_policy_removes_the_same_workspaces_policy(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) + refresh_managed_config(_state(), force_refresh=True) + assert self._installed() is None + + def test_configure_without_policy_removes_another_workspaces_policy(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(workspace=OTHER_WORKSPACE), force_refresh=True) + assert (self._installed() or {})["workspace"] == OTHER_WORKSPACE + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) + refresh_managed_config(_state(), force_refresh=True) + assert self._installed() is None + + def test_feature_disabled_configure_removes_installed_policy(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + disabled = (None, "FEATURE_DISABLED: coding agent configs are off") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: disabled) + result = refresh_managed_config(_state(), force_refresh=True) + assert result == (None, True) + assert self._installed() is None + + def test_launch_never_writes_the_machine_policy(self, monkeypatch): + calls = self._recording_installer(monkeypatch) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state()) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) + refresh_managed_config(_state(), force_refresh=False) + assert calls == [] + assert self._installed() is None + + def test_launch_within_ttl_reads_the_per_user_cache(self, monkeypatch): + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + monkeypatch.setattr( + mc_mod, + "get_managed_config", + lambda ws, tok: pytest.fail("a launch within the TTL must not fetch"), + ) + result, _ = refresh_managed_config(_state()) + assert result == normalize_managed_config(RAW_MANIFEST) + + def test_denied_install_raises_actionable_error(self, monkeypatch): + def denied(path, desired_text, **kwargs): + raise ManagedFileWriteUnavailable("a password is required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + with pytest.raises(ManagedPolicyInstallDenied, match="needs sudo") as error: + refresh_managed_config(_state(), force_refresh=True) + assert isinstance(error.value, RuntimeError) + assert "ug configure" in str(error.value) + assert "a password is required" in str(error.value) + + def test_denied_removal_raises(self, monkeypatch): + def denied(path, desired_text, **kwargs): + raise ManagedFileWriteUnavailable("sudo: a password is required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) + with pytest.raises(ManagedPolicyInstallDenied): + refresh_managed_config(_state(), force_refresh=True) + + def test_denied_launch_refresh_does_not_raise(self, monkeypatch): + def denied(path, desired_text, **kwargs): + raise ManagedFileWriteUnavailable("a password is required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + result, _ = refresh_managed_config(_state()) + assert result == normalize_managed_config(RAW_MANIFEST) + + @pytest.mark.parametrize("platform", [OS.WINDOWS, OS.OTHER]) + def test_platforms_without_machine_policy_never_write(self, monkeypatch, platform): + calls = self._recording_installer(monkeypatch) + monkeypatch.setattr(mc_mod, "current_os", lambda: platform) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + result, _ = refresh_managed_config(_state(), force_refresh=True) + assert calls == [] + assert result == normalize_managed_config(RAW_MANIFEST) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + + def test_skip_os_settings_never_writes_the_machine_policy(self, monkeypatch): + calls = self._recording_installer(monkeypatch) + monkeypatch.setattr(mc_mod, "managed_settings_skipped", lambda: True) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) + refresh_managed_config(_state(), force_refresh=True) + assert calls == [] + assert self._installed() is None diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 8c36c53e4..a03e83934 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -571,8 +571,314 @@ def test_real_managed_path_helpers_are_allowlisted(self, os_enum, monkeypatch): assert codex_config.codex_managed_config_path() in allowed +class TestWorkspacePolicyWriter: + @pytest.fixture + def policy_path(self, tmp_path, monkeypatch): + path = tmp_path / "unitygateway" / "managed-settings.json" + path.parent.mkdir() + monkeypatch.setattr(managed_files, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setitem( + managed_files._SUDO_REPLACE_TARGETS, + managed_files.current_os(), + frozenset({path}), + ) + monkeypatch.setattr(managed_files.os, "geteuid", lambda: 1000, raising=False) + return path + + def test_identical_policy_does_not_invoke_sudo(self, policy_path, monkeypatch): + policy_path.write_text("installed") + monkeypatch.setattr( + managed_files.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("unchanged policy must not invoke sudo"), + ) + managed_files.update_workspace_policy(policy_path, "installed", interactive=True) + + @pytest.mark.parametrize("desired_text", ["new", None]) + @pytest.mark.parametrize("interactive", [False, True]) + def test_nonroot_launch_never_probes_or_invokes_sudo( + self, policy_path, monkeypatch, desired_text, interactive + ): + policy_path.write_text("old") + monkeypatch.setattr(managed_files, "managed_writes_allowed", lambda: True) + monkeypatch.setattr( + managed_files.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("non-root launch must never invoke sudo"), + ) + monkeypatch.setattr( + managed_files, + "_sudo_replace", + lambda *args, **kwargs: pytest.fail("non-root launch must never start a sudo worker"), + ) + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="administrator"): + managed_files.update_workspace_policy( + policy_path, desired_text, interactive=interactive, allow_sudo=False + ) + assert policy_path.read_text() == "old" + + def test_nonroot_launch_cannot_install_initial_policy(self, policy_path, monkeypatch): + monkeypatch.setattr( + managed_files.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("initial launch install must not invoke sudo"), + ) + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="administrator"): + managed_files.update_workspace_policy(policy_path, "new", allow_sudo=False) + assert not policy_path.exists() + + def test_changed_interactive_policy_uses_existing_prompting_writer( + self, policy_path, monkeypatch + ): + policy_path.write_text("old") + calls = [] + + def write(path, desired_text): + calls.append((path, desired_text)) + path.write_text(desired_text) + + monkeypatch.setattr(managed_files, "_sudo_replace", write) + managed_files.update_workspace_policy(policy_path, "new", interactive=True, allow_sudo=True) + assert calls == [(policy_path, "new")] + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_noninteractive_policy_update_uses_sudo_n(self, policy_path, monkeypatch): + monkeypatch.setattr(managed_files.sys.stdin, "isatty", lambda: False) + calls = [] + + def run(command, **kwargs): + calls.append(command) + policy_path.write_text(Path(command[-2]).read_text()) + assert kwargs["timeout"] == 30 + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", run) + managed_files.update_workspace_policy(policy_path, "new", interactive=True, allow_sudo=True) + assert calls[0][:2] == [managed_files._SUDO, "-n"] + assert policy_path.read_text() == "new" + assert not Path(calls[0][-2]).exists() + + def test_failed_privileged_update_leaves_original_file(self, policy_path, monkeypatch): + policy_path.write_text("old") + + def denied(command, **kwargs): + raise subprocess.CalledProcessError(1, command, stderr="a password is required") + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", denied) + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="password"): + managed_files.update_workspace_policy(policy_path, "new", allow_sudo=True) + assert policy_path.read_text() == "old" + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_interactive_policy_removal_may_prompt(self, policy_path, monkeypatch): + policy_path.write_text("old") + calls = [] + + def remove(command, **kwargs): + calls.append(command) + assert Path(command[-2]).read_text() == "old" + policy_path.unlink() + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", remove) + managed_files.update_workspace_policy(policy_path, None, interactive=True, allow_sudo=True) + assert calls[0][0:2] == [managed_files._SUDO, "/bin/sh"] + assert "remove" in calls[0] + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + @pytest.mark.parametrize(("interactive", "timeout"), [(True, None), (False, 30)]) + def test_policy_removal_waits_for_a_password_prompt( + self, policy_path, monkeypatch, interactive, timeout + ): + """Only an unattended removal times out; a person typing a sudo password is not rushed.""" + policy_path.write_text("old") + seen = {} + + def remove(command, **kwargs): + seen.update(kwargs) + policy_path.unlink() + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", remove) + managed_files.update_workspace_policy( + policy_path, None, interactive=interactive, allow_sudo=True + ) + assert seen["timeout"] == timeout + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_failed_policy_update_reports_why_sudo_failed(self, policy_path, monkeypatch): + policy_path.write_text("old") + + def denied(command, **kwargs): + raise subprocess.CalledProcessError(1, command, stderr="sudo: a password is required\n") + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", denied) + with pytest.raises(managed_files.ManagedFileWriteUnavailable) as error: + managed_files.update_workspace_policy( + policy_path, None, interactive=False, allow_sudo=True + ) + assert "sudo: a password is required" in str(error.value) + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_timed_out_policy_update_decodes_sudo_output(self, policy_path, monkeypatch): + policy_path.write_text("old") + + def slow(command, **kwargs): + raise subprocess.TimeoutExpired(command, 30, stderr=b"sudo: still waiting\n") + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", slow) + with pytest.raises(managed_files.ManagedFileWriteUnavailable) as error: + managed_files.update_workspace_policy(policy_path, None, allow_sudo=True) + assert "sudo: still waiting" in str(error.value) + assert "b'" not in str(error.value) + + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_root_policy_update_does_not_need_sudo(self, policy_path, monkeypatch): + monkeypatch.setattr(managed_files.os, "geteuid", lambda: 0) + calls = [] + + def run(command, **kwargs): + calls.append(command) + policy_path.write_text(Path(command[-2]).read_text()) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", run) + managed_files.update_workspace_policy( + policy_path, "new", interactive=True, allow_sudo=False + ) + assert calls[0][0:2] == ["/bin/sh", "-c"] + + def test_policy_dry_run_never_invokes_sudo(self, policy_path, monkeypatch): + config_io.set_dry_run(True) + monkeypatch.setattr( + managed_files.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("dry run must not invoke sudo"), + ) + managed_files.update_workspace_policy(policy_path, "new", interactive=True, allow_sudo=True) + assert not policy_path.exists() + + def test_policy_symlink_is_rejected_before_sudo(self, policy_path, tmp_path, monkeypatch): + target = tmp_path / "user-file.json" + target.write_text("user settings") + policy_path.symlink_to(target) + monkeypatch.setattr( + managed_files.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("symlink must not invoke sudo"), + ) + with pytest.raises(RuntimeError, match="symlink"): + managed_files.update_workspace_policy(policy_path, "new") + assert target.read_text() == "user settings" + + +class TestWorkspacePolicyAsRoot: + def test_direct_root_run_points_sudo_uid_at_root(self, tmp_path, monkeypatch): + """Under `sudo ug configure` SUDO_UID names the caller, but root owns the temp source.""" + path = tmp_path / "unitygateway" / "managed-settings.json" + monkeypatch.setattr(managed_files, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setitem(managed_files._SUDO_REPLACE_TARGETS, managed_files.OS.LINUX, {path}) + monkeypatch.setattr(managed_files, "current_os", lambda: managed_files.OS.LINUX) + monkeypatch.setattr(managed_files.os, "geteuid", lambda: 0, raising=False) + monkeypatch.setenv("SUDO_UID", "1000") + calls = [] + + def run(command, **kwargs): + calls.append((command, kwargs)) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"a": 1}\n', encoding="utf-8") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", run) + + managed_files.update_workspace_policy(path, '{"a": 1}\n', interactive=True) + + command, kwargs = calls[0] + assert command[0] == "/bin/sh" + assert kwargs["env"]["SUDO_UID"] == "0" + + @pytest.mark.skipif(sys.platform == "win32", reason="The managed writer is Unix-only") class TestManagedWorkerShell: + @pytest.mark.parametrize("existing", [False, True]) + def test_real_shell_enforces_workspace_policy_permissions(self, tmp_path, existing): + source = tmp_path / "source.json" + source.write_text('{"workspace":"https://ws","config":{"spec_version":1}}\n') + parent = tmp_path / "unitygateway" + parent.mkdir(mode=0o700) + target = parent / "managed-settings.json" + if existing: + target.write_text("old policy") + target.chmod(0o600) + ownership_log = tmp_path / "ownership.log" + script = managed_files._SUDO_REPLACE_SCRIPT.replace( + "/etc/unitygateway/managed-settings.json", str(target) + ) + shim = """ +chown() { + printf '%s\\n' "$*" >> "$UG_TEST_CHOWN_LOG" +} +""" + script = script.replace('\ncase "$mode" in', shim + '\ncase "$mode" in') + result = subprocess.run( + [ + "/bin/sh", + "-c", + script, + "policy-test", + "once", + managed_files.current_os().value, + str(source), + str(target), + ], + capture_output=True, + text=True, + timeout=10, + env={ + **os.environ, + "SUDO_UID": str(os.getuid()), + "UG_TEST_CHOWN_LOG": str(ownership_log), + }, + ) + assert result.returncode == 0, result.stderr + assert target.read_text() == source.read_text() + assert target.stat().st_mode & 0o777 == 0o644 + assert parent.stat().st_mode & 0o777 == 0o755 + assert f"0:0 {parent}" in ownership_log.read_text() + assert len(ownership_log.read_text().splitlines()) >= 2 + + @pytest.mark.parametrize("changed", [False, True]) + def test_real_shell_removes_only_matching_workspace_policy(self, tmp_path, changed): + source = tmp_path / "expected.json" + source.write_text("old policy") + target = tmp_path / "managed-settings.json" + target.write_text("new policy" if changed else "old policy") + script = managed_files._SUDO_REPLACE_SCRIPT.replace( + "/etc/unitygateway/managed-settings.json", str(target) + ) + result = subprocess.run( + [ + "/bin/sh", + "-c", + script, + "policy-test", + "remove", + managed_files.current_os().value, + str(source), + str(target), + ], + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "SUDO_UID": str(os.getuid())}, + ) + if changed: + assert result.returncode != 0 + assert target.read_text() == "new policy" + else: + assert result.returncode == 0, result.stderr + assert not target.exists() + @pytest.mark.parametrize("failed_step", ["", "metadata", "content", "rename"]) def test_real_shell_preserves_file_on_failed_step(self, tmp_path, failed_step): """Exercise the actual shell protocol without sudo, using only temporary files.""" From 183dcda2b1217aa22f1730a835c1bfaf7b0916c3 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:01:25 +0000 Subject: [PATCH 2/8] Read and enforce the installed workspace policy at launch Launches read the policy installed at /etc/unitygateway/managed-settings.json, refuse a selection for another workspace, and refresh at most every five minutes using user-owned stamps in ~/.ucode/refresh_ts.json. Non-root launches never install or remove the policy. The per-user cache remains only for Windows, UG_SKIP_OS_SETTINGS, and --user. Configure restores the saved workspace on a denied install, and other agents refuse early on a machine managed elsewhere. Also adds ug export, dry-run workspace adoption, and the CUJ recorder and harness reading the installed policy. Co-authored-by: Isaac --- README.md | 14 +- docs/managed-workspace-policy.md | 148 ++- specs/features/machine-managed-policy.md | 186 ++++ src/ucode/cli.py | 85 +- src/ucode/managed_config.py | 473 ++++++---- src/ucode/managed_export.py | 6 +- src/ucode/managed_resolve.py | 2 +- src/ucode/managed_setup.py | 2 +- tests/README.md | 27 +- tests/conftest.py | 26 +- tests/e2e_cuj/helpers/tui_request_recorder.py | 52 +- tests/e2e_cuj/test_cuj1_explicit_models.py | 9 +- tests/e2e_cuj/test_cuj2_mps_mcp.py | 9 +- tests/integration/README.md | 19 +- .../test_ug_codex_managed_model_discovery.py | 50 +- .../integration/test_ug_configure_managed.py | 106 ++- tests/integration/utils/managed.py | 4 +- tests/test_cli.py | 209 +++++ tests/test_managed_config.py | 848 ++++++++++++++---- tests/test_tui_request_recorder.py | 65 +- 20 files changed, 1816 insertions(+), 524 deletions(-) create mode 100644 specs/features/machine-managed-policy.md diff --git a/README.md b/README.md index db55532e5..18dab4899 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,19 @@ preferences are ignored and their files are left untouched. See the bundled | GitHub Copilot CLI | `~/.copilot/ucode.env`, `~/.copilot/ucode-mcp-config.json` | | Pi | `~/.ucode/pi-home/.pi/agent/models.json`, `~/.ucode/pi-home/.pi/agent/settings.json` | | Cursor Agent | `~/.cursor/mcp.json` | -| Unity Gateway | `~/.ucode/managed-state.json`, `~/.ucode/managed-backups/` | +| Unity Gateway | `/etc/unitygateway/managed-settings.json` (Linux and macOS), `~/.ucode/refresh_ts.json`, `~/.ucode/managed-state.json`, `~/.ucode/managed-backups/` | + +Workspace policy is root-owned but readable without sudo. Configure installs it only when the +workspace publishes a policy; launches check for updates every five minutes. Non-root launches +never invoke sudo for workspace policy: they warn about changes and keep the installed policy +until an administrator or IT provisioning run updates it. Each distinct pending change warns once. +The installed workspace is authoritative: selecting a different one fails launch instead of +falling back to unmanaged behavior. +Refresh timestamps and pending-change hashes live in user-owned `~/.ucode/refresh_ts.json`, +not in the protected policy file. +This policy is separate from agent-file backups and is not removed by `ug revert`. +See [managed workspace policy](docs/managed-workspace-policy.md) for permissions and trade-offs. +Managed policy headers must not contain secrets: the policy is readable by all local users. ### Codex desktop tracing diff --git a/docs/managed-workspace-policy.md b/docs/managed-workspace-policy.md index e95fc3e96..c11284294 100644 --- a/docs/managed-workspace-policy.md +++ b/docs/managed-workspace-policy.md @@ -1,10 +1,10 @@ # Managed workspace policy `ug configure` reads the workspace's published `CodingAgentConfig` through -`GET /api/ai-gateway/v2/coding-agent-configs` and installs it machine-wide at -`/etc/unitygateway/managed-settings.json` on Linux and macOS. +`GET /api/ai-gateway/v2/coding-agent-configs` and installs it at +`/etc/unitygateway/managed-settings.json` on Linux/macOS. -The installed file contains only: +The file contains only: ```json { @@ -13,39 +13,109 @@ The installed file contains only: } ``` -`config` is the raw remote configuration, not normalized agent settings. The empty object above -only illustrates the shape: a workspace without a published policy does not leave a policy file. -The directory is root-owned `0755` and the file is root-owned `0644`, so ordinary users can read it -without sudo. Authentication tokens and profiles stay in user-owned state. - -**Never put secrets in managed policy headers.** The policy is readable by every local user, and -ug does not redact raw fields. Do not put passwords, bearer tokens, API keys, or session cookies in -`http_headers` or anywhere else in the configuration. Routing headers such as a workspace ID are -appropriate. - -## Configure - -- `ug configure` forces a fresh read of the workspace policy, so it never installs a stale one. -- Changed policy is written with sudo from a terminal. Where sudo cannot prompt (for example in a - script), ug uses non-interactive sudo and fails if a password would be needed. -- Root-run configure installs directly, without sudo. -- Unchanged policy writes nothing. A re-read that differs only in `retrieved_time` is not a change. -- A workspace that publishes no policy, or has the feature disabled, removes the installed file. - Configure decides the machine's workspace, so this also removes a policy installed for a different - workspace. -- If the installation or removal fails, configure stops with an error that says the machine-wide - file needs sudo. Rerun `ug configure` from a terminal where you can use sudo, or ask your IT team - or an administrator to run it. -- Windows has no machine-wide policy file. `UG_SKIP_OS_SETTINGS=1` and `ug configure --user` also - skip it, so no file is written. - -Launches never install or remove the machine-wide policy. Per-user caching of the fetched policy -(`~/.ucode/managed-config.json`) is unchanged by this write path. - -## Limits - -- The policy describes one workspace for the whole machine. Configuring another workspace replaces it - for all users. -- Installation needs sudo on Linux and macOS. On MDM machines, ask IT to run the provisioning step - rather than attempting administrator-only configure yourself. -- Live sudo and ownership checks need a disposable clean machine, not a developer workstation. +`config` is the raw remote configuration, not normalized agent settings. The empty object +above illustrates the shape only: a workspace without policy does not create a policy file. +The installed directory is root-owned `0755`; the policy is root-owned `0644`, so ordinary +users can read without sudo. Authentication tokens and profiles remain in user-owned state. +Administrator-authored policy fields, including configured headers, are visible to all local users. + +**Never put secrets in managed policy headers.** The current schema accepts arbitrary header +strings; ug cannot establish that a value is safe to publish. Do not put passwords, bearer tokens, +API keys, or session cookies in `http_headers` (including `Authorization`, `Cookie`, or secret +custom headers). Use per-user authentication helpers/credential storage instead. Routing headers +such as a workspace ID are appropriate. This requirement applies to the entire world-readable raw +configuration, not just headers; ug preserves raw fields rather than silently redacting policy. + +## Configure and launch + +- Configure forces a remote check and installs changed policy before using it. A privileged + installation failure is an actionable configure error. +- Launch reads installed policy and reuses a successful check for five minutes. Once expired, + it checks remote policy; indirect agent checks reuse the same timestamp. +- The installed policy's workspace is authoritative for launch and remote refresh. A different + workspace selected in user state or with `--workspace` causes a launch error before authentication, + bootstrap, cache reuse, or any remote request. This also applies to dry-run and `--skip-preflight`. + Without a user-selected workspace, launch uses the installed workspace. Explicit administrator + configure can replace the machine workspace; launch cannot switch it. +- Unchanged policy does not invoke sudo or touch installed bytes/metadata. +- Non-root launches never install or remove workspace policy, even in a terminal or with cached + sudo access. They do not run `sudo -n -l`, start a sudo worker, or request a password. + This avoids elevation attempts on MDM machines; IT/root provisioning owns the update. +- Explicit configure may request sudo in a terminal or use `sudo -n` in scripts. + Root-run configure/refresh installs directly without sudo. +- A deferred launch update warns: “Your workspace policy changed. Ask your IT team or an + administrator to apply the update on this machine.” + Launch continues with the matching installed policy. Without one, the fetched policy applies to + that run, but only if there is no installed policy for another workspace; a workspace mismatch is an + error, not unmanaged fallback. Checks remain throttled for five minutes, but the warning is shown + only once per distinct pending policy change, not at every expired check. +- No-config or feature-disabled removes a matching installed policy during configure or a root + refresh. A non-root launch warns and retains it. +- `ug configure` sets the machine's workspace: configuring a workspace that publishes no policy + also removes another workspace's installed policy. A launch never changes the machine's + workspace; selecting another workspace is refused. +- Fetch/authentication failures retain existing local fallback and do not advance the timestamp. +- Launch dry-run is local/offline. Configure dry-run may fetch to preview but does not install. + +The one file holds one active workspace for the machine. Installing another workspace replaces +that policy for all users. Workspace URLs are canonicalized; a mismatched selection is refused, +not used to bypass machine policy. Agent-specific Claude/Codex OS-managed settings remain separate; their existing +conflict restrictions can still prevent launch even when workspace-policy refresh falls back. + +## Refresh metadata + +`~/.ucode/refresh_ts.json` is user-owned `0600`, keyed by canonical workspace URL. Each entry has +`last_checked_at`, `outcome` (`published`, `none`, or `feature_disabled`), and the installed-file +fingerprint. Deferred updates also record `pending_config_hash`, a SHA-256 digest of the canonical +workspace and fetched configuration, excluding `retrieved_time` and insensitive to JSON object +key order. No second policy copy is stored. + +The pending hash suppresses repeat change warnings across TTL expiry, including pending policy +removal. A different pending config warns again. A successful installation/removal or a fetched +config matching the installed policy clears the hash. External MDM updates invalidate the file +fingerprint, so a user's next launch reconciles its own sidecar even if root used a different home. +Failed authentication/API reads preserve the pending hash and last successful check time. +Metadata is best-effort; deleting/corrupting the sidecar, failed writes, or concurrent launches can +cause a repeated warning. + +An inode or mtime indicates a local write, not the last successful unchanged remote check. +The sidecar allows throttling without updating the root-owned file. Its fingerprint (existence, +device, inode, size, mtime, ctime) invalidates a warm check after local replacement/edit. +These timestamps are an optimization, not a tamper-proof enforcement mechanism. + +The API's `retrieved_time` changes on reads and is excluded from drift comparison. Its stored +value may therefore predate the latest check; use `last_checked_at` for refresh information. +All other raw fields participate in comparison. Policy is otherwise preserved when installed. + +## Migration and limits + +On Linux and macOS, the per-user `~/.ucode/managed-config.json` is no longer read and is never +promoted to a root-owned policy. +Run `ug configure --workspace YOUR_WORKSPACE` to retrieve and install authoritative policy. +No policy is written when the workspace has none. On MDM machines, ask IT to run root provisioning +instead of attempting administrator-only configure yourself. + +The five-minute TTL avoids every-launch request latency but delays detection of remote changes. +Warning/fallback prioritizes availability over strict enforcement of the newest policy. +Privileged writes are supported on Linux/macOS only. Real sudo/password and ownership integration +checks require a disposable clean machine, not an existing developer-managed workstation. + +Where the machine cannot hold the policy (Windows, `UG_SKIP_OS_SETTINGS=1`, or `ug configure +--user`) and none is installed for the workspace, ug keeps its earlier behavior unchanged: the +fetched policy is cached per user in `~/.ucode/managed-config.json`, a launch reuses it for five +minutes, and a failed fetch falls back to it. Windows support for the installed policy is a +follow-up. A non-root Linux or macOS launch with nothing installed applies the fetched policy to +that run without installing or caching it, so a first launch before any `ug configure` is still +managed. An installed policy still wins: with `UG_SKIP_OS_SETTINGS=1` +a developer's launch reads it read-only, never writes OS-managed files, and builds only user-owned +settings. That is the supported developer mode on an MDM machine whose administrator ran `ug configure`. + +There is no refresh-interval setting in the currently supported `CodingAgentConfig` shape. +The five-minute launch-check default is retained; the installed workspace selects the remote source. + +For fleet-managed installations, schedule root/MDM provisioning about every **four hours** +(`14400` seconds) rather than once daily. This bounds the usual stale-policy window without requiring +developer elevation; offline/sleeping machines can still lag. This is separate from the five-minute +developer check interval. The PR's baseline has no MDM scheduler, and current main's +`scripts/mdm-bootstrap.sh` is a one-shot bootstrap with no 24-hour refresh default. The deployment's +actual scheduler must set the four-hour interval; this change does not invent a background daemon. diff --git a/specs/features/machine-managed-policy.md b/specs/features/machine-managed-policy.md new file mode 100644 index 000000000..bc766f769 --- /dev/null +++ b/specs/features/machine-managed-policy.md @@ -0,0 +1,186 @@ +--- +service: unity-gateway +created: 2026-10-12 +--- + +# Install and Refresh Machine-Managed Workspace Policy + +## Intent + +Install workspace policy in `/etc/unitygateway/managed-settings.json` during `ug configure` and read it during agent launches. Preserve five-minute launch refreshes without rewriting the privileged file or requesting sudo when policy content is unchanged. + +## Context + +Previously, `managed_config.py` stored the remote `CodingAgentConfig` in `~/.ucode/managed-config.json` alongside `workspace`, `retrieved_at`, and `outcome`. Configure forced a refresh; launches and indirect agent checks reused that wrapper for five minutes. + +The existing privileged writer atomically installs allowlisted Claude/Codex settings. Extend it for Unity Gateway policy without changing agent settings metadata behavior. Agent-specific OS-managed files remain separate from the workspace policy. + +## Requirements + +- R1: Explicit workspace configuration fetches policy through the existing authenticated `GET /api/ai-gateway/v2/coding-agent-configs`. +- R2: Install only `{workspace, config}`, preserving the raw remote configuration under `config`; no refresh timestamps or outcomes in the protected wrapper. +- R3: Bare `ug`, `ug `, and indirect agent checks reuse fresh refresh metadata and check remote policy after the five-minute TTL. +- R4: The installed machine file is the sole persisted policy source; the legacy home cache is neither read nor automatically migrated. +- R5: Canonical workspace URLs determine identity. The installed workspace is authoritative for launches and refresh; reject a different selected workspace instead of falling back to unmanaged behavior. +- R6: Install atomically with root-owned `0755` directory and root-owned `0644` file; reject unsafe targets and add no authentication data. +- R7: Failed privileged configure writes raise actionable errors. Failed launch writes warn and continue with the matching installed policy. +- R8: No-config or feature-disabled removes only a matching installed policy. If none exists, do not create a file or directory to represent absence. +- R9: Retain existing forgiving absent/malformed-policy reads and fetch-failure fallback. Unsupported remote spec versions retain existing diagnostics. +- R10: Preserve resolution and agent-specific rendering; new policy refresh fallback does not bypass independent agent OS-managed-file conflicts. +- R11: Dry-run configuration previews required policy changes without sudo or disk changes; launch dry-run is local and offline. +- R12: Export and local status use installed policy; explicitly requested live status may query remote policy without installing it. +- R13: Store workspace-keyed check timestamps, outcomes, and installed-file fingerprints in `~/.ucode/refresh_ts.json`, mode `0600`, without a second policy copy. +- R14: Compare workspace and raw config excluding only volatile API `retrieved_time`; unchanged reads never rewrite protected policy. +- R15: Only explicit configure may invoke sudo (interactive prompt or scripted `sudo -n`); root provisioning needs no sudo. Non-root launch policy writes/removals are deferred without even a sudo capability probe. +- R16: Deferred launch updates direct users to IT/an administrator, not to a configure command they cannot run. +- R17: Document that the arbitrary-string header schema does not prohibit secrets and that the entire readable raw policy must contain no secret values. +- R18: Warn once per distinct pending policy change across TTL expiry, storing only its hash in `refresh_ts.json`; clear the hash on reconciliation. +- R19: Validate launch workspace before cache reuse, authentication, or bootstrap, including dry-run and skip-preflight. Use the installed workspace when none is selected; only explicit administrator configure may replace it. + +## Clarifications + +- Q1: Which platforms are supported? + A1: Linux and macOS use `/etc/unitygateway/managed-settings.json`. Unsupported-platform writes fail explicitly; do not introduce a user-cache policy fallback. +- Q2: How many workspaces does the machine file hold? + A2: One active workspace. Explicit administrator configure can replace it for all users. Launch refuses a mismatched workspace and never uses unmanaged fallback to escape the installed policy. +- Q3: What if sudo is unavailable? + A3: Configure installation fails. Launch installation warns and retains the old matching policy; if none is installed, the fetched policy applies to that run without installing it. +- Q4: Why not use the installed file's inode timestamps for refresh throttling? + A4: Those timestamps describe local writes, not a successful unchanged remote check. Keep one user-owned check timestamp plus a fingerprint to invalidate stale checks after another process changes installed policy. +- Q5: What permissions apply? + A5: Root-owned policy `0644`, directory `0755`; ordinary users need no sudo to read. Refresh metadata is user-owned `0600`. +- Q6: Why retain refresh outcomes? + A6: Distinguish published, absent, and feature-disabled reads during the TTL without empty policy tombstones or duplicate content. Failed launch updates cache the effective installed fallback, delaying reattempt until TTL expiry. +- Q7: Is the local config identical to the latest remote response? + A7: `config` preserves raw fields when installed. Only API `retrieved_time` is ignored for drift comparison, so that informational field may remain from an earlier read until actual policy changes. +- Q8: Does Bill's review request policy-controlled refresh intervals? + A8: The user confirmed that the installed workspace should select the refresh source, with mismatched launch workspaces refused. Keep the five-minute TTL; the supported schema has no interval field. +- Q9: Where is the deployment's 24-hour MDM refresh default? + A9: UNRESOLVED. No scheduler exists in this PR's baseline; freshly fetched main has a one-shot `scripts/mdm-bootstrap.sh` with no refresh interval. Recommend four hours (`14400` seconds) in deployment scheduling, but the actual scheduler location is needed to change its default. + +## Acceptance Criteria + +- AC1: GIVEN published policy WHEN configure succeeds THEN the API is queried and the installed wrapper contains its canonical workspace and raw configuration. +- AC2: GIVEN a expired check and unchanged policy WHEN an agent launches THEN only user refresh metadata advances; installed bytes, inode, mtime, and ctime stay unchanged and no sudo occurs. +- AC3: GIVEN a fresh matching check WHEN bare `ug` launches THEN it reads installed policy without another policy API request. +- AC4: GIVEN configure already refreshed policy WHEN Claude or AI Tools checks it again THEN the shared refresh metadata prevents a duplicate API request or privileged write. +- AC5: GIVEN only legacy home policy WHEN a launch refresh cannot install policy THEN the legacy copy is not read or promoted. +- AC6: GIVEN installed machine policy WHEN launch selects a foreign workspace THEN launch fails before cache/auth/bootstrap, without requests or policy/state writes; dry-run and skip-preflight do not bypass the guard. +- AC7: GIVEN matching installed policy WHEN no-config or feature-disabled is fetched and removal succeeds THEN the file is removed and the outcome is cached only in refresh metadata. GIVEN another workspace's installed policy WHEN `ug configure` selects a workspace without policy THEN that policy is removed; a launch never removes it. +- AC8: GIVEN installed policy WHEN privileged replacement fails THEN bytes remain intact; configure errors and launch warns while returning the installed policy. +- AC9: GIVEN failed API/authentication reads WHEN launching THEN existing matching-policy fallback and unsupported-version diagnostics remain. +- AC10: GIVEN root-owned readable policy and a fresh check WHEN an ordinary user launches THEN no sudo is needed to read it. +- AC11: GIVEN dry-run configuration WHEN policy needs installing THEN preview occurs without writes or sudo; GIVEN launch dry-run WHEN invoked THEN no policy API request occurs. +- AC12: GIVEN successful installation WHEN metadata is inspected THEN directory is root-owned `0755`, file root-owned `0644`, and refresh metadata `0600`. +- AC13: GIVEN symlinked or unsafe targets WHEN installing THEN replacement is refused. +- AC14: GIVEN valid installed policy WHEN export or local status runs THEN the installed file is the policy source. +- AC15: GIVEN changed policy during launch setup WHEN a non-root user launches THEN no sudo probe, password prompt, or protected write/removal occurs; IT guidance and installed fallback remain. GIVEN root refresh THEN installation proceeds directly without sudo. +- AC16: GIVEN remote changes only to `retrieved_time` WHEN TTL expires THEN no protected write or sudo occurs. +- AC17: GIVEN another process replaces or edits installed policy WHEN a still-warm refresh runs THEN its fingerprint invalidates the check. +- AC18: GIVEN workspace without policy and no matching installed file WHEN refreshed THEN no protected file or directory is created. +- AC19: GIVEN deferred launch update WHEN immediately relaunched THEN the recorded check throttles another API request/warning within five minutes. +- AC20: GIVEN explicit configure WHEN changed policy requires privilege THEN existing interactive/scripted sudo behavior remains available. +- AC21: GIVEN a deferred policy change WHEN repeated launch checks expire THEN only the first check warns and all checks retain installed policy; a distinct fetched policy warns again. +- AC22: GIVEN a pending hash WHEN retrieval metadata or JSON object key order alone changes THEN no new change warning occurs. +- AC23: GIVEN a pending update/removal WHEN remote policy matches installed policy or root/MDM/configure reconciles it THEN the next successful check clears the hash. +- AC24: GIVEN installed policy and no selected workspace WHEN launch checks policy THEN the installed workspace is the remote source, while per-user authentication remains per-user. + +## Constraints + +- Fetching, normalization, policy persistence, and throttling remain in `managed_config.py`; orchestration stays in `cli.py`; privileged writing stays in `managed_files.py` and its shell worker. +- Extend fixed target allowlists narrowly. Preserve existing agent metadata and immutable-flag handling. +- Keep tokens/profiles in existing user state, never add them to the policy wrapper. +- Use existing Rich warning/error conventions and atomic JSON persistence. +- Unit tests isolate paths and privileged operations. Live integration requires a disposable clean machine with explicit credentials; never mutate this developer's real OS-managed files. + +## Out of Scope + +- Remote API changes, policy authoring, or publishing. +- Multiple simultaneous machine-workspace policies. +- Background refresh or complete tamper-proof enforcement. +- Replacing Claude/Codex OS-managed files or their independent conflict checks. +- Automatic legacy home-policy promotion. +- Mandatory launch failure on denied policy update. + +## Plan + +### Approach + +1. Change the authoritative policy path and persist only workspace/config. Normalize at consumption time; ignore only volatile retrieval metadata for comparison. +2. Extend the existing atomic privileged writer with fixed policy installation/removal, root ownership, readable modes, configure-only sudo, a no-elevation launch guard, and root support. +3. Move check metadata into the user-owned sidecar. Keep five-minute throttling, negative outcomes, fingerprint invalidation, and a pending-policy hash for once-per-change warnings without copying policy. +4. Force refresh in all explicit agent configuration variants. Keep launch and indirect agent refreshes; after one check they reuse the sidecar. +5. Handle changed installs and matching removals through the writer. Configure errors on failed writes; launches warn once per distinct change and use the installed fallback, throttling retries. Clear pending hashes after reconciliation. +6. Update export/recovery documentation and integration consumers. Validate temporary-path unit/component coverage and integration contracts; report live sudo coverage as unrun without a clean VM. +7. Use the installed workspace as launch/refresh authority. Refuse mismatched state/flags before cache/auth/bootstrap; allow explicit administrator configure to switch the machine workspace. + +### Affected Components + +- `src/ucode/managed_config.py`: machine path, raw wrapper comparison, user timestamp cache, effective-policy fallback. +- `src/ucode/managed_files.py`, `managed_replace.sh`: narrowly allowlisted atomic installation/removal and ownership/modes. +- `src/ucode/cli.py`: consistent explicit-agent configure refresh sequencing. +- `managed_setup.py`, `managed_resolve.py`, `managed_export.py`: source-path documentation and recovery guidance. +- Unit/component tests and integration journeys/helpers/runner: separate policy/refresh paths, unchanged-file evidence, clean-machine prerequisite. + +### Data Model Changes + +Machine policy is `{workspace, config}`. User refresh metadata maps each canonical workspace to `{last_checked_at, outcome, policy_fingerprint}` plus optional `pending_config_hash` for deferred updates/removals. The SHA-256 hash covers workspace and raw policy excluding `retrieved_time`, with sorted JSON keys. Fingerprint contains existence, device, inode, size, mtime, and ctime. No second raw/normalized policy copy is introduced. + +### API Changes + +None. Continue using the existing coding-agent-configs GET. Internal policy save supports an interactive flag; privileged update supports install/remove and no-password script behavior. + +### Risks and Trade-offs + +- Root-writable versus root-readable: policy can be read without sudo, but all machine users can see its configuration metadata. Arbitrary-string headers are not schema-safe for secrets; all raw policy fields must contain no secrets. +- A single active machine workspace is intentional; launch selections must match it and fail rather than run unmanaged. Administrator configure switches affect other users. +- A user-owned timestamp avoids pointless privileged writes after unchanged checks but is mutable and not a security enforcement boundary. +- TTL avoids every-launch API latency but delays change detection by up to five minutes. Fingerprints detect local changes, not remote freshness. +- Warning/fallback favors availability over strict latest-policy enforcement. Denied updates/removals leave old policy active; retries are throttled for five minutes and warnings deduplicated across expiry by pending hash. +- Non-root launch writes are always deferred, even for administrators with cached sudo; this deliberately favors MDM ownership and zero elevation attempts over automatic privileged launch repair. Configure retains interactive sudo and scripted `sudo -n`; root refresh writes directly. +- Ignoring `retrieved_time` avoids false drift but means that field does not necessarily represent the latest check; use the sidecar instead. +- Deletion avoids empty tombstones; sidecar outcomes preserve absent/disabled semantics. +- Refresh metadata atomic writes do not serialize simultaneous workspace checks; a lost timestamp/hash entry can cause an extra check/warning, not an alternate policy copy. +- Legacy home policy is intentionally not trusted/promoted; configure must retrieve/install authoritative workspace policy. +- Existing forgiving readers remain, and this is not a fail-closed enforcement redesign. +- Packaged-writer root ownership and non-root TTY/script deferral are checked in a disposable Linux container. Actual configure password interaction and full MDM/agent provisioning still require separately selected credentials and machine policy. + +## Tasks + +### Phase 1 — Policy Storage + +- [x] T1: Resolve platforms, workspace scope, privilege behavior, TTL, and launch fallback. +- [x] T2: Introduce machine policy and workspace-aware raw/normalized readers. +- [x] T3: Compare raw policy excluding volatile retrieval metadata. +- [x] T4: Update persistence and legacy-cache tests. + +### Phase 2 — Privileged Installation + +- [x] T5: Extend both fixed privileged allowlists. +- [x] T6: Add atomic installation, enforced policy modes, and dry-run behavior. +- [x] T7: Refresh all explicit agent configuration variants. +- [x] T8: Remove matching policy for absent/disabled responses and preserve failed writes. + +### Phase 3 — Throttled Launch Refresh + +- [x] T9: Add workspace-keyed user-owned refresh metadata. +- [x] T10: Preserve five-minute launch throttling and local-only launch dry-run. +- [x] T11: Share throttling with indirect Claude/AI Tools refresh checks. +- [x] T12: Cover unchanged policy, retrieval-only drift, configure-only sudo, deferred launch fallback, no-probe guards, and fingerprint invalidation. + +### Phase 4 — Consumers and Documentation + +- [x] T13: Update installed-policy source and export recovery guidance. +- [x] T14: Update integration consumers and clean-machine runner checks. +- [x] T15: Document permissions, refresh lifecycle, single-workspace scope, and migration. + +### Phase 5 — Review Follow-ups + +- [x] T16: Hash pending workspace policy, suppress repeat warnings, and clear the hash on reconciliation. +- [x] T17: Guard all launch paths against workspace mismatch before setup and use installed workspace for remote refresh. +- [x] T18: Add unit regressions and a black-box foreign-workspace rejection journey; document the four-hour MDM scheduling recommendation. +- [ ] T19: Change the actual MDM refresh default to four hours once its scheduler location is supplied; no such default exists in this PR or fetched main. + +### Validation + +- [x] T-val: Rerun focused tests, integration contracts, lint, formatting, types, and root/non-root container component checks after review fixes. Focused checks: 1009 passed; real container root ownership/modes, root/non-root persistence, and installed-CLI workspace rejection checks passed. Managed integration file: 5 cases collected, not a live pass. Prior full suite: 2783 passed, 44 skipped, 9 unrelated/environment failures (documented in the PR). Full live MDM/agent provisioning remains pending explicitly selected workspace credentials; the MDM scheduler source is still needed for T19. diff --git a/src/ucode/cli.py b/src/ucode/cli.py index e237c13e8..715054baa 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -96,9 +96,12 @@ from ucode.managed_config import ( AGENT_ENUM_TO_TOOL, ManagedConfigResult, + ManagedPolicyInstallDenied, get_managed_config, get_model_recommendation, load_managed_state, + managed_launch_workspace, + managed_state_workspace, normalize_managed_config, refresh_managed_config, ) @@ -798,6 +801,72 @@ def _configure_shared_workspace_states( return states +def _refresh_configured_policy(state: dict) -> ManagedConfigResult: + """Read and install the workspace policy for an explicit configure. + + Configure saves the selected workspace before the policy step. When that step cannot switch the + machine (no administrator access), the machine stays managed for its installed workspace, so put + the saved selection back on it: launches keep working there, and the error explains the switch. + """ + try: + return refresh_managed_config(state, force_refresh=True) + except ManagedPolicyInstallDenied: + _keep_installed_workspace_selected(state) + raise + + +def _keep_installed_workspace_selected(state: dict) -> str | None: + """Put the saved selection back on the machine's installed workspace when it differs. + + Returns that installed workspace, or None when the selection already matches or none exists. + """ + installed = managed_state_workspace() + if not installed or normalize_workspace_url(installed) == normalize_workspace_url( + state["workspace"] + ): + return None + set_current_workspace(normalize_workspace_url(installed)) + return installed + + +def _read_policy_for_other_agent(state: dict) -> ManagedConfigResult: + """Read the policy for an agent other than Claude and Codex, which never installs it. + + Such a configure cannot switch the machine's workspace, so on a machine managed for another + workspace it keeps the saved selection there and fails before writing the agent's config. + """ + installed = _keep_installed_workspace_selected(state) + if installed: + raise RuntimeError( + f"This machine is managed for {installed}, so it cannot be configured for " + f"{state['workspace']}. Run `ug configure --workspace {state['workspace']}` with sudo " + "to switch the machine, or ask your IT team or an administrator." + ) + return refresh_managed_config(state) + + +def _launch_workspace_state(workspace: str | None) -> dict: + """Load launch state on the machine's installed workspace, refusing any other selection. + + Adopts the installed workspace when none is saved, so a fresh user launches it directly. + """ + flag_workspace = managed_launch_workspace(workspace) if workspace else None + if flag_workspace: + set_current_workspace(flag_workspace) + state = load_state() + if flag_workspace and state.get("workspace") != flag_workspace: + # A dry run saves nothing, so carry the requested workspace for this run. + state = {**state, "workspace": flag_workspace} + current = managed_launch_workspace(state.get("workspace")) + if current and not state.get("workspace"): + set_current_workspace(current) + state = load_state() + if not state.get("workspace"): + # A dry run saves nothing, so carry the adopted workspace for this run. + state = {**state, "workspace": current} + return state + + def _setup_single_agent( workspace_entries: list[tuple[str, str | None]], tool: str, @@ -827,14 +896,16 @@ def _setup_single_agent( parent_schema = None managed = None if apply_managed and tool in ("claude", "codex"): - managed, _ = refresh_managed_config(state, force_refresh=True) + # Explicit configure always reads the workspace policy fresh, so it can install it. + managed, _ = _refresh_configured_policy(state) _reject_disabled_agent(managed, tool) if managed is not None: state = resolve_state(managed, state, tool) if not managed_provider_service(managed, tool): parent_schema = managed_unity_catalog_location(managed, tool) elif apply_managed: - managed, _ = refresh_managed_config(state, force_refresh=False) + # Other agents only read the policy; they never install it. + managed, _ = _read_policy_for_other_agent(state) state = configure_single_tool(tool, state, parent_schema=parent_schema) if apply_managed and not managed_enabled_tools(managed or {}): state = _record_unmanaged_configured_agents(state, [tool]) @@ -1046,7 +1117,7 @@ def _configure_workspace_command( # A published managed config means the admin dictates the setup: apply it to every enabled agent # now rather than prompting the developer to pick. Configure always reads fresh so it never # applies a config the admin has since changed. - managed, _ = refresh_managed_config(state, force_refresh=True) + managed, _ = _refresh_configured_policy(state) managed_tools = managed_enabled_tools(managed) if managed is not None else [] if managed is not None and managed_tools: refuse_user_scope_over_managed_files(managed_tools) @@ -3222,9 +3293,7 @@ def _launch_tool( # An explicit --workspace targets that workspace for this launch (and # auto-configures it if unseen), so `ug claude --provider ... --workspace ...` # works without a prior `ug configure`. - if workspace_url: - set_current_workspace(normalize_workspace_url(workspace_url)) - existing = load_state() + existing = _launch_workspace_state(workspace_url) # Workspaces configured with --use-pat export the profile's PAT as # DATABRICKS_BEARER up front so every auth check below (and the # launched agent itself) uses the static token instead of OAuth. @@ -3769,9 +3838,7 @@ def _launch_managed_default( workspace: str | None, ) -> None: """Route bare ``ucode`` by whether the workspace publishes a managed config.""" - if workspace: - set_current_workspace(normalize_workspace_url(workspace)) - state = load_state() + state = _launch_workspace_state(workspace) current = state.get("workspace") if not current: console.print(ctx.get_help()) diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 65727235c..9c13d5ac6 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -2,19 +2,18 @@ An org admin defines a ``CodingAgentConfig`` on the Databricks AI Gateway; developers read it (non-admin) and ``ucode`` applies it locally. This module owns the fetch/normalize side and the one -local file, ``~/.ucode/managed-config.json`` (0600), used on the launch path: +machine-wide file, ``/etc/unitygateway/managed-settings.json`` (root-owned, 0644): - fetching the raw manifest (via :func:`ucode.databricks.fetch_managed_coding_agent_configs`), - normalizing the proto-JSON into a stable internal dict keyed by ucode's own tool names, - persisting it via :func:`save_managed_state` / :func:`load_managed_state` — the launch path pulls - the published copy into this file, stamped with a ``retrieved_at`` and its outcome, and + the published copy into this file only when its workspace or configuration changes, and - re-reading it on each launch (reusing a read younger than :data:`MANAGED_CONFIG_TTL` rather than re-fetching), falling back to the persisted copy when the read fails. -An explicit ``ug configure`` also installs the workspace policy machine-wide at -``/etc/unitygateway/managed-settings.json`` (Linux and macOS) through -:func:`ucode.managed_files.update_workspace_policy`. Launches never write that file; they read and -refresh only the per-user cache above. +Refresh timestamps live separately in ``~/.ucode/refresh_ts.json`` (0600), keyed by workspace. +Unchanged remote reads never rewrite the protected policy. Non-root launches never invoke sudo; +they retain installed policy and request an administrator update when remote policy changes. :func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery, because the manifest decides whether that discovery is needed at all; the launch path then hands the @@ -30,6 +29,7 @@ import re from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from hashlib import sha256 from pathlib import Path from typing import NamedTuple, cast @@ -41,17 +41,22 @@ ) from ucode.managed_files import ( OS, + SKIP_OS_SETTINGS_ENV, UNITY_GATEWAY_MANAGED_SETTINGS_PATH, current_os, + managed_file_fingerprint, managed_settings_skipped, update_workspace_policy, ) +from ucode.os_compatibility.system_cross_os import is_admin from ucode.time_utils import parse_update_time from ucode.ui import console, normalize_workspace_url, print_warning -MANAGED_CONFIG_PATH = config_io.APP_DIR / "managed-config.json" -# The machine-wide workspace policy that explicit ``ug configure`` installs (Linux and macOS only). -MACHINE_POLICY_PATH = UNITY_GATEWAY_MANAGED_SETTINGS_PATH +MANAGED_CONFIG_PATH = UNITY_GATEWAY_MANAGED_SETTINGS_PATH +# Per-user policy cache for platforms or modes without machine installation (Windows, +# UG_SKIP_OS_SETTINGS, `--user` scope). +USER_MANAGED_CONFIG_PATH = config_io.APP_DIR / "managed-config.json" +REFRESH_TS_PATH = config_io.APP_DIR / "refresh_ts.json" # Shown to a developer when their workspace has no admin-defined managed config yet — the normal # case, not an error. Kept here so the CLI (which surfaces it) uses one consistent message. @@ -81,9 +86,6 @@ # hitting the control plane every launch; `ug configure` forces a fresh read (force_refresh=True). MANAGED_CONFIG_TTL = timedelta(minutes=5) -# The last authoritative read's outcome, persisted alongside the config so a cached wrapper can be -# replayed without a GET. "none" and "feature_disabled" both persist an empty config, so the outcome -# is what tells them apart. _OUTCOME_PUBLISHED = "published" _OUTCOME_NONE = "none" _OUTCOME_FEATURE_DISABLED = "feature_disabled" @@ -390,7 +392,7 @@ class FetchedManagedConfig(NamedTuple): class ManagedPolicyInstallDenied(RuntimeError): - """``ug configure`` could not install, replace, or remove the machine-wide workspace policy.""" + """``ug configure`` could not install, replace, or remove the machine policy (e.g. no sudo).""" class ManagedConfigResult(NamedTuple): @@ -647,104 +649,61 @@ def _utcnow() -> datetime: return datetime.now(UTC) -def save_managed_state(workspace: str, config: dict, *, outcome: str | None = None) -> None: - """Persist the raw managed config to ``~/.ucode/managed-config.json`` at mode 0600. - - ``config`` is stored verbatim as the gateway returned it (byte-identical to the GET), so the file - is inspectable and ``ug export`` can dump it unchanged; normalization into ucode's internal shape - happens on read (:func:`load_managed_state`), not here. The file is org-authored, not - developer-editable — 0600 keeps it readable/writable only by the user. No-op in dry-run. - - An empty ``config`` records "this workspace has no managed config", which matters because the - file doubles as the fallback when a later read fails: without it, removing a config server-side - would leave the old one on disk to be reapplied after a transient outage. - - ``outcome``, when set, stamps the read time and its result (published / none / feature_disabled) - so a later launch can reuse this read within :data:`MANAGED_CONFIG_TTL` without a GET. Only an - authoritative read passes it; a failed refresh persists nothing and so never advances the stamp. - """ - payload: dict = {"workspace": workspace, "config": config} - if outcome is not None: - payload["retrieved_at"] = _utcnow().isoformat() - payload["outcome"] = outcome - if config_io.is_dry_run(): - # Print rather than write, matching how the agent config writers behave under --dry-run. - console.print( - f"\n[bold]\\[dry run] {MANAGED_CONFIG_PATH}[/bold]\n{json.dumps(payload, indent=2)}\n" - ) - return - config_io.ensure_parent_dir(MANAGED_CONFIG_PATH) - try: - MANAGED_CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - except OSError as exc: - raise RuntimeError(f"Failed to write managed state file: {MANAGED_CONFIG_PATH}") from exc - _restrict_permissions(MANAGED_CONFIG_PATH) - - -def _machine_policy_installable() -> bool: - """Whether this run can hold the machine-wide policy: Linux or macOS, not opted out with - ``UG_SKIP_OS_SETTINGS`` or the ``user`` scope. Windows has no machine policy file.""" - return not managed_settings_skipped() and current_os() in (OS.LINUX, OS.MACOS) - - def _policy_content(config: dict) -> dict: - """The config compared for changes; ``retrieved_time`` changes on every read, so it is ignored.""" return {key: value for key, value in config.items() if key != "retrieved_time"} -def _install_machine_policy(workspace: str, config: dict) -> None: - """Make the machine policy match an explicit configure's result. +def _policy_hash(workspace: str, config: dict) -> str: + payload = {"workspace": normalize_workspace_url(workspace), "config": _policy_content(config)} + return sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - An empty ``config`` removes the installed policy, whichever workspace it was installed for: the - configured workspace now decides the machine's workspace. Otherwise the file is rewritten only - when its workspace or configuration differs. Raises RuntimeError when sudo is unavailable. + +def save_managed_state( + workspace: str, + config: dict, + *, + interactive: bool = False, + allow_sudo: bool = False, + sets_machine_workspace: bool = False, +) -> None: + """Install raw policy only when changed; remove a matching policy when it no longer exists. + + ``sets_machine_workspace`` marks an explicit ``ug configure``, which decides the machine's + workspace: a workspace that publishes no policy then also removes another workspace's + installed policy. A launch never does. """ + workspace = normalize_workspace_url(workspace) + existing = config_io.read_json_safe(MANAGED_CONFIG_PATH) + existing_workspace = existing.get("workspace") + matches = ( + isinstance(existing_workspace, str) + and bool(existing_workspace.strip()) + and normalize_workspace_url(existing_workspace) == workspace + ) if not config: - update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) + if matches or (sets_machine_workspace and existing_workspace): + update_workspace_policy( + MANAGED_CONFIG_PATH, None, interactive=interactive, allow_sudo=allow_sudo + ) return - workspace = normalize_workspace_url(workspace) - installed = config_io.read_json_safe(MACHINE_POLICY_PATH) - installed_workspace = installed.get("workspace") - installed_config = installed.get("config") + existing_config = existing.get("config") if ( - isinstance(installed_workspace, str) - and installed_workspace.strip() - and normalize_workspace_url(installed_workspace) == workspace - and isinstance(installed_config, dict) - and _policy_content(installed_config) == _policy_content(config) + matches + and isinstance(existing_config, dict) + and _policy_content(existing_config) == _policy_content(config) ): return payload = {"workspace": workspace, "config": config} update_workspace_policy( - MACHINE_POLICY_PATH, + MANAGED_CONFIG_PATH, json.dumps(payload, indent=2) + "\n", - interactive=True, - allow_sudo=True, + interactive=interactive, + allow_sudo=allow_sudo, ) -def _sync_machine_policy(workspace: str, config: dict, *, force_refresh: bool) -> None: - """Install or remove the machine policy for an explicit configure; launches never write it. - - A failed install raises :class:`ManagedPolicyInstallDenied`, so configure reports it instead of - implying the machine is managed for this workspace. - """ - if not force_refresh or not _machine_policy_installable(): - return - try: - _install_machine_policy(workspace, config) - except RuntimeError as exc: - message = ( - f"Could not install the workspace policy at {MACHINE_POLICY_PATH}: {exc} " - "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where you " - "can use sudo, or ask your IT team or an administrator to run it." - ) - raise ManagedPolicyInstallDenied(message) from exc - - def _restrict_permissions(path: Path) -> None: - """Best-effort chmod 0600. No-op where unsupported (e.g. Windows), where the effective - read-only guarantee is left to a later change.""" + """Best-effort chmod 0600 for user-owned refresh metadata.""" try: os.chmod(path, 0o600) except (OSError, NotImplementedError): @@ -775,78 +734,149 @@ def load_managed_configuration(workspace: str | None) -> dict | None: """ if not workspace: return None - data = config_io.read_json_safe(MANAGED_CONFIG_PATH) - if data.get("workspace") != workspace: + installed = _installed_config(workspace) + if installed is not None or _machine_policy_installable(): + return installed + return _stored_config(USER_MANAGED_CONFIG_PATH, workspace) + + +def _installed_config(workspace: str) -> dict | None: + """The installed machine policy for ``workspace``, read only where ug installs one. + + On Windows the POSIX path resolves under the current drive's root, which a standard user may be + able to create, so it is never trusted there. + """ + if not _machine_policy_supported(): + return None + return _stored_config(MANAGED_CONFIG_PATH, workspace) + + +def _stored_config(path: Path, workspace: str) -> dict | None: + """The raw config ``path`` stores for ``workspace``, or None if absent or for another one.""" + data = config_io.read_json_safe(path) + stored_workspace = data.get("workspace") + if ( + not isinstance(stored_workspace, str) + or not stored_workspace.strip() + or normalize_workspace_url(stored_workspace) != normalize_workspace_url(workspace) + ): return None config = data.get("config") return config if isinstance(config, dict) else None -def managed_state_workspace() -> str | None: - """The workspace the on-disk managed config was authored/pulled for, or None when there is none. +def managed_state_workspace(*, include_user_cache: bool = False) -> str | None: + """The workspace the installed machine policy belongs to, or None when there is none. - Lets a caller that has no workspace in local ucode state (e.g. ``ucode setup --show`` before - ``ucode configure``) still find the manifest on disk and report which workspace it belongs to. + Launches enforce this workspace. ``include_user_cache`` also accepts the per-user cache's + workspace where no machine policy is possible, for a caller with no workspace in local state + (e.g. ``ug export`` before ``ug configure``) that only needs to find a saved policy. """ - workspace = config_io.read_json_safe(MANAGED_CONFIG_PATH).get("workspace") - return workspace if isinstance(workspace, str) and workspace else None + paths = [MANAGED_CONFIG_PATH] if _machine_policy_supported() else [] + if include_user_cache and not _machine_policy_installable(): + paths.append(USER_MANAGED_CONFIG_PATH) + for path in paths: + workspace = config_io.read_json_safe(path).get("workspace") + if isinstance(workspace, str) and workspace: + return workspace + return None -def _cached_result_if_fresh(workspace: str) -> ManagedConfigResult | None: - """The persisted read for ``workspace`` replayed as a result, if still within the TTL. +def managed_launch_workspace(workspace: str | None) -> str | None: + """Resolve launch policy from the installed workspace, refusing a different selection.""" + installed = managed_state_workspace() + installed = normalize_workspace_url(installed) if installed and installed.strip() else None + selected = normalize_workspace_url(workspace) if workspace else None + if installed and selected and selected != installed: + raise RuntimeError( + f"This machine is managed for {installed}, but your selected workspace is {selected}. " + f"Launch refused. Use `--workspace {installed}`, or ask your IT team or an administrator " + f"to switch the machine with `ug configure --workspace {selected}`." + ) + return installed or selected - Returns None (forcing a fresh fetch) when the wrapper is for another workspace, predates this - cache format (no ``outcome`` / ``retrieved_at``), or its stamp is missing, unparseable, in the - future, or at least :data:`MANAGED_CONFIG_TTL` old. - """ - data = config_io.read_json_safe(MANAGED_CONFIG_PATH) - if data.get("workspace") != workspace: + +def _cached_result_if_fresh(workspace: str) -> ManagedConfigResult | None: + """Reuse a recent user-owned refresh stamp only while the installed policy is unchanged.""" + entry = config_io.read_json_safe(REFRESH_TS_PATH).get(normalize_workspace_url(workspace)) + if not isinstance(entry, dict): return None - # Reuses the RFC-3339 parser the update-time watermark uses; None (missing/unparseable) is stale. - retrieved_at = parse_update_time(_str(data.get("retrieved_at"))) - if retrieved_at is None: + checked_at = parse_update_time(_str(entry.get("last_checked_at"))) + if checked_at is None: return None - age = _utcnow() - retrieved_at + age = _utcnow() - checked_at if age < timedelta(0) or age >= MANAGED_CONFIG_TTL: return None - outcome = data.get("outcome") + if entry.get("policy_fingerprint") != managed_file_fingerprint(MANAGED_CONFIG_PATH): + return None + outcome = entry.get("outcome") + raw = load_managed_configuration(workspace) + if raw: + # A disabled feature whose installed policy couldn't be removed still reports disabled. + return ManagedConfigResult( + normalize_managed_config(raw), outcome == _OUTCOME_FEATURE_DISABLED + ) if outcome == _OUTCOME_FEATURE_DISABLED: return ManagedConfigResult(None, True) if outcome == _OUTCOME_NONE: return ManagedConfigResult(None, False) - if outcome == _OUTCOME_PUBLISHED and isinstance(data.get("config"), dict): - return ManagedConfigResult(normalize_managed_config(data["config"]), False) return None +def _record_refresh( + workspace: str, result: ManagedConfigResult, *, pending_config_hash: str | None = None +) -> None: + if config_io.is_dry_run(): + return + data = config_io.read_json_safe(REFRESH_TS_PATH) + data[normalize_workspace_url(workspace)] = { + "last_checked_at": _utcnow().isoformat(), + # Disabled first: an installed policy whose removal failed still replays as disabled. + "outcome": ( + _OUTCOME_FEATURE_DISABLED + if result.feature_disabled + else _OUTCOME_PUBLISHED + if result.manifest is not None + else _OUTCOME_NONE + ), + "policy_fingerprint": managed_file_fingerprint(MANAGED_CONFIG_PATH), + } + if pending_config_hash is not None: + data[normalize_workspace_url(workspace)]["pending_config_hash"] = pending_config_hash + try: + config_io.atomic_write_json(REFRESH_TS_PATH, data) + _restrict_permissions(REFRESH_TS_PATH) + except RuntimeError as exc: + print_warning(f"Could not save the workspace policy refresh timestamp: {exc}") + + def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> ManagedConfigResult: - """Fetch the workspace's managed config and persist it as a :class:`ManagedConfigResult`. - - Runs on every launch so a developer picks up an admin's edits without re-running - ``ucode configure``. A launch reuses the last read when it is younger than - :data:`MANAGED_CONFIG_TTL`, so back-to-back launches don't each hit the control plane; - ``force_refresh`` (used by ``ug configure``) skips the cache and always reads fresh. Whether the - fetched config is *newer* than what was last applied, and so whether the launch re-applies the - settings, is the caller's decision, via :func:`managed_config_is_newer` against the persisted - applied watermark. The manifest is None when the workspace has no managed config, the normal - case for a workspace whose admin hasn't published one. - - A failed fetch never blocks the launch: an unreachable control plane shouldn't stop someone from - coding. Instead it falls back to the last config persisted for this workspace, so the admin's - most recent known policy still applies; only when there is no persisted config either does the - launch fall through to the developer's own settings. ``FEATURE_DISABLED`` is the exception — it - is an authoritative "off", not a transient failure, so it drops the cache rather than falling - back (see below). - - ``coding_agent_config_feature_disabled`` is True whenever the gateway returned ``FEATURE_DISABLED`` — - the coding-agent-configs feature isn't enabled server-side, so callers suppress the ``ucode - setup`` recommendation. A config cached from when the feature was enabled is discarded in that - case (returned manifest is None), so a launch doesn't re-apply a policy the workspace has turned - off and ``ug configure`` doesn't route into a managed-setup flow that would dead-end. + """Refresh workspace policy, reusing user-owned timestamps for five-minute launch throttling. + + ``force_refresh`` bypasses throttling for explicit configuration. The installed policy changes + only when the workspace or configuration changes; unchanged reads update only refresh metadata. + Only explicit configuration may invoke sudo. Non-root launches are read-only regardless of + terminal attachment or cached sudo access; root launches can install without sudo. + + The installed workspace is authoritative for launches; mismatched selections fail before + cached reads or remote requests. Explicit configure can change the machine workspace. + Failed launch writes warn once per distinct pending policy and return the installed policy, + recording the attempt for throttling. Explicit configuration write failures raise instead. + Fetch failures retain the existing installed-policy fallback and do not advance the timestamp. + No-config and ``FEATURE_DISABLED`` remove a matching installed policy; if removal fails, launches + keep using it with a warning rather than pretending it was removed. """ - workspace = state.get("workspace") + workspace = ( + state.get("workspace") + if force_refresh + else managed_launch_workspace(state.get("workspace")) + ) if not workspace: return ManagedConfigResult(None, False) + if not _machine_policy_installable() and _installed_config(workspace) is None: + return _refresh_user_cache(state, workspace, force_refresh=force_refresh) + if config_io.is_dry_run() and not force_refresh: + return ManagedConfigResult(load_managed_state(workspace), False) if not force_refresh: cached = _cached_result_if_fresh(workspace) if cached is not None: @@ -856,26 +886,169 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag except RuntimeError as exc: return ManagedConfigResult(_persisted_fallback(workspace, str(exc)), False) raw, reason = get_managed_config(workspace, token) + if reason is not None: + if not _is_feature_disabled(reason): + fallback = _persisted_fallback(workspace, reason, refused=_is_permission_denied(reason)) + return ManagedConfigResult(fallback, False) + if not _can_install_policy(force_refresh) and load_managed_configuration(workspace) is None: + # Nothing is installed for this workspace and this non-root launch can't install it: apply + # the fetched policy to this run only, without installing it. An installed policy still + # wins, and only `ug configure` or root can install one. + result = ManagedConfigResult( + normalize_managed_config(raw) if raw is not None else None, + reason is not None and _is_feature_disabled(reason), + ) + _record_refresh(workspace, result) + return result + pending_config_hash = None + try: + save_managed_state( + workspace, + raw or {}, + interactive=force_refresh, + allow_sudo=force_refresh, + sets_machine_workspace=force_refresh, + ) + except RuntimeError as exc: + if force_refresh and _machine_policy_installable(): + message = ( + f"Could not install the workspace policy at {MANAGED_CONFIG_PATH}: {exc} " + "This machine-wide file needs sudo. Rerun `ug configure` from a terminal where you " + "can use sudo, or ask your IT team or an administrator to run it." + ) + raise ManagedPolicyInstallDenied(message) from exc + installed = load_managed_state(workspace) + if installed is not None: + result = ManagedConfigResult( + installed, raw is None and reason is not None and _is_feature_disabled(reason) + ) + fallback_message = "Continuing with the installed policy." + else: + # Nothing is installed for this workspace: the fetched policy still governs this run. + result = ManagedConfigResult( + normalize_managed_config(raw) if raw is not None else None, + reason is not None and _is_feature_disabled(reason), + ) + fallback_message = "Continuing with the fetched policy for this run." + pending_config_hash = _policy_hash(workspace, raw or {}) + entry = config_io.read_json_safe(REFRESH_TS_PATH).get(normalize_workspace_url(workspace)) + if not isinstance(entry, dict) or entry.get("pending_config_hash") != pending_config_hash: + if managed_settings_skipped(): + print_warning( + f"Your workspace policy changed, but {SKIP_OS_SETTINGS_ENV} is set, so the " + f"installed policy at {MANAGED_CONFIG_PATH} was not updated. Run `ug configure` " + f"without {SKIP_OS_SETTINGS_ENV} as an administrator. {fallback_message}" + ) + else: + print_warning( + "Your workspace policy changed. Ask your IT team or an administrator to apply " + f"the update on this machine. {fallback_message}" + ) + else: + result = ManagedConfigResult( + normalize_managed_config(raw) if raw is not None else None, + reason is not None and _is_feature_disabled(reason), + ) + _record_refresh(workspace, result, pending_config_hash=pending_config_hash) + return result + + +def _machine_policy_supported() -> bool: + """Whether ug installs a machine policy on this OS at all (Linux and macOS).""" + return current_os() in (OS.LINUX, OS.MACOS) + + +def _machine_policy_installable() -> bool: + """Whether this machine can hold the installed workspace policy (Linux or macOS, not opted out + with ``UG_SKIP_OS_SETTINGS``).""" + return not managed_settings_skipped() and _machine_policy_supported() + + +def _can_install_policy(explicit_configure: bool) -> bool: + """Whether this run may install the machine policy: root, or an explicit ``ug configure`` + (which may use sudo). A non-root launch never does.""" + return _machine_policy_installable() and (explicit_configure or is_admin()) + + +def _refresh_user_cache(state: dict, workspace: str, *, force_refresh: bool) -> ManagedConfigResult: + """Cache policy per user when machine installation is unavailable. + + A launch reuses a read younger than + :data:`MANAGED_CONFIG_TTL`, ``ug configure`` always reads fresh, a failed read falls back to the + last saved config for this workspace, and no-config or ``FEATURE_DISABLED`` replaces it. + """ + if not force_refresh: + cached = _cached_user_result_if_fresh(workspace) + if cached is not None: + return cached + try: + token = get_databricks_token(workspace, state.get("profile")) + except RuntimeError as exc: + return ManagedConfigResult(_persisted_fallback(workspace, str(exc)), False) + raw, reason = get_managed_config(workspace, token) if reason is not None: if _is_feature_disabled(reason): - save_managed_state(workspace, {}, outcome=_OUTCOME_FEATURE_DISABLED) - _sync_machine_policy(workspace, {}, force_refresh=force_refresh) + _save_user_cache(workspace, {}, outcome=_OUTCOME_FEATURE_DISABLED) return ManagedConfigResult(None, True) fallback = _persisted_fallback(workspace, reason, refused=_is_permission_denied(reason)) return ManagedConfigResult(fallback, False) if raw is None: - # Record that this workspace has no config, rather than leaving an earlier one on disk: - # the file doubles as the fallback above, so a removed policy would otherwise come back - # into force after the next transient outage. - save_managed_state(workspace, {}, outcome=_OUTCOME_NONE) - _sync_machine_policy(workspace, {}, force_refresh=force_refresh) + # Record that this workspace has no config, so a removed policy can't return as the + # fallback after a later outage. + _save_user_cache(workspace, {}, outcome=_OUTCOME_NONE) return ManagedConfigResult(None, False) - # Persist the raw config verbatim; hand callers the normalized manifest they expect. - save_managed_state(workspace, raw, outcome=_OUTCOME_PUBLISHED) - _sync_machine_policy(workspace, raw, force_refresh=force_refresh) + _save_user_cache(workspace, raw, outcome=_OUTCOME_PUBLISHED) return ManagedConfigResult(normalize_managed_config(raw), False) +def _save_user_cache(workspace: str, config: dict, *, outcome: str) -> None: + """Persist the raw config and its read outcome to the per-user cache at mode 0600.""" + payload = { + "workspace": workspace, + "config": config, + "retrieved_at": _utcnow().isoformat(), + "outcome": outcome, + } + if config_io.is_dry_run(): + console.print( + f"\n[bold]\\[dry run] {USER_MANAGED_CONFIG_PATH}[/bold]\n" + f"{json.dumps(payload, indent=2)}\n" + ) + return + config_io.ensure_parent_dir(USER_MANAGED_CONFIG_PATH) + try: + USER_MANAGED_CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + raise RuntimeError( + f"Failed to write managed state file: {USER_MANAGED_CONFIG_PATH}" + ) from exc + _restrict_permissions(USER_MANAGED_CONFIG_PATH) + + +def _cached_user_result_if_fresh(workspace: str) -> ManagedConfigResult | None: + """The per-user cache's read for ``workspace`` replayed as a result, if within the TTL.""" + data = config_io.read_json_safe(USER_MANAGED_CONFIG_PATH) + stored = data.get("workspace") + if not isinstance(stored, str) or normalize_workspace_url(stored) != normalize_workspace_url( + workspace + ): + return None + retrieved_at = parse_update_time(_str(data.get("retrieved_at"))) + if retrieved_at is None: + return None + age = _utcnow() - retrieved_at + if age < timedelta(0) or age >= MANAGED_CONFIG_TTL: + return None + outcome = data.get("outcome") + if outcome == _OUTCOME_FEATURE_DISABLED: + return ManagedConfigResult(None, True) + if outcome == _OUTCOME_NONE: + return ManagedConfigResult(None, False) + if outcome == _OUTCOME_PUBLISHED and isinstance(data.get("config"), dict): + return ManagedConfigResult(normalize_managed_config(data["config"]), False) + return None + + def _is_feature_disabled(reason: str) -> bool: return "feature_disabled" in reason.lower() diff --git a/src/ucode/managed_export.py b/src/ucode/managed_export.py index 4636acd37..78819c934 100644 --- a/src/ucode/managed_export.py +++ b/src/ucode/managed_export.py @@ -52,12 +52,12 @@ def build_export_payload() -> dict: ``name`` stripped. Reads only local state — no network, no auth. Raises RuntimeError with an actionable message when no config is authored locally or the config fails structural validation. """ - workspace = load_state().get("workspace") or managed_state_workspace() + workspace = load_state().get("workspace") or managed_state_workspace(include_user_cache=True) config = load_managed_configuration(workspace) if not config: raise RuntimeError( - "No managed configuration found locally. Run `ug` against a workspace that " - "publishes one, then re-run `ug export`." + "No managed configuration found locally. Run `ug configure` against a workspace that " + "publishes one to install its policy, then re-run `ug export`." ) errors = validate_manifest(normalize_managed_config(config), None) if errors: diff --git a/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index 96e758f0f..73d695ec7 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -1,6 +1,6 @@ """Resolve the effective agent settings from the managed config plus local ucode state. -The managed config (``~/.ucode/managed-state.json``, published by an admin through the AI Gateway +The managed config (``/etc/unitygateway/managed-settings.json``, published through the AI Gateway and refreshed from the workspace at launch through :mod:`ucode.managed_config`) and the developer's own ucode state (``~/.ucode/state.json``) stay separate files — they are never merged on disk. Instead this module resolves them *per key* at config-write time: whatever the manifest specifies wins, and diff --git a/src/ucode/managed_setup.py b/src/ucode/managed_setup.py index a7feab5cb..49c8d9910 100644 --- a/src/ucode/managed_setup.py +++ b/src/ucode/managed_setup.py @@ -14,7 +14,7 @@ once. Local persistence is not duplicated here: the config lives in the one local file, -``~/.ucode/managed-config.json``, which the launch path pulls into via +``/etc/unitygateway/managed-settings.json``, which configuration and launch refreshes update via :func:`ucode.managed_config.save_managed_state` / :func:`ucode.managed_config.load_managed_state`. These validation and serialization helpers are consumed on the launch/refresh side (notably by diff --git a/tests/README.md b/tests/README.md index ded800103..2b0a20204 100644 --- a/tests/README.md +++ b/tests/README.md @@ -533,7 +533,6 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured | | `test_ug_configure_claude_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace | | `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup | -| `test_ug_configure_installs_workspace_policy_machine_wide` | Run a managed `ug configure` with no installed workspace policy | `/etc/unitygateway/managed-settings.json` is root-owned `0644` in a root-owned `0755` directory and contains exactly `workspace` and `config`; the suite removes it at teardown (marker `managed_fixture`) | | `test_ug_configure_managed_claude`, `test_ug_configure_managed_codex` | Configure on the managed workspace with the stubbed `managed_workspace_default.json` CodingAgentConfig | No agent selector; each agent's generated config exposes exactly the admin's static model_services; real gateway prompt on launch. The Codex case also checks the shared catalog pointer, restart guidance, and a fresh bare app-server's visible model list | | `e2e_cuj/test_cuj5_budget_defaults.py` | Launch bare `ug` with separate low-spend and above-tier principals against the fixed 1% tier (`spending_percentage=0.01`); explicitly launch `ug claude` above the tier | Bare launches check Claude/Sonnet below the tier and the Codex/Luna recommendation above it; `ug usage` agrees with backend spend, threshold, and percentage. Explicit `ug claude` displays the backend's Codex/Luna recommendation while its generated model setting and native header select Sonnet. No budget writes or inference tasks. Runs on its own `E2E CUJs · CUJ 5 · Budget-driven defaults` runner with both credential pairs documented in `integration/README.md`. | | `test_case_01_*` | Launch managed Claude without defaults after configure and from fresh state | Claude receives the admin MPS header; its gateway cache and replacement picker match the independently fetched provider model IDs; catalog labels are preserved and a model appears in a numbered picker row | @@ -735,21 +734,29 @@ and real-shell copy/rename failure handling in temporary directories. `test_cli.py` covers the workspace configure session boundary. These are unit/component checks; they do not establish live sudo password-prompt behavior. -`test_managed_config.py::TestMachinePolicyInstall` covers `ug configure` installing the machine-wide -workspace policy: unchanged content writes nothing, a changed policy is reinstalled, no-config and -feature-disabled remove it (for the same or another workspace), launches never write it, denied sudo -raises `ManagedPolicyInstallDenied`, and Windows, other OSes, and `UG_SKIP_OS_SETTINGS` never write. -`test_managed_files.py` covers the workspace-policy writer (non-root launch never invokes sudo, sudo -password failures, root direct runs, dry run, symlink refusal) and runs the real managed shell script -for install and matching removal. These are component checks against a faked installer; they do not -establish live sudo behavior. - Codex desktop OTEL component tests cover the systemd unit, the macOS LaunchAgent, and the Windows scheduled task (`launchctl` and `schtasks` are simulated), reuse of CLI proxy/auth, fixed-port collisions, setup failures that only warn, unchanged launches that keep the service running, and leaving another exporter alone. They do not establish a live desktop GUI, a real `launchctl` or `schtasks` run, or a login/reboot pass. +`test_managed_config.py` and `test_managed_files.py` cover machine policy versus user-owned +`refresh_ts.json`, five-minute throttling, no privileged write for unchanged policy or +retrieval-only drift, configure-only sudo selection, deferred-update warning/fallback, matching +removal, workspace isolation, fingerprint invalidation, and dry-run behavior. +Pending-change regressions cover one warning across expired checks, distinct-change warnings, +retrieval/key-order stability, removal, and clearing after remote/MDM/configure reconciliation. +Workspace-lock regressions in `test_managed_config.py` and `test_cli.py` refuse foreign workspaces +before cache/auth/bootstrap, including bare/agent launches, dry-run, and skip-preflight. +`test_ug_managed_machine_refuses_foreign_workspace_launch` adds the black-box managed journey +(stubbed managed config): configure followed by rejected bare/Claude/Codex workspace overrides, with policy, +refresh metadata, and user state unchanged. This new live case has not been run locally. +`test_ug_managed_config_launch_reuses_cache_within_ttl` is the live journey for root-owned `0644` +policy, user-owned `0600` timestamps, and unchanged policy bytes/inode/mtime/ctime after an +expired unchanged check. Its live sudo/password behavior still requires a disposable clean VM. +Non-root writer regressions cover terminal/script launches, initial install and removal, and +verify that neither a sudo capability probe nor a prompting worker is started. + | Scenario | Status / requirement | | --- | --- | | Broad live MCP functionality | CUJ2 covers sandbox MCP configuration and generated client listings. Live sandbox execution is deferred because MAS cannot downscope the CI service principal. Installation tests cover the local web-search MCP handshake and tool listing, not upstream proxying or a real search request. Other MCP services, live parent/child search, and permission decisions remain deferred | diff --git a/tests/conftest.py b/tests/conftest.py index 29b324f3a..025339f38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,35 +47,35 @@ def _isolate_ucode_state(tmp_path, monkeypatch): monkeypatch.setattr(codex_mod, "_otel_service_path", lambda: state_dir / "otel.service") monkeypatch.setattr(desktop_mod, "APP_DIR", state_dir) monkeypatch.setattr(desktop_auth_mod, "_AUTH_LOCK_PATH", state_dir / "claude-desktop-auth.lock") - # MANAGED_CONFIG_PATH is bound from APP_DIR at import, so patching APP_DIR alone doesn't move it; - # rebind it or save_managed_state writes to the developer's real ~/.ucode/managed-config.json. - monkeypatch.setattr( - managed_config_mod, "MANAGED_CONFIG_PATH", state_dir / "managed-config.json" - ) - # The machine-wide workspace policy lives outside APP_DIR. Redirect it, and fake its installer, - # so no test installs or removes the real /etc/unitygateway/managed-settings.json. + # The workspace policy lives outside APP_DIR; rebind it, and its user-owned refresh stamps, + # so no test reads or installs the machine-wide copy. monkeypatch.setattr( managed_config_mod, - "MACHINE_POLICY_PATH", + "MANAGED_CONFIG_PATH", tmp_path / "etc" / "unitygateway" / "managed-settings.json", ) + monkeypatch.setattr(managed_config_mod, "REFRESH_TS_PATH", state_dir / "refresh_ts.json") + monkeypatch.setattr( + managed_config_mod, "USER_MANAGED_CONFIG_PATH", state_dir / "managed-config.json" + ) - def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=False): - if path != managed_config_mod.MACHINE_POLICY_PATH or not path.is_relative_to(tmp_path): + def install_test_policy(path, desired_text, *, interactive=False, allow_sudo=True): + if path != managed_config_mod.MANAGED_CONFIG_PATH or not path.is_relative_to(tmp_path): pytest.fail(f"test attempted to install machine-wide policy at {path}") if config_io_mod.is_dry_run(): return if desired_text is None: path.unlink(missing_ok=True) else: - path.parent.mkdir(parents=True, exist_ok=True) config_io_mod.atomic_write_json(path, json.loads(desired_text)) path.chmod(0o644) monkeypatch.setattr(managed_config_mod, "update_workspace_policy", install_test_policy) - # The installer is faked, so let every host take the Linux/macOS install path; tests for - # Windows or UG_SKIP_OS_SETTINGS override this. + # The installer is faked above, so let every host take the installable (Linux/macOS) path; + # tests for Windows or UG_SKIP_OS_SETTINGS override this. monkeypatch.setattr(managed_config_mod, "current_os", lambda: managed_files_mod.OS.LINUX) + # Likewise let any run install the faked policy, as root may; non-root launch tests override it. + monkeypatch.setattr(managed_config_mod, "is_admin", lambda: True) backup_dir = state_dir / "managed-backups" monkeypatch.setattr(managed_files_mod, "MANAGED_BACKUP_DIR", backup_dir) monkeypatch.setattr( diff --git a/tests/e2e_cuj/helpers/tui_request_recorder.py b/tests/e2e_cuj/helpers/tui_request_recorder.py index d0f82e599..4d655a1bd 100644 --- a/tests/e2e_cuj/helpers/tui_request_recorder.py +++ b/tests/e2e_cuj/helpers/tui_request_recorder.py @@ -1,6 +1,8 @@ """CUJ configuration glue for the shared real-workspace request recorder.""" import json +import subprocess +import tempfile from copy import deepcopy from datetime import UTC, datetime from pathlib import Path @@ -14,7 +16,9 @@ from tests.integration.utils.request_recorder import ( TuiRequestRecorder as RequestRecorder, ) +from ucode.managed_files import managed_file_fingerprint +from .constants import WORKSPACE_POLICY_PATH from .session import UserSession @@ -31,15 +35,25 @@ def configure_session(self, session: UserSession, args: list[str]) -> None: self.retarget_session(session.home) def prepare_launch(self) -> None: - """Keep the recorder-scoped managed-config cache fresh for a no-preflight launch.""" + """Keep the recorder's policy check fresh for a no-preflight launch. + + A configure without a terminal installs no machine policy, so its launches fetch the + policy through the recorder and there is nothing to refresh. + """ if self._session_home is None: raise RuntimeError("configure_session must run before prepare_launch") - path = self._session_home / ".ucode" / "managed-config.json" - payload = json.loads(path.read_text()) - if payload.get("workspace") != self.url: - raise AssertionError("Managed-config cache does not target the recorder") - payload["retrieved_at"] = datetime.now(UTC).isoformat() - path.write_text(json.dumps(payload, indent=2) + "\n") + if not WORKSPACE_POLICY_PATH.exists(): + return + if json.loads(WORKSPACE_POLICY_PATH.read_text()).get("workspace") != self.url: + raise AssertionError("The installed workspace policy does not target the recorder") + refresh_path = self._session_home / ".ucode" / "refresh_ts.json" + refresh = json.loads(refresh_path.read_text()) + refresh[self.url] = { + **refresh[self.url], + "last_checked_at": datetime.now(UTC).isoformat(), + "policy_fingerprint": managed_file_fingerprint(WORKSPACE_POLICY_PATH), + } + refresh_path.write_text(json.dumps(refresh, indent=2) + "\n") def retarget_session(self, home: Path) -> None: """Retarget a session configured outside configure_session, e.g. in a terminal.""" @@ -56,11 +70,23 @@ def retarget_session(self, home: Path) -> None: state["current_workspace"] = self.url state_path.write_text(json.dumps(state, indent=2) + "\n") - managed_path = home / ".ucode" / "managed-config.json" - managed = json.loads(managed_path.read_text()) - if managed.get("workspace") != upstream: - raise AssertionError("ug configure wrote no upstream managed-config cache") - managed["workspace"] = self.url - managed_path.write_text(json.dumps(managed, indent=2) + "\n") + refresh_path = home / ".ucode" / "refresh_ts.json" + # A read that installs nothing leaves no stamp; launches then read through the recorder. + if refresh_path.is_file(): + refresh = json.loads(refresh_path.read_text()) + if upstream in refresh: + refresh[self.url] = refresh[upstream] + refresh_path.write_text(json.dumps(refresh, indent=2) + "\n") + if WORKSPACE_POLICY_PATH.exists(): + # A terminal configure installed the root-owned policy; point it at the recorder too. + policy = json.loads(WORKSPACE_POLICY_PATH.read_text()) + if policy.get("workspace") != upstream: + raise AssertionError("ug configure installed no upstream workspace policy") + policy["workspace"] = self.url + with tempfile.NamedTemporaryFile("w", suffix=".json", dir=home) as staged: + staged.write(json.dumps(policy, indent=2) + "\n") + staged.flush() + # Copying over the existing file keeps its root ownership and 0644 mode. + subprocess.run(["sudo", "cp", staged.name, str(WORKSPACE_POLICY_PATH)], check=True) self._session_home = home self.prepare_launch() diff --git a/tests/e2e_cuj/test_cuj1_explicit_models.py b/tests/e2e_cuj/test_cuj1_explicit_models.py index c0a229654..a17d68cc6 100644 --- a/tests/e2e_cuj/test_cuj1_explicit_models.py +++ b/tests/e2e_cuj/test_cuj1_explicit_models.py @@ -25,6 +25,7 @@ CODEX_SOL_MODEL, INFERENCE_PATHS, RUN_HEADER, + WORKSPACE_POLICY_PATH, ) from .helpers.desktop import assert_claude_desktop_profile, enable_claude_desktop from .helpers.evidence import SessionEvidence @@ -103,10 +104,10 @@ def _assert_inference_requests( def _managed_config(session) -> dict: - cache_path = session.home / ".ucode" / "managed-config.json" - cache = json.loads(cache_path.read_text()) - raw = cache.get("config") - assert isinstance(raw, dict), cache + """The policy `ug configure` installed for this machine.""" + installed = json.loads(WORKSPACE_POLICY_PATH.read_text()) + raw = installed.get("config") + assert isinstance(raw, dict), installed return raw diff --git a/tests/e2e_cuj/test_cuj2_mps_mcp.py b/tests/e2e_cuj/test_cuj2_mps_mcp.py index fb21b55d3..446fdaf85 100644 --- a/tests/e2e_cuj/test_cuj2_mps_mcp.py +++ b/tests/e2e_cuj/test_cuj2_mps_mcp.py @@ -26,6 +26,7 @@ PROVIDER_HEADER, SANDBOX_MCP_SERVICE_NAME, WEB_SEARCH_MCP_SERVICE_NAME, + WORKSPACE_POLICY_PATH, ) from .helpers.desktop import assert_claude_desktop_profile, enable_claude_desktop from .helpers.evidence import assert_served @@ -113,11 +114,9 @@ def _assert_mcp_services(workspace) -> None: def _assert_configured_files(session, workspace_url: str) -> None: - managed_cache = json.loads( - (session.home / ".ucode" / "managed-config.json").read_text(encoding="utf-8") - ) - managed_config = managed_cache.get("config") - assert isinstance(managed_config, dict), managed_cache + installed = json.loads(WORKSPACE_POLICY_PATH.read_text(encoding="utf-8")) + managed_config = installed.get("config") + assert isinstance(managed_config, dict), installed _assert_cuj2_config(managed_config) claude_settings = session.home / ".claude" / "ucode-settings.json" diff --git a/tests/integration/README.md b/tests/integration/README.md index e9429770e..e67ccef3a 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -271,6 +271,21 @@ fails before Copilot starts, and the TUI switches from Claude to GPT with `/model` mid-session. Model evidence comes from Copilot's own `~/.copilot/session-state/*/events.jsonl` (`utils/agents/copilot.py`). +The managed TTL journey installs `/etc/unitygateway/managed-settings.json` through real configure, +checks root ownership and readable modes, then ages only user-owned `~/.ucode/refresh_ts.json`. +Warm launch leaves the timestamp unchanged; expired launch advances it without changing installed +policy bytes, inode, mtime, or ctime when the workspace policy is unchanged. Unit/component tests +cover configure-only elevation, non-root read-only launches, IT guidance, removal, and +metadata-only drift. Root/non-root packaged-writer checks in a disposable Linux container are +component evidence, not live MDM provisioning or Claude/Codex launch evidence. Running the full MDM +bootstrap and agent journey still requires explicitly selected workspace provisioning credentials. +The foreign-workspace journey configures through the public CLI, then verifies that bare ug, +Claude, and Codex reject a different `--workspace` with dry-run/skip-preflight without changing +machine policy, user state, or refresh metadata. It uses the `managed_workspace_default` fixture and is implemented but not locally run; unit +regressions cover workspace-authoritative refresh and pending-hash warning suppression. +The credential-free disposable-container harness passes real installed-CLI mismatch rejection +as UID 10001; it does not replace the unrun real-workspace configure journey. + ## Shared agent helpers `utils/agents/claude.py` and `utils/agents/codex.py` own the existing native answer @@ -292,7 +307,7 @@ existing agent installations or checkout's `.venv`. CI pins Databricks CLI 1.17.0 in the live and managed integration lanes. The runner's isolated `PATH` exposes that selected CLI, so skills journeys meet ug's CLI minimum without falling back to another version installed on the machine. -Native live runs refuse existing machine-wide Claude/Codex configuration, which +Native live runs refuse existing machine-wide Unity Gateway/Claude/Codex configuration, which could override the selected workspace even with a fresh home. Use a clean VM in that case; the runner never edits or bypasses those managed settings. @@ -704,7 +719,7 @@ managed workspace with a checked-in JSON CodingAgentConfig from `tests/fixtures/ injected through `UCODE_MANAGED_CONFIG_STUB`; there are no cases that read a published config. Twelve explicit configured/fresh Claude and Codex discovery and source-override journeys use the `claude_mps.json` / `codex_mps.json` fixtures, which set each agent's source to its dedicated MPS. -The other cases cover focused model, MCP, skills, cache-TTL, and lifecycle shapes, including +The other cases cover focused model, MCP, skills, cache-TTL, foreign-workspace refusal, and lifecycle shapes, including per-agent model reconciliation and managed skill cleanup. Of the two Claude default-model cases, the MPS case stubs a `main.default.ci_e2e_anthropic_mps` source and the `system.ai` parent-schema case stubs a Unity Catalog source; both verify the generated settings diff --git a/tests/integration/test_ug_codex_managed_model_discovery.py b/tests/integration/test_ug_codex_managed_model_discovery.py index 4cfffebb3..2e01bee61 100644 --- a/tests/integration/test_ug_codex_managed_model_discovery.py +++ b/tests/integration/test_ug_codex_managed_model_discovery.py @@ -8,6 +8,7 @@ import json import os import tomllib +from pathlib import Path import pytest from utils.constants import MANAGED_CODEX_PROVIDER_SERVICE @@ -65,7 +66,9 @@ def _assert_rejected_before_codex_started(session, result, before=None): assert _codex_state_and_agent_files(session) == before -def _assert_managed_provider_catalog(session, models, expected: CodexProviderCatalog): +def _assert_managed_provider_catalog( + session, models, expected: CodexProviderCatalog, *, installed: bool = True +): config = tomllib.loads((session.home / ".codex" / "ucode.config.toml").read_text()) # The provider header is a launch-only overlay; neither it nor the scoped catalog is persisted # in Codex's generated profile. @@ -75,22 +78,31 @@ def _assert_managed_provider_catalog(session, models, expected: CodexProviderCat app_catalog_path = session.home / ".ucode" / "codex-model-catalog.json" assert shared_config.get("model_catalog_json") == str(app_catalog_path), shared_config - managed_cache = json.loads((session.home / ".ucode/managed-config.json").read_text()) - raw_config = managed_cache.get("config") - enabled_agents = raw_config.get("enabled_agents") if isinstance(raw_config, dict) else None - assert isinstance(enabled_agents, list), "persisted managed config had no enabled_agents list" - codex_entries = [ - entry - for entry in enabled_agents - if isinstance(entry, dict) and entry.get("agent") == "CODING_AGENT_CODEX" - ] - assert len(codex_entries) == 1, "persisted managed config did not contain one Codex entry" - managed_codex = codex_entries[0].get("config") - assert isinstance(managed_codex, dict), "persisted managed Codex config was not an object" - assert managed_codex.get("models") == { - "model_provider_service": MANAGED_CODEX_PROVIDER_SERVICE - }, "persisted managed Codex config did not select the dedicated MPS" - assert "default_models" not in managed_codex, "static Codex defaults survived the MPS variant" + policy = Path("/etc/unitygateway/managed-settings.json") + if installed: + managed_cache = json.loads(policy.read_text()) + raw_config = managed_cache.get("config") + enabled_agents = raw_config.get("enabled_agents") if isinstance(raw_config, dict) else None + assert isinstance(enabled_agents, list), ( + "persisted managed config had no enabled_agents list" + ) + codex_entries = [ + entry + for entry in enabled_agents + if isinstance(entry, dict) and entry.get("agent") == "CODING_AGENT_CODEX" + ] + assert len(codex_entries) == 1, "persisted managed config did not contain one Codex entry" + managed_codex = codex_entries[0].get("config") + assert isinstance(managed_codex, dict), "persisted managed Codex config was not an object" + assert managed_codex.get("models") == { + "model_provider_service": MANAGED_CODEX_PROVIDER_SERVICE + }, "persisted managed Codex config did not select the dedicated MPS" + assert "default_models" not in managed_codex, ( + "static Codex defaults survived the MPS variant" + ) + else: + # A non-root launch applies the fetched policy for that run; only `ug configure` installs. + assert not policy.exists(), "a launch installed the machine policy" catalog_paths = list((session.home / ".ucode").glob("codex-model-catalog-*.json")) assert len(catalog_paths) == 1, catalog_paths @@ -175,7 +187,9 @@ def test_case_02_managed_codex_uses_admin_discovery_from_fresh_state( ["--workspace", workspace, "--", "app-server", "--listen", "stdio://"] ) - _assert_managed_provider_catalog(session, models, _managed_codex_provider_catalog) + _assert_managed_provider_catalog( + session, models, _managed_codex_provider_catalog, installed=False + ) app_models = session.codex_model_ids( ["app-server", "--listen", "stdio://"], name="bare-codex-models", diff --git a/tests/integration/test_ug_configure_managed.py b/tests/integration/test_ug_configure_managed.py index d2d888f4e..6c8587181 100644 --- a/tests/integration/test_ug_configure_managed.py +++ b/tests/integration/test_ug_configure_managed.py @@ -117,60 +117,92 @@ def test_ug_configure_managed_is_idempotent(live_session, workspace): assert runs == [expected, expected], runs -@pytest.mark.managed_fixture -@pytest.mark.claude -def test_ug_configure_installs_workspace_policy_machine_wide(live_session, workspace): - """Scenario: run a managed `ug configure` on a machine with no installed workspace policy. - - Expected: configure installs the workspace policy at /etc/unitygateway/managed-settings.json as - a root-owned 0644 file in a root-owned 0755 directory, containing exactly the `workspace` and - `config` keys. Launches read the per-user cache as before, so this test checks only the - installed file, which the test suite removes at teardown. - """ - session = live_session - use_managed_config_fixture(session, "managed_workspace_default") - policy = Path("/etc/unitygateway/managed-settings.json") - - session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) - - installed = policy.stat() - assert installed.st_uid == 0 - assert installed.st_mode & 0o777 == 0o644 - assert policy.parent.stat().st_uid == 0 - assert policy.parent.stat().st_mode & 0o777 == 0o755 - assert set(json.loads(policy.read_text(encoding="utf-8"))) == {"workspace", "config"} - - @pytest.mark.managed_fixture @pytest.mark.claude def test_ug_managed_config_launch_reuses_cache_within_ttl(live_session, workspace): """Scenario: after a managed `ug configure`, launch Claude within the cache TTL, then again - after the cached read is backdated past it. + after the user-owned refresh timestamp is backdated past it. - Expected: configure stamps managed-config.json with a `published` outcome and a `retrieved_at`; - a launch within the TTL is served from that cache and leaves the stamp untouched (no re-read of - the config source), and once the stamp is backdated past the TTL the next launch reads fresh - and advances it. The stub takes the same cache path as a live read. + Expected: configure installs a readable machine policy and records a published refresh outcome. + A warm launch leaves both files unchanged; after expiry, a fresh read advances only the + user-owned timestamp when remote policy content has not changed. The stub takes the same + refresh path as a live read. """ session = live_session use_managed_config_fixture(session, "managed_workspace_default") - cache = session.home / ".ucode" / "managed-config.json" + policy = Path("/etc/unitygateway/managed-settings.json") + refresh = session.home / ".ucode" / "refresh_ts.json" + workspace_key = workspace.rstrip("/") session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) - fresh = json.loads(cache.read_text()) + installed = policy.read_bytes() + installed_stat = policy.stat() + assert installed_stat.st_uid == 0 + assert installed_stat.st_mode & 0o777 == 0o644 + assert policy.parent.stat().st_mode & 0o777 == 0o755 + assert set(json.loads(installed)) == {"workspace", "config"} + fresh = json.loads(refresh.read_text())[workspace_key] assert fresh["outcome"] == "published", fresh - stamped = fresh["retrieved_at"] + assert refresh.stat().st_mode & 0o777 == 0o600 + stamped = fresh["last_checked_at"] - # A launch within the TTL reuses the cached read: the stamp must not move. with AgentTerminal(session, "claude", [str(session.binary), "claude"], "ttl-cache-hit") as tui: tui.boot() tui.check_input_and_exit() - assert json.loads(cache.read_text())["retrieved_at"] == stamped + assert json.loads(refresh.read_text())[workspace_key]["last_checked_at"] == stamped + assert policy.read_bytes() == installed + assert policy.stat().st_ino == installed_stat.st_ino + assert policy.stat().st_mtime_ns == installed_stat.st_mtime_ns + assert policy.stat().st_ctime_ns == installed_stat.st_ctime_ns - # Backdate the stamp past the TTL; the next launch reads fresh and advances it. backdated = "2000-01-01T00:00:00+00:00" - cache.write_text(json.dumps({**json.loads(cache.read_text()), "retrieved_at": backdated})) + refresh_data = json.loads(refresh.read_text()) + refresh_data[workspace_key]["last_checked_at"] = backdated + refresh.write_text(json.dumps(refresh_data)) with AgentTerminal(session, "claude", [str(session.binary), "claude"], "ttl-expired") as tui: tui.boot() tui.check_input_and_exit() - assert json.loads(cache.read_text())["retrieved_at"] != backdated + assert json.loads(refresh.read_text())[workspace_key]["last_checked_at"] != backdated + assert policy.read_bytes() == installed + assert policy.stat().st_ino == installed_stat.st_ino + assert policy.stat().st_mtime_ns == installed_stat.st_mtime_ns + assert policy.stat().st_ctime_ns == installed_stat.st_ctime_ns + + +@pytest.mark.managed_fixture +@pytest.mark.claude +def test_ug_managed_machine_refuses_foreign_workspace_launch(live_session, workspace): + """Scenario: configure a managed machine, then select a foreign workspace for launch. + + Expected: bare ug, Claude, and Codex refuse the foreign workspace even with skip-preflight + or dry-run, without changing user state, refresh metadata, or the installed policy. + """ + session = live_session + use_managed_config_fixture(session, "managed_workspace_default") + session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) + policy = Path("/etc/unitygateway/managed-settings.json") + refresh = session.home / ".ucode" / "refresh_ts.json" + state = session.home / ".ucode" / "state.json" + before = {path: path.read_bytes() for path in (policy, refresh, state)} + installed_stat = policy.stat() + foreign = "https://not-the-managed-workspace.invalid" + for tool in (None, "claude", "codex"): + for dry_run in (False, True): + args = [tool] if tool else [] + args += ["--workspace", foreign, "--skip-preflight"] + if dry_run: + args = ["--dry-run", *args] + result = session.run(*args, ok=False, timeout=30) + assert result.returncode == 1, (result.stdout, result.stderr) + output = " ".join((result.stdout + result.stderr).split()) + assert "Launch refused" in output, output + assert workspace.rstrip("/") in output, output + assert foreign in output, output + assert "IT team or an administrator" in output, output + assert {path: path.read_bytes() for path in before} == before + after_stat = policy.stat() + assert (after_stat.st_ino, after_stat.st_mtime_ns, after_stat.st_ctime_ns) == ( + installed_stat.st_ino, + installed_stat.st_mtime_ns, + installed_stat.st_ctime_ns, + ) diff --git a/tests/integration/utils/managed.py b/tests/integration/utils/managed.py index 735e7c106..442acf983 100644 --- a/tests/integration/utils/managed.py +++ b/tests/integration/utils/managed.py @@ -40,9 +40,11 @@ def assert_no_managed_config(payload: object) -> None: def is_managed_config_control_plane_cache(home: Path, path: Path) -> bool: """Whether ``path`` is ug's workspace-policy state, not agent-owned configuration. - That is the machine-wide installed policy, or the per-user cache of the fetched policy. + That is the installed policy and its refresh stamps, or, where the machine cannot hold the + installed policy (Windows), the per-user cache of the fetched policy. """ return path in ( + home / ".ucode" / "refresh_ts.json", home / ".ucode" / "managed-config.json", Path("/etc/unitygateway/managed-settings.json"), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 799275f88..70d23e66f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6376,6 +6376,215 @@ def test_absent_flag_defaults_false(self, tool): assert cfg.call_args.kwargs["skip_preflight"] is False +class TestDeniedMachineWorkspaceSwitch: + INSTALLED = "https://installed.example.com" + SELECTED = "https://selected.example.com" + + @pytest.mark.parametrize("published", [True, False], ids=["replace", "remove"]) + def test_denied_switch_keeps_the_saved_workspace_on_the_installed_one( + self, monkeypatch, published + ): + """Configure saves B first; when it can't replace or remove A's policy, launches stay on A.""" + import ucode.managed_config as mc_mod + from ucode.managed_config import load_managed_configuration, save_managed_state + from ucode.state import load_state + + save_managed_state(self.INSTALLED, {"default_agent": "CODING_AGENT_CLAUDE_CODE"}) + cli_mod.set_current_workspace(self.SELECTED) + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda *_a: "tok") + raw = {"default_agent": "CODING_AGENT_CODEX"} if published else None + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *_a: (raw, None)) + + def denied(*_a, **_k): + raise RuntimeError("sudo: a password is required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + + with pytest.raises(RuntimeError, match="needs sudo"): + cli_mod._refresh_configured_policy({"workspace": self.SELECTED, "profile": None}) + + assert load_state().get("workspace") == self.INSTALLED + assert load_managed_configuration(self.INSTALLED) == { + "default_agent": "CODING_AGENT_CLAUDE_CODE" + } + + +class TestUnrelatedConfigureFailure: + def test_other_errors_leave_the_saved_workspace_alone(self, monkeypatch): + """Only a denied policy install moves the selection back to the installed workspace.""" + from ucode.managed_config import save_managed_state + from ucode.state import load_state + + save_managed_state("https://installed.example.com", {"default_agent": "claude"}) + cli_mod.set_current_workspace("https://selected.example.com") + + def boom(*_a, **_k): + raise RuntimeError("could not write ~/.ucode/refresh_ts.json") + + monkeypatch.setattr(cli_mod, "refresh_managed_config", boom) + with pytest.raises(RuntimeError, match="refresh_ts"): + cli_mod._refresh_configured_policy( + {"workspace": "https://selected.example.com", "profile": None} + ) + + assert load_state().get("workspace") == "https://selected.example.com" + + +class TestDryRunAdoptsInstalledWorkspace: + def test_dry_run_launch_state_uses_the_installed_workspace(self, monkeypatch): + """A dry run saves nothing, so the adopted workspace is carried for that run.""" + from ucode.managed_config import save_managed_state + + save_managed_state("https://installed.example.com", {"default_agent": "claude"}) + cli_mod.set_dry_run(True) + try: + state = cli_mod._launch_workspace_state(None) + finally: + cli_mod.set_dry_run(False) + + assert state["workspace"] == "https://installed.example.com" + + +class TestConfigureOtherAgentReadsPolicyOnly: + @pytest.mark.parametrize(("tool", "forced"), [("opencode", False), ("claude", True)]) + def test_only_claude_and_codex_force_the_policy_read(self, monkeypatch, tool, forced): + """As on main, other agents only read the policy, so they never prompt for sudo.""" + calls = [] + monkeypatch.setattr( + cli_mod, "_configure_shared_workspace_states", lambda *a, **k: [dict(MINIMAL_STATE)] + ) + monkeypatch.setattr( + cli_mod, + "refresh_managed_config", + lambda state, force_refresh=False: calls.append(force_refresh) or (None, False), + ) + monkeypatch.setattr(cli_mod, "configure_single_tool", lambda tool, state, **k: state) + monkeypatch.setattr(cli_mod, "_record_unmanaged_configured_agents", lambda s, t: s) + monkeypatch.setattr(cli_mod, "install_databricks_ai_tools_for_agents", lambda *a, **k: None) + monkeypatch.setattr( + "ucode.agents.desktop.setup.configure_claude_desktop", lambda *a, **k: None + ) + + cli_mod._setup_single_agent( + [("https://example.databricks.com", None)], tool, apply_managed=True + ) + + assert calls == [forced] + + +class TestConfigureOtherAgentOnAnotherWorkspacesMachine: + def test_other_agent_configure_keeps_the_machine_workspace(self, monkeypatch): + """It can't switch the machine, so it fails early and leaves the selection on A.""" + from ucode.managed_config import save_managed_state + from ucode.state import load_state + + save_managed_state("https://installed.example.com", {"default_agent": "claude"}) + cli_mod.set_current_workspace("https://selected.example.com") + monkeypatch.setattr( + cli_mod, + "_configure_shared_workspace_states", + lambda *a, **k: [{**MINIMAL_STATE, "workspace": "https://selected.example.com"}], + ) + monkeypatch.setattr( + cli_mod, "configure_single_tool", lambda *a, **k: pytest.fail("must fail before") + ) + + with pytest.raises(RuntimeError, match="managed for https://installed.example.com"): + cli_mod._setup_single_agent( + [("https://selected.example.com", None)], "opencode", apply_managed=True + ) + + assert load_state().get("workspace") == "https://installed.example.com" + + +class TestInstalledMachineWorkspace: + INSTALLED = "https://installed.example.com" + SELECTED = "https://selected.example.com" + + # Every agent takes the same `_launch_tool` path, so Claude stands in for the agents next to + # the bare launch. + @pytest.mark.parametrize( + ("selection", "tool"), + [("state", None), ("state", "claude"), ("flag", None), ("flag", "claude")], + ) + @pytest.mark.parametrize("dry_run", [False, True]) + def test_foreign_workspace_launch_fails_before_setup( + self, monkeypatch, tool, selection, dry_run + ): + from ucode.managed_config import save_managed_state + + cli_mod.set_dry_run(False) + save_managed_state(self.INSTALLED, {"default_agent": "CODING_AGENT_CLAUDE_CODE"}) + monkeypatch.setattr( + cli_mod, + "load_state", + lambda: { + **MINIMAL_STATE, + "workspace": self.SELECTED if selection == "state" else self.INSTALLED, + }, + ) + for helper in ( + "set_current_workspace", + "apply_pat_environment", + "install_databricks_cli", + "ensure_bootstrap_dependencies", + "ensure_provider_state", + "refresh_managed_config", + "launch_agent", + ): + monkeypatch.setattr( + cli_mod, + helper, + lambda *args, **kwargs: pytest.fail("mismatch must fail before setup"), + ) + args = [tool] if tool else [] + args += ["--skip-preflight"] + if selection == "flag": + args += ["--workspace", self.SELECTED] + if dry_run: + args = ["--dry-run", *args] + result = runner.invoke(app, args) + assert result.exit_code == 1, result.output + output = " ".join(_strip_ansi(result.output).split()) + assert "Launch refused" in output + assert self.INSTALLED in output + assert self.SELECTED in output + assert "IT team or an administrator" in output + + def test_bare_launch_without_user_workspace_uses_installed_workspace(self, monkeypatch): + from ucode.managed_config import save_managed_state + + cli_mod.set_dry_run(False) + save_managed_state(self.INSTALLED, {"default_agent": "CODING_AGENT_CLAUDE_CODE"}) + state = {} + selected = [] + refreshed = [] + launched = [] + managed = {"default_agent": "claude", "enabled_agents": {"claude": {}}} + + def select(workspace): + selected.append(workspace) + state["workspace"] = workspace + + def refresh(current): + refreshed.append(current["workspace"]) + return managed, False + + monkeypatch.setattr(cli_mod, "load_state", lambda: state.copy()) + monkeypatch.setattr(cli_mod, "set_current_workspace", select) + monkeypatch.setattr(cli_mod, "install_databricks_cli", lambda **kwargs: None) + monkeypatch.setattr(cli_mod, "apply_pat_environment", lambda *args: None) + monkeypatch.setattr(cli_mod, "refresh_managed_config", refresh) + monkeypatch.setattr(cli_mod, "_fetch_budget_recommendation", lambda *args: None) + monkeypatch.setattr( + cli_mod, "_launch_tool", lambda tool, ctx, **kwargs: launched.append(tool) + ) + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert selected == refreshed == [self.INSTALLED] + assert launched == ["claude"] + + class TestRejectDisabledAgent: """`enabled_agents` is an allowlist: an agent the admin didn't enable would launch unmanaged.""" diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index 87112ced3..1286e9752 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -14,7 +14,6 @@ import ucode.databricks as db_mod import ucode.managed_config as mc_mod from ucode.managed_config import ( - ManagedPolicyInstallDenied, get_managed_config, load_managed_configuration, load_managed_state, @@ -25,7 +24,6 @@ refresh_managed_config, save_managed_state, ) -from ucode.managed_files import OS, ManagedFileWriteUnavailable from ucode.managed_setup import serialize_managed_config from ucode.telemetry import client_platform @@ -396,11 +394,10 @@ def test_save_stores_raw_and_load_normalizes(self, _managed_path): ) @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX file modes") - def test_saved_file_is_0600(self, _managed_path): + def test_saved_file_is_0644(self, _managed_path): save_managed_state("https://ws.example.com", {"default_agent": "claude"}) mode = stat.S_IMODE(os.stat(_managed_path).st_mode) - # Owner-only read/write; no group/other bits. - assert mode == 0o600 + assert mode == 0o644 def test_load_ignores_other_workspace(self, _managed_path): save_managed_state("https://ws-a.example.com", {"default_agent": "claude"}) @@ -412,12 +409,11 @@ def test_load_missing_returns_none(self, _managed_path): def test_load_none_workspace_returns_none(self, _managed_path): assert load_managed_state(None) is None - def test_empty_config_overwrites_a_previous_one(self, _managed_path): - # Saving an empty config is how "the admin removed it" is recorded: the stored config must - # be replaced, not left behind for the read-failure fallback to reapply. + def test_empty_config_removes_a_matching_policy(self, _managed_path): save_managed_state("https://ws.example.com", {"default_agent": "claude"}) save_managed_state("https://ws.example.com", {}) - assert load_managed_state("https://ws.example.com") == {} + assert load_managed_state("https://ws.example.com") is None + assert not _managed_path.exists() def test_workspace_is_stored_alongside_the_config(self, _managed_path): # `ucode setup --show` reads this when local state carries no workspace yet, so the authored @@ -428,18 +424,10 @@ def test_workspace_is_stored_alongside_the_config(self, _managed_path): def test_workspace_is_none_when_absent(self, _managed_path): assert managed_state_workspace() is None - def test_outcome_stamps_retrieved_at_and_outcome(self, _managed_path, monkeypatch): - # A read's outcome carries a retrieved_at stamp so the launch path can reuse it within the - # TTL; without an outcome the wrapper stays the bare {workspace, config} shape. - monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) - save_managed_state("https://ws.example.com", RAW_MANIFEST, outcome="published") + def test_policy_contains_only_workspace_and_raw_config(self, _managed_path): + save_managed_state("https://ws.example.com", RAW_MANIFEST) stored = json.loads(_managed_path.read_text(encoding="utf-8")) - assert stored["outcome"] == "published" - assert stored["retrieved_at"] == NOW.isoformat() - save_managed_state("https://ws.example.com", {"default_agent": "claude"}) - bare = json.loads(_managed_path.read_text(encoding="utf-8")) - assert "retrieved_at" not in bare - assert "outcome" not in bare + assert stored == {"workspace": "https://ws.example.com", "config": RAW_MANIFEST} def test_dry_run_writes_nothing(self, _managed_path, monkeypatch): # Under --dry-run the config writers print instead of touching disk, so a launch that @@ -780,14 +768,13 @@ def fetch(ws, tok): @staticmethod def _write_cache(*, config, outcome, retrieved_at, workspace=WORKSPACE): - payload = { - "workspace": workspace, - "config": config, + save_managed_state(workspace, config) + entry = { "outcome": outcome, - "retrieved_at": retrieved_at.isoformat(), + "last_checked_at": retrieved_at.isoformat(), + "policy_fingerprint": mc_mod.managed_file_fingerprint(mc_mod.MANAGED_CONFIG_PATH), } - mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - mc_mod.MANAGED_CONFIG_PATH.write_text(json.dumps(payload), encoding="utf-8") + mc_mod.REFRESH_TS_PATH.write_text(json.dumps({workspace: entry}), encoding="utf-8") @staticmethod def _no_fetch(monkeypatch): @@ -849,33 +836,26 @@ def test_future_dated_cache_refetches(self, monkeypatch): assert calls["n"] == 1 def test_missing_timestamp_refetches(self, monkeypatch): - # An old-format file (no retrieved_at) is never treated as a fresh cache. save_managed_state(WORKSPACE, RAW_MANIFEST) calls = self._counting_fetch(monkeypatch) refresh_managed_config(_state()) assert calls["n"] == 1 def test_invalid_timestamp_refetches(self, monkeypatch): - mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - mc_mod.MANAGED_CONFIG_PATH.write_text( - json.dumps( - { - "workspace": WORKSPACE, - "config": RAW_MANIFEST, - "outcome": "published", - "retrieved_at": "not-a-timestamp", - } - ), - encoding="utf-8", + self._write_cache( + config=RAW_MANIFEST, outcome="published", retrieved_at=NOW - timedelta(minutes=1) ) + stamps = json.loads(mc_mod.REFRESH_TS_PATH.read_text()) + stamps[WORKSPACE]["last_checked_at"] = "not-a-timestamp" + mc_mod.REFRESH_TS_PATH.write_text(json.dumps(stamps)) calls = self._counting_fetch(monkeypatch) refresh_managed_config(_state()) assert calls["n"] == 1 def test_other_workspace_cache_refetches(self, monkeypatch): self._write_cache( - config=RAW_MANIFEST, - outcome="published", + config={}, + outcome="none", retrieved_at=NOW - timedelta(minutes=1), workspace="https://other.example.com", ) @@ -911,26 +891,30 @@ def test_successful_read_stamps_retrieved_at_and_outcome(self, monkeypatch): refresh_managed_config(_state()) stored = json.loads(mc_mod.MANAGED_CONFIG_PATH.read_text(encoding="utf-8")) assert stored["config"] == RAW_MANIFEST - assert stored["outcome"] == "published" - assert stored["retrieved_at"] == NOW.isoformat() + assert set(stored) == {"workspace", "config"} + entry = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + assert entry["outcome"] == "published" + assert entry["last_checked_at"] == NOW.isoformat() + if sys.platform != "win32": # Windows has no POSIX file modes + assert stat.S_IMODE(mc_mod.REFRESH_TS_PATH.stat().st_mode) == 0o600 def test_no_config_read_caches_none_outcome(self, monkeypatch): self._counting_fetch(monkeypatch, result=(None, None)) refresh_managed_config(_state()) - stored = json.loads(mc_mod.MANAGED_CONFIG_PATH.read_text(encoding="utf-8")) - assert stored["config"] == {} + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + stored = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] assert stored["outcome"] == "none" - assert stored["retrieved_at"] == NOW.isoformat() + assert stored["last_checked_at"] == NOW.isoformat() def test_feature_disabled_read_caches_feature_disabled_outcome(self, monkeypatch): reason = 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED"}' self._counting_fetch(monkeypatch, result=(None, reason)) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) refresh_managed_config(_state()) - stored = json.loads(mc_mod.MANAGED_CONFIG_PATH.read_text(encoding="utf-8")) - assert stored["config"] == {} + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + stored = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] assert stored["outcome"] == "feature_disabled" - assert stored["retrieved_at"] == NOW.isoformat() + assert stored["last_checked_at"] == NOW.isoformat() def test_failed_read_does_not_advance_retrieved_at(self, monkeypatch): # A transient failure falls back to the last good config and must leave the stamp untouched, @@ -942,8 +926,623 @@ def test_failed_read_does_not_advance_retrieved_at(self, monkeypatch): result, flag = refresh_managed_config(_state()) assert result == normalize_managed_config(RAW_MANIFEST) assert flag is False - stored = json.loads(mc_mod.MANAGED_CONFIG_PATH.read_text(encoding="utf-8")) - assert stored["retrieved_at"] == stamped.isoformat() + stored = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + assert stored["last_checked_at"] == stamped.isoformat() + + +class TestMachinePolicyRefresh: + @pytest.fixture(autouse=True) + def _stub_clock_and_fetch(self, monkeypatch): + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (RAW_MANIFEST, None)) + + def test_unchanged_policy_never_invokes_privileged_writer(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + before = mc_mod.MANAGED_CONFIG_PATH.stat() + original = mc_mod.MANAGED_CONFIG_PATH.read_bytes() + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *args, **kwargs: pytest.fail("unchanged policy must not invoke sudo"), + ) + assert refresh_managed_config( + _state(), force_refresh=True + ).manifest == normalize_managed_config(RAW_MANIFEST) + after = mc_mod.MANAGED_CONFIG_PATH.stat() + assert mc_mod.MANAGED_CONFIG_PATH.read_bytes() == original + assert (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns) + entry = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + assert entry["last_checked_at"] == NOW.isoformat() + assert set(entry) == {"last_checked_at", "outcome", "policy_fingerprint"} + + def test_expired_unchanged_policy_advances_only_user_timestamp(self, monkeypatch): + refresh_managed_config(_state()) + original = mc_mod.managed_file_fingerprint(mc_mod.MANAGED_CONFIG_PATH) + later = NOW + timedelta(minutes=6) + monkeypatch.setattr(mc_mod, "_utcnow", lambda: later) + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *args, **kwargs: pytest.fail("refresh must not rewrite unchanged policy"), + ) + refresh_managed_config(_state()) + assert mc_mod.managed_file_fingerprint(mc_mod.MANAGED_CONFIG_PATH) == original + assert ( + json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE]["last_checked_at"] + == later.isoformat() + ) + + def test_server_retrieval_time_change_does_not_trigger_sudo(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + checked = {**RAW_MANIFEST, "retrieved_time": NOW.isoformat()} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (checked, None)) + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *args, **kwargs: pytest.fail("server retrieval metadata is not policy drift"), + ) + assert refresh_managed_config(_state()).manifest == normalize_managed_config(checked) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + + @pytest.mark.parametrize("force_refresh", [False, True]) + def test_only_explicit_configure_allows_policy_elevation(self, monkeypatch, force_refresh): + writes = [] + installer = mc_mod.update_workspace_policy + + def install(path, desired_text, *, interactive=False, allow_sudo=True): + writes.append((interactive, allow_sudo)) + installer(path, desired_text, interactive=interactive, allow_sudo=allow_sudo) + + monkeypatch.setattr(mc_mod, "update_workspace_policy", install) + refresh_managed_config(_state(), force_refresh=force_refresh) + assert writes == [(force_refresh, force_refresh)] + + def test_denied_changed_policy_warns_and_reuses_installed_policy(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + warnings = [] + + def denied(*args, **kwargs): + raise RuntimeError("sudo permission denied") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + result = refresh_managed_config(_state()) + assert result == (normalize_managed_config(RAW_MANIFEST), False) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + assert "Ask your IT team or an administrator" in warnings[0] + assert "Continuing with the installed policy" in warnings[0] + assert "run `ug configure`" not in warnings[0] + monkeypatch.setattr( + mc_mod, + "get_managed_config", + lambda *args: pytest.fail("a checked-but-uninstalled change must still be throttled"), + ) + assert refresh_managed_config(_state()) == result + + def test_denied_initial_install_warns_and_applies_the_fetched_policy(self, monkeypatch): + """A root run that cannot install still governs this run with the fetched policy.""" + warnings = [] + + def denied(*args, **kwargs): + raise RuntimeError("sudo permission denied") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + assert refresh_managed_config(_state()) == (normalize_managed_config(RAW_MANIFEST), False) + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + assert "Continuing with the fetched policy for this run" in warnings[0] + assert "Ask your IT team or an administrator" in warnings[0] + + def test_non_root_launch_applies_the_fetched_policy_without_installing(self, monkeypatch): + """A first launch before any configure is managed, as on main, and installs nothing.""" + monkeypatch.setattr(mc_mod, "is_admin", lambda: False) + warnings = [] + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *a, **k: pytest.fail("a non-root launch must not install the machine policy"), + ) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + assert warnings == [] + + def test_non_root_configure_installs_the_policy(self, monkeypatch): + monkeypatch.setattr(mc_mod, "is_admin", lambda: False) + + refresh_managed_config(_state(), force_refresh=True) + + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + + def test_configure_reports_failed_install_and_preserves_policy(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + + def denied(*args, **kwargs): + raise RuntimeError("sudo permission denied") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + with pytest.raises(RuntimeError, match="run `ug configure`"): + refresh_managed_config(_state(), force_refresh=True) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + assert not mc_mod.REFRESH_TS_PATH.exists() + + def test_no_remote_policy_never_creates_machine_file(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *args, **kwargs: pytest.fail("no policy must not invoke sudo"), + ) + assert refresh_managed_config(_state()) == (None, False) + assert not mc_mod.MANAGED_CONFIG_PATH.parent.exists() + assert json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE]["outcome"] == "none" + + def test_removed_policy_is_deleted_instead_of_replaced_with_empty_file(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + assert refresh_managed_config(_state()) == (None, False) + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + + @pytest.mark.parametrize("reason", [None, "FEATURE_DISABLED"]) + def test_failed_policy_removal_warns_and_preserves_installed_policy(self, monkeypatch, reason): + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, reason)) + warnings = [] + + def denied(*args, **kwargs): + raise RuntimeError("sudo permission denied") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + # A disabled feature stays reported, so callers still skip the setup recommendation. + feature_disabled = reason is not None and "FEATURE_DISABLED" in reason + assert refresh_managed_config(_state()) == ( + normalize_managed_config(RAW_MANIFEST), + feature_disabled, + ) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + assert warnings + + def test_cached_feature_disabled_read_keeps_its_flag_when_removal_failed(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda ws, token: (None, "FEATURE_DISABLED") + ) + + def denied(*args, **kwargs): + raise RuntimeError("sudo permission denied") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "print_warning", lambda *_a: None) + first = refresh_managed_config(_state()) + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda *_a: pytest.fail("a fresh stamp must be reused") + ) + + assert ( + refresh_managed_config(_state()) + == first + == ( + normalize_managed_config(RAW_MANIFEST), + True, + ) + ) + + def test_configure_for_a_workspace_without_policy_removes_another_workspaces(self, monkeypatch): + """`ug configure` decides the machine's workspace, even when it publishes no policy.""" + save_managed_state("https://other.example.com", RAW_MANIFEST) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + + assert refresh_managed_config(_state(), force_refresh=True) == (None, False) + + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + assert managed_state_workspace() is None + + def test_launch_never_removes_another_workspaces_policy(self): + """Only `ug configure` changes the machine's workspace; a launch is refused instead.""" + save_managed_state("https://other.example.com", RAW_MANIFEST) + + with pytest.raises(RuntimeError, match="Launch refused"): + refresh_managed_config(_state()) + + assert load_managed_configuration("https://other.example.com") == RAW_MANIFEST + + def test_launch_for_another_workspace_without_policy_keeps_it(self): + """Only an explicit configure may remove another workspace's policy.""" + save_managed_state("https://other.example.com", RAW_MANIFEST) + + save_managed_state(WORKSPACE, {}, allow_sudo=False) + + assert load_managed_configuration("https://other.example.com") == RAW_MANIFEST + + def test_timestamps_are_keyed_by_workspace_without_policy_copies(self, monkeypatch): + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + refresh_managed_config(_state()) + refresh_managed_config({"workspace": "https://other.example.com"}) + entries = json.loads(mc_mod.REFRESH_TS_PATH.read_text()) + assert set(entries) == {WORKSPACE, "https://other.example.com"} + assert all("config" not in entry for entry in entries.values()) + + def test_workspace_urls_with_trailing_slash_share_policy_and_timestamp(self, monkeypatch): + refresh_managed_config(_state()) + monkeypatch.setattr( + mc_mod, + "get_managed_config", + lambda *args: pytest.fail("canonical workspace should reuse the check"), + ) + assert refresh_managed_config({"workspace": WORKSPACE + "/"}) == ( + normalize_managed_config(RAW_MANIFEST), + False, + ) + + def test_external_policy_change_invalidates_recent_stamp(self, monkeypatch): + refresh_managed_config(_state()) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + save_managed_state(WORKSPACE, changed) + fetches = [] + + def fetch(*args): + fetches.append(args) + return changed, None + + monkeypatch.setattr(mc_mod, "get_managed_config", fetch) + assert refresh_managed_config(_state()).manifest == normalize_managed_config(changed) + assert len(fetches) == 1 + + def test_legacy_home_cache_is_not_a_policy_fallback(self, monkeypatch): + legacy = config_io_mod.APP_DIR / "managed-config.json" + legacy.write_text(json.dumps({"workspace": WORKSPACE, "config": RAW_MANIFEST})) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, "HTTP 503")) + assert refresh_managed_config(_state()) == (None, False) + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + + def test_launch_dry_run_does_not_fetch_or_stamp(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setattr(config_io_mod, "is_dry_run", lambda: True) + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda *args: pytest.fail("dry run must be offline") + ) + assert refresh_managed_config(_state()) == (normalize_managed_config(RAW_MANIFEST), False) + assert not mc_mod.REFRESH_TS_PATH.exists() + + @staticmethod + def _deny_policy_updates(monkeypatch): + warnings = [] + + def denied(*args, **kwargs): + raise RuntimeError("administrator update required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + return warnings + + def test_same_pending_change_warns_once_across_expired_checks(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + original = mc_mod.managed_file_fingerprint(mc_mod.MANAGED_CONFIG_PATH) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + fetches = [] + + def fetch(workspace, token): + fetches.append(workspace) + return changed, None + + monkeypatch.setattr(mc_mod, "get_managed_config", fetch) + warnings = self._deny_policy_updates(monkeypatch) + for minutes in (0, 6, 12, 24 * 60): + checked_at = NOW + timedelta(minutes=minutes) + monkeypatch.setattr(mc_mod, "_utcnow", lambda checked_at=checked_at: checked_at) + assert refresh_managed_config(_state()) == ( + normalize_managed_config(RAW_MANIFEST), + False, + ) + entry = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + assert entry["last_checked_at"] == checked_at.isoformat() + assert len(entry["pending_config_hash"]) == 64 + assert "config" not in entry + assert fetches == [WORKSPACE] * 4 + assert len(warnings) == 1 + assert mc_mod.managed_file_fingerprint(mc_mod.MANAGED_CONFIG_PATH) == original + if sys.platform != "win32": # Windows has no POSIX file modes + assert stat.S_IMODE(mc_mod.REFRESH_TS_PATH.stat().st_mode) == 0o600 + + def test_distinct_pending_change_warns_again(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + warnings = self._deny_policy_updates(monkeypatch) + hashes = [] + for index, default_agent in enumerate(("CODING_AGENT_CODEX", "CODING_AGENT_GEMINI")): + changed = {**RAW_MANIFEST, "default_agent": default_agent} + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda *args, changed=changed: (changed, None) + ) + checked_at = NOW + timedelta(minutes=index * 6) + monkeypatch.setattr(mc_mod, "_utcnow", lambda checked_at=checked_at: checked_at) + refresh_managed_config(_state()) + hashes.append( + json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE]["pending_config_hash"] + ) + assert len(warnings) == 2 + assert hashes[0] != hashes[1] + + def test_pending_hash_ignores_retrieval_time_and_object_key_order(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + warnings = self._deny_policy_updates(monkeypatch) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (changed, None)) + refresh_managed_config(_state()) + first_hash = json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE][ + "pending_config_hash" + ] + checked = dict(reversed(list({**changed, "retrieved_time": NOW.isoformat()}.items()))) + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (checked, None)) + refresh_managed_config(_state()) + assert len(warnings) == 1 + assert ( + json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE]["pending_config_hash"] + == first_hash + ) + + @pytest.mark.parametrize("reason", [None, "FEATURE_DISABLED"]) + def test_pending_removal_warns_once_across_expired_checks(self, monkeypatch, reason): + save_managed_state(WORKSPACE, RAW_MANIFEST) + warnings = self._deny_policy_updates(monkeypatch) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (None, reason)) + for minutes in (0, 6, 12): + checked_at = NOW + timedelta(minutes=minutes) + monkeypatch.setattr(mc_mod, "_utcnow", lambda checked_at=checked_at: checked_at) + assert refresh_managed_config(_state()).manifest == normalize_managed_config( + RAW_MANIFEST + ) + assert len(warnings) == 1 + assert "pending_config_hash" in json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + + @pytest.mark.parametrize("reconciled_by", ["remote", "mdm", "configure"]) + def test_reconciliation_clears_pending_hash(self, monkeypatch, reconciled_by): + save_managed_state(WORKSPACE, RAW_MANIFEST) + installer = mc_mod.update_workspace_policy + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (changed, None)) + warnings = self._deny_policy_updates(monkeypatch) + refresh_managed_config(_state()) + assert "pending_config_hash" in json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + if reconciled_by == "remote": + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (RAW_MANIFEST, None)) + elif reconciled_by == "mdm": + installer( + mc_mod.MANAGED_CONFIG_PATH, + json.dumps({"workspace": WORKSPACE, "config": changed}), + ) + else: + monkeypatch.setattr(mc_mod, "update_workspace_policy", installer) + refresh_managed_config(_state(), force_refresh=reconciled_by == "configure") + assert len(warnings) == 1 + assert ( + "pending_config_hash" not in json.loads(mc_mod.REFRESH_TS_PATH.read_text())[WORKSPACE] + ) + + def test_reintroduced_change_warns_after_remote_reverts(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + warnings = self._deny_policy_updates(monkeypatch) + for index, remote in enumerate((changed, RAW_MANIFEST, changed)): + checked_at = NOW + timedelta(minutes=index * 6) + monkeypatch.setattr(mc_mod, "_utcnow", lambda checked_at=checked_at: checked_at) + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda *args, remote=remote: (remote, None) + ) + refresh_managed_config(_state()) + assert len(warnings) == 2 + + def test_fetch_failure_preserves_pending_hash_and_check_time(self, monkeypatch): + save_managed_state(WORKSPACE, RAW_MANIFEST) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (changed, None)) + self._deny_policy_updates(monkeypatch) + refresh_managed_config(_state()) + original = mc_mod.REFRESH_TS_PATH.read_bytes() + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (None, "HTTP 503")) + refresh_managed_config(_state()) + assert mc_mod.REFRESH_TS_PATH.read_bytes() == original + + +class TestPolicyWithoutInstallation: + """Windows and UG_SKIP_OS_SETTINGS can't hold an installed policy.""" + + @pytest.fixture(autouse=True) + def _stub_clock_and_fetch(self, monkeypatch): + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (RAW_MANIFEST, None)) + + @pytest.fixture(params=["skip_os_settings", "windows"]) + def not_installable(self, request, monkeypatch): + if request.param == "skip_os_settings": + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + else: + monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) + monkeypatch.setattr( + mc_mod, + "update_workspace_policy", + lambda *a, **k: pytest.fail("nothing may be installed where policy can't be"), + ) + + @pytest.mark.parametrize("force_refresh", [False, True]) + def test_fetched_policy_applies_without_installing(self, not_installable, force_refresh): + result = refresh_managed_config(_state(), force_refresh=force_refresh) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + + def test_launch_reuses_the_per_user_cache_within_the_ttl(self, not_installable, monkeypatch): + """Unchanged from main: a recent read is reused instead of fetching again.""" + refresh_managed_config(_state()) + monkeypatch.setattr( + mc_mod, "get_managed_config", lambda *_a: pytest.fail("a fresh cache must be reused") + ) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert mc_mod.USER_MANAGED_CONFIG_PATH.exists() + assert not mc_mod.MANAGED_CONFIG_PATH.exists() + + def test_failed_fetch_falls_back_to_the_per_user_cache(self, not_installable, monkeypatch): + """Unchanged from main: an outage keeps the last saved policy in force.""" + refresh_managed_config(_state()) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *_a: (None, "503: unavailable")) + + result = refresh_managed_config(_state(), force_refresh=True) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + + def test_removed_policy_replaces_the_per_user_cache(self, not_installable, monkeypatch): + refresh_managed_config(_state()) + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *_a: (None, None)) + + assert refresh_managed_config(_state(), force_refresh=True) == (None, False) + assert load_managed_configuration(WORKSPACE) == {} + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX file modes") + def test_per_user_cache_is_private(self, not_installable): + refresh_managed_config(_state()) + + assert stat.S_IMODE(mc_mod.USER_MANAGED_CONFIG_PATH.stat().st_mode) == 0o600 + + def test_windows_ignores_a_planted_posix_policy_file(self, monkeypatch): + """On Windows the POSIX path sits under a drive root a standard user may create.""" + save_managed_state("https://planted.example.com", {"default_agent": "CODING_AGENT_CODEX"}) + monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert mc_mod.managed_state_workspace() is None + assert load_managed_configuration("https://planted.example.com") is None + + def test_export_finds_the_per_user_cache_without_a_saved_workspace(self, not_installable): + refresh_managed_config(_state()) + + assert mc_mod.managed_state_workspace() is None + assert mc_mod.managed_state_workspace(include_user_cache=True) == WORKSPACE + + def test_installed_policy_still_wins_with_skip(self, monkeypatch, capsys): + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + + def unavailable(*_a, **_k): + raise RuntimeError( + "Machine-wide workspace policy is supported only on Linux and macOS." + ) + + monkeypatch.setattr(mc_mod, "update_workspace_policy", unavailable) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST + output = " ".join(capsys.readouterr().out.split()) + assert "UG_SKIP_OS_SETTINGS is set" in output + assert "Continuing with the installed policy" in output + + def test_configure_with_skip_degrades_like_a_launch(self, monkeypatch, capsys): + """The installed policy can't be updated with the opt-out, so configure warns, not fails.""" + save_managed_state(WORKSPACE, RAW_MANIFEST) + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + + def unavailable(*_a, **_k): + raise RuntimeError( + "Machine-wide workspace policy is supported only on Linux and macOS." + ) + + monkeypatch.setattr(mc_mod, "update_workspace_policy", unavailable) + + result = refresh_managed_config(_state(), force_refresh=True) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert "UG_SKIP_OS_SETTINGS is set" in " ".join(capsys.readouterr().out.split()) + + +class TestInstalledWorkspaceLaunch: + @pytest.mark.parametrize("dry_run", [False, True]) + def test_mismatched_launch_is_refused_before_cache_auth_fetch_or_write( + self, monkeypatch, dry_run + ): + save_managed_state("https://installed.example.com", RAW_MANIFEST) + original = mc_mod.MANAGED_CONFIG_PATH.read_bytes() + monkeypatch.setattr(config_io_mod, "is_dry_run", lambda: dry_run) + for helper in ( + "_cached_result_if_fresh", + "get_databricks_token", + "get_managed_config", + "save_managed_state", + ): + monkeypatch.setattr( + mc_mod, helper, lambda *args, **kwargs: pytest.fail("mismatch must fail first") + ) + with pytest.raises(RuntimeError, match="Launch refused") as error: + refresh_managed_config(_state()) + assert "https://installed.example.com" in str(error.value) + assert WORKSPACE in str(error.value) + assert "IT team or an administrator" in str(error.value) + assert mc_mod.MANAGED_CONFIG_PATH.read_bytes() == original + assert not mc_mod.REFRESH_TS_PATH.exists() + + @pytest.mark.parametrize( + "selected", [None, WORKSPACE, WORKSPACE + "/", WORKSPACE.removeprefix("https://")] + ) + def test_installed_workspace_is_the_refresh_source(self, monkeypatch, selected): + save_managed_state(WORKSPACE, RAW_MANIFEST) + calls = [] + + def token(workspace, profile): + assert workspace == WORKSPACE + assert profile == "developer" + return "tok" + + def fetch(workspace, token): + calls.append(workspace) + return RAW_MANIFEST, None + + monkeypatch.setattr(mc_mod, "get_databricks_token", token) + monkeypatch.setattr(mc_mod, "get_managed_config", fetch) + assert refresh_managed_config({"workspace": selected, "profile": "developer"}).manifest == ( + normalize_managed_config(RAW_MANIFEST) + ) + assert calls == [WORKSPACE] + + def test_foreign_warm_unmanaged_stamp_cannot_bypass_installed_workspace(self, monkeypatch): + mc_mod._record_refresh(WORKSPACE, mc_mod.ManagedConfigResult(None, False)) + save_managed_state("https://installed.example.com", RAW_MANIFEST) + entry = json.loads(mc_mod.REFRESH_TS_PATH.read_text()) + entry[WORKSPACE]["policy_fingerprint"] = mc_mod.managed_file_fingerprint( + mc_mod.MANAGED_CONFIG_PATH + ) + mc_mod.REFRESH_TS_PATH.write_text(json.dumps(entry)) + with pytest.raises(RuntimeError, match="Launch refused"): + refresh_managed_config(_state()) + + def test_explicit_configure_can_replace_the_machine_workspace(self, monkeypatch): + save_managed_state("https://installed.example.com", RAW_MANIFEST) + monkeypatch.setattr(mc_mod, "get_databricks_token", lambda *args: "tok") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda *args: (RAW_MANIFEST, None)) + assert refresh_managed_config(_state(), force_refresh=True).manifest == ( + normalize_managed_config(RAW_MANIFEST) + ) + assert managed_state_workspace() == WORKSPACE class TestManagedUpdateTime: @@ -1077,152 +1676,3 @@ def test_unparseable_decimals_become_none(self, monkeypatch): ) rec, _ = mc_mod.get_model_recommendation("https://w", "tok") assert rec is not None and rec["current_spend"] is None - - -OTHER_WORKSPACE = "https://other.example.com" - - -class TestMachinePolicyInstall: - """`ug configure` installs the workspace policy machine-wide; a launch never writes it.""" - - @pytest.fixture(autouse=True) - def _stub_token(self, monkeypatch): - monkeypatch.setattr(mc_mod, "get_databricks_token", lambda ws, profile: "tok") - - @staticmethod - def _recording_installer(monkeypatch) -> list[object]: - """Record every installer write, still applying it to the redirected machine path.""" - calls: list[object] = [] - install = mc_mod.update_workspace_policy - - def record(path, desired_text, **kwargs): - calls.append(desired_text) - install(path, desired_text, **kwargs) - - monkeypatch.setattr(mc_mod, "update_workspace_policy", record) - return calls - - @staticmethod - def _installed() -> dict | None: - path = mc_mod.MACHINE_POLICY_PATH - return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None - - def test_configure_installs_workspace_and_config(self, monkeypatch): - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - assert self._installed() == {"workspace": WORKSPACE, "config": RAW_MANIFEST} - # The per-user cache keeps main's behavior: it is still written on configure. - assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST - - def test_unchanged_policy_writes_nothing(self, monkeypatch): - calls = self._recording_installer(monkeypatch) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - # A re-read differs only in retrieved_time, which is not policy content. - reread = {**RAW_MANIFEST, "retrieved_time": "2030-01-01T00:00:00Z"} - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (reread, None)) - refresh_managed_config(_state(), force_refresh=True) - assert len(calls) == 1 - - def test_changed_policy_is_reinstalled(self, monkeypatch): - calls = self._recording_installer(monkeypatch) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - changed = {**RAW_MANIFEST, "mcp_servers": {"names": ["system.ai.other"]}} - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (changed, None)) - refresh_managed_config(_state(), force_refresh=True) - assert len(calls) == 2 - assert (self._installed() or {})["config"] == changed - - def test_configure_without_policy_removes_the_same_workspaces_policy(self, monkeypatch): - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - refresh_managed_config(_state(), force_refresh=True) - assert self._installed() is None - - def test_configure_without_policy_removes_another_workspaces_policy(self, monkeypatch): - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(workspace=OTHER_WORKSPACE), force_refresh=True) - assert (self._installed() or {})["workspace"] == OTHER_WORKSPACE - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - refresh_managed_config(_state(), force_refresh=True) - assert self._installed() is None - - def test_feature_disabled_configure_removes_installed_policy(self, monkeypatch): - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - disabled = (None, "FEATURE_DISABLED: coding agent configs are off") - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: disabled) - result = refresh_managed_config(_state(), force_refresh=True) - assert result == (None, True) - assert self._installed() is None - - def test_launch_never_writes_the_machine_policy(self, monkeypatch): - calls = self._recording_installer(monkeypatch) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state()) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - refresh_managed_config(_state(), force_refresh=False) - assert calls == [] - assert self._installed() is None - - def test_launch_within_ttl_reads_the_per_user_cache(self, monkeypatch): - monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - monkeypatch.setattr( - mc_mod, - "get_managed_config", - lambda ws, tok: pytest.fail("a launch within the TTL must not fetch"), - ) - result, _ = refresh_managed_config(_state()) - assert result == normalize_managed_config(RAW_MANIFEST) - - def test_denied_install_raises_actionable_error(self, monkeypatch): - def denied(path, desired_text, **kwargs): - raise ManagedFileWriteUnavailable("a password is required") - - monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - with pytest.raises(ManagedPolicyInstallDenied, match="needs sudo") as error: - refresh_managed_config(_state(), force_refresh=True) - assert isinstance(error.value, RuntimeError) - assert "ug configure" in str(error.value) - assert "a password is required" in str(error.value) - - def test_denied_removal_raises(self, monkeypatch): - def denied(path, desired_text, **kwargs): - raise ManagedFileWriteUnavailable("sudo: a password is required") - - monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - with pytest.raises(ManagedPolicyInstallDenied): - refresh_managed_config(_state(), force_refresh=True) - - def test_denied_launch_refresh_does_not_raise(self, monkeypatch): - def denied(path, desired_text, **kwargs): - raise ManagedFileWriteUnavailable("a password is required") - - monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - result, _ = refresh_managed_config(_state()) - assert result == normalize_managed_config(RAW_MANIFEST) - - @pytest.mark.parametrize("platform", [OS.WINDOWS, OS.OTHER]) - def test_platforms_without_machine_policy_never_write(self, monkeypatch, platform): - calls = self._recording_installer(monkeypatch) - monkeypatch.setattr(mc_mod, "current_os", lambda: platform) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - result, _ = refresh_managed_config(_state(), force_refresh=True) - assert calls == [] - assert result == normalize_managed_config(RAW_MANIFEST) - assert load_managed_configuration(WORKSPACE) == RAW_MANIFEST - - def test_skip_os_settings_never_writes_the_machine_policy(self, monkeypatch): - calls = self._recording_installer(monkeypatch) - monkeypatch.setattr(mc_mod, "managed_settings_skipped", lambda: True) - monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (RAW_MANIFEST, None)) - refresh_managed_config(_state(), force_refresh=True) - assert calls == [] - assert self._installed() is None diff --git a/tests/test_tui_request_recorder.py b/tests/test_tui_request_recorder.py index 9ddc3f029..223551f20 100644 --- a/tests/test_tui_request_recorder.py +++ b/tests/test_tui_request_recorder.py @@ -1,11 +1,14 @@ import json +import sys import threading from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path import httpx import pytest +import tests.e2e_cuj.helpers.tui_request_recorder as recorder_module from tests.e2e_cuj.helpers.tui_request_recorder import ( RecordedRequest, RecordedResponse, @@ -58,9 +61,7 @@ def test_records_request_and_response(recorder): assert_response(recorder.response_for(request)) -def test_configures_isolated_session_state_for_recorder(recorder, tmp_path): - commands = [] - +def _configured_session(recorder, tmp_path, commands): class Session: home = tmp_path @@ -82,18 +83,29 @@ def configure(self, args): } ) ) - (app / "managed-config.json").write_text( + (app / "refresh_ts.json").write_text( json.dumps( { - "workspace": recorder.upstream, - "config": {"spec_version": 1}, - "retrieved_at": "2000-01-01T00:00:00+00:00", - "outcome": "published", + recorder.upstream: { + "last_checked_at": "2000-01-01T00:00:00+00:00", + "outcome": "published", + "policy_fingerprint": {"exists": False}, + } } ) ) - recorder.configure_session(Session(), ["configure", "--disable-databricks-ai-tools"]) + return Session() + + +def test_configures_isolated_session_state_for_recorder(recorder, tmp_path, monkeypatch): + commands = [] + monkeypatch.setattr(recorder_module, "WORKSPACE_POLICY_PATH", tmp_path / "absent.json") + + recorder.configure_session( + _configured_session(recorder, tmp_path, commands), + ["configure", "--disable-databricks-ai-tools"], + ) assert commands == [ [ @@ -106,14 +118,31 @@ def configure(self, args): state = json.loads((tmp_path / ".ucode/state.json").read_text()) assert state["current_workspace"] == recorder.url assert state["workspaces"][recorder.url] == state["workspaces"][recorder.upstream] - managed_path = tmp_path / ".ucode/managed-config.json" - managed = json.loads(managed_path.read_text()) - assert managed["workspace"] == recorder.url - first_refresh = datetime.fromisoformat(managed["retrieved_at"]) - - managed["retrieved_at"] = "2000-01-01T00:00:00+00:00" - managed_path.write_text(json.dumps(managed)) + refresh = json.loads((tmp_path / ".ucode/refresh_ts.json").read_text()) + assert refresh[recorder.url] == refresh[recorder.upstream] + # Without an installed policy, launches fetch it through the recorder: nothing to refresh. recorder.prepare_launch() + assert json.loads((tmp_path / ".ucode/refresh_ts.json").read_text()) == refresh + + +@pytest.mark.skipif(sys.platform == "win32", reason="CUJs run on Linux") +def test_retargets_an_installed_policy_to_the_recorder(recorder, tmp_path, monkeypatch): + policy = tmp_path / "managed-settings.json" + policy.write_text(json.dumps({"workspace": recorder.upstream, "config": {"spec_version": 1}})) + monkeypatch.setattr(recorder_module, "WORKSPACE_POLICY_PATH", policy) + copies = [] + + def fake_sudo_cp(command, check): + assert command[:2] == ["sudo", "cp"] and check is True + copies.append(command[3]) + policy.write_text(Path(command[2]).read_text()) + + monkeypatch.setattr(recorder_module.subprocess, "run", fake_sudo_cp) + + recorder.configure_session(_configured_session(recorder, tmp_path, []), ["configure"]) - refreshed = json.loads(managed_path.read_text()) - assert datetime.fromisoformat(refreshed["retrieved_at"]) >= first_refresh + assert copies == [str(policy)] + assert json.loads(policy.read_text())["workspace"] == recorder.url + entry = json.loads((tmp_path / ".ucode/refresh_ts.json").read_text())[recorder.url] + assert datetime.fromisoformat(entry["last_checked_at"]).year > 2000 + assert entry["policy_fingerprint"]["exists"] is True From 0731949844a711cbaeeac750668cea3b9c75b5ed Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:52:27 +0000 Subject: [PATCH 3/8] Remove the workspace policy on ug revert `ug revert` deletes /etc/unitygateway/managed-settings.json with sudo; nothing else writes that file. Without sudo it warns and keeps it, and Windows, UG_SKIP_OS_SETTINGS and `--user` leave it alone. Test cleanup keeps a backstop removal in its own finally, so a failed or skipped revert can no longer leave the policy behind for later cases. Co-authored-by: Isaac --- docs/managed-workspace-policy.md | 1 + src/ucode/cli.py | 3 ++ src/ucode/managed_config.py | 19 ++++++++++++ tests/e2e_cuj/helpers/session.py | 16 +++++----- tests/integration/conftest.py | 52 +++++++++++++++++--------------- tests/test_managed_config.py | 43 ++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 32 deletions(-) diff --git a/docs/managed-workspace-policy.md b/docs/managed-workspace-policy.md index e95fc3e96..a3678a546 100644 --- a/docs/managed-workspace-policy.md +++ b/docs/managed-workspace-policy.md @@ -46,6 +46,7 @@ Launches never install or remove the machine-wide policy. Per-user caching of th - The policy describes one workspace for the whole machine. Configuring another workspace replaces it for all users. +- `ug revert` removes the policy, with sudo. Without sudo it warns and keeps the file. - Installation needs sudo on Linux and macOS. On MDM machines, ask IT to run the provisioning step rather than attempting administrator-only configure yourself. - Live sudo and ownership checks need a disposable clean machine, not a developer workstation. diff --git a/src/ucode/cli.py b/src/ucode/cli.py index e237c13e8..329d6db00 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -101,6 +101,7 @@ load_managed_state, normalize_managed_config, refresh_managed_config, + revert_machine_policy, ) from ucode.managed_files import managed_write_session, set_config_scope from ucode.managed_resolve import ( @@ -1462,6 +1463,7 @@ def revert() -> int: print_warning(f"Could not remove leftover Copilot session files: {exc}") claude_managed_result = claude_agent.revert_managed_settings() codex_managed_result = codex_agent.revert_managed_config() + workspace_policy_result = revert_machine_policy() results: dict[str, bool] = { tool: restore_file( @@ -1484,6 +1486,7 @@ def revert() -> int: print_kv("Claude Code OS-managed settings", claude_managed_result) print_kv("Claude Desktop profile", desktop_result) print_kv("Codex OS-managed settings", codex_managed_result) + print_kv("Unity Gateway workspace policy", workspace_policy_result) print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") for client, spec in MCP_CLIENTS.items(): print_kv( diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 65727235c..145f8e774 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -723,6 +723,25 @@ def _install_machine_policy(workspace: str, config: dict) -> None: ) +def revert_machine_policy() -> str: + """Remove the installed workspace policy for ``ug revert``: "removed", "unchanged", or "failed". + + Nothing else writes this file, so revert deletes it outright. Without sudo it warns and keeps + the file, like the other machine-wide settings revert restores. + """ + if not _machine_policy_installable() or not MACHINE_POLICY_PATH.exists(): + return "unchanged" + try: + update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) + except RuntimeError as exc: + print_warning( + f"Could not remove the workspace policy at {MACHINE_POLICY_PATH}: {exc} Rerun " + "`ug revert` from a terminal where you can use sudo." + ) + return "failed" + return "removed" + + def _sync_machine_policy(workspace: str, config: dict, *, force_refresh: bool) -> None: """Install or remove the machine policy for an explicit configure; launches never write it. diff --git a/tests/e2e_cuj/helpers/session.py b/tests/e2e_cuj/helpers/session.py index a4cd8dc30..a7ad39e0f 100644 --- a/tests/e2e_cuj/helpers/session.py +++ b/tests/e2e_cuj/helpers/session.py @@ -76,11 +76,13 @@ def configure(self, args): def revert_machine_wide(self, name, message): state_dir = self.home / ".ucode" - if any((state_dir / file).is_file() for file in MANAGED_STATE_FILES): - with TerminalProcess(self, "ug", [str(self.binary), "revert"], name) as terminal: - terminal.finish() - # `ug revert` leaves the installed workspace policy, like an uninstall would not. - policy = constants.WORKSPACE_POLICY_PATH - if policy.exists(): - self.run("rm", "-f", str(policy), binary=self.which("sudo")) + try: + if any((state_dir / file).is_file() for file in MANAGED_STATE_FILES): + with TerminalProcess(self, "ug", [str(self.binary), "revert"], name) as terminal: + terminal.finish() + finally: + # Backstop: `ug revert` removes the workspace policy, but it can fail or be skipped. + policy = constants.WORKSPACE_POLICY_PATH + if policy.exists(): + self.run("rm", "-f", str(policy), binary=self.which("sudo")) assert not any(path.exists() for path in constants.MANAGED_PATHS), message diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 452945301..4bd0e2021 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -144,31 +144,33 @@ def session(request, installed_binary): try: yield user finally: - # Restore machine-level settings through the same public CLI that - # created them. A later fresh-home test must not inherit this setup. - if any( - (user.home / ".ucode" / name).is_file() - for name in ("state.json", "managed-backups/manifest.json") - ): - if os.name == "posix": - # Import PTY support only when a live-capable run actually - # created state that teardown must revert. - from utils.terminal import TerminalProcess - - with TerminalProcess( - user, "ug", [str(user.binary), "revert"], "cleanup-revert" - ) as terminal: - terminal.finish() - else: - # Installation checks are noninteractive; if a regression - # creates state, still clean it through the installed CLI. - user.run("revert", timeout=120) - # `ug revert` keeps the machine's workspace policy; remove it like an uninstall so a - # later fresh-home case, or the next run's policy preflight, starts clean. - policy = Path("/etc/unitygateway/managed-settings.json") - sudo = shutil.which("sudo") - if os.name == "posix" and policy.exists() and sudo: - user.run("-n", "rm", "-f", str(policy), binary=Path(sudo), timeout=30) + try: + # Restore machine-level settings through the same public CLI that + # created them. A later fresh-home test must not inherit this setup. + if any( + (user.home / ".ucode" / name).is_file() + for name in ("state.json", "managed-backups/manifest.json") + ): + if os.name == "posix": + # Import PTY support only when a live-capable run actually + # created state that teardown must revert. + from utils.terminal import TerminalProcess + + with TerminalProcess( + user, "ug", [str(user.binary), "revert"], "cleanup-revert" + ) as terminal: + terminal.finish() + else: + # Installation checks are noninteractive; if a regression + # creates state, still clean it through the installed CLI. + user.run("revert", timeout=120) + finally: + # Backstop: `ug revert` removes the workspace policy, but it can fail or be + # skipped, and a later fresh-home case or the next run's preflight must start clean. + policy = Path("/etc/unitygateway/managed-settings.json") + sudo = shutil.which("sudo") + if os.name == "posix" and policy.exists() and sudo: + user.run("-n", "rm", "-f", str(policy), binary=Path(sudo), timeout=30) @pytest.fixture diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index 87112ced3..0585eb673 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -1226,3 +1226,46 @@ def test_skip_os_settings_never_writes_the_machine_policy(self, monkeypatch): refresh_managed_config(_state(), force_refresh=True) assert calls == [] assert self._installed() is None + + +class TestRevertMachinePolicy: + def test_revert_removes_the_installed_policy(self): + mc_mod.MACHINE_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.MACHINE_POLICY_PATH.write_text('{"workspace": "x", "config": {}}', encoding="utf-8") + + assert mc_mod.revert_machine_policy() == "removed" + assert not mc_mod.MACHINE_POLICY_PATH.exists() + + def test_revert_without_a_policy_writes_nothing(self, monkeypatch): + monkeypatch.setattr( + mc_mod, "update_workspace_policy", lambda *a, **k: pytest.fail("nothing to remove") + ) + + assert mc_mod.revert_machine_policy() == "unchanged" + + @pytest.mark.parametrize("mode", ["skip_os_settings", "windows"]) + def test_revert_leaves_the_policy_where_ug_does_not_manage_it(self, monkeypatch, mode): + mc_mod.MACHINE_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.MACHINE_POLICY_PATH.write_text("{}", encoding="utf-8") + if mode == "skip_os_settings": + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + else: + monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) + + assert mc_mod.revert_machine_policy() == "unchanged" + assert mc_mod.MACHINE_POLICY_PATH.exists() + + def test_revert_without_sudo_warns_and_keeps_the_policy(self, monkeypatch): + mc_mod.MACHINE_POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.MACHINE_POLICY_PATH.write_text("{}", encoding="utf-8") + warnings = [] + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + + def denied(*_a, **_k): + raise RuntimeError("sudo: a password is required") + + monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) + + assert mc_mod.revert_machine_policy() == "failed" + assert mc_mod.MACHINE_POLICY_PATH.exists() + assert "ug revert" in warnings[0] From b25da2d8b865ff94b2acb39ce25e46269e468ab9 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:41:54 -0700 Subject: [PATCH 4/8] Update src/ucode/managed_files.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/ucode/managed_files.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 9009ef37e..eef4eac22 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -1257,7 +1257,10 @@ def update_workspace_policy( _sudo_replace(path, desired_text) else: with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as source: - source.write(desired_text if desired_text is not None else current_text or "") + with tempfile.NamedTemporaryFile(mode="wb", suffix=".json") as source: + source.write( + desired_text.encode("utf-8") if desired_text is not None else path.read_bytes() + ) source.flush() subprocess_cross_os.run( _sudo_replace_command( From f55d62dc42e9590d4d6467aaa26e6602471a7631 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:46:55 -0700 Subject: [PATCH 5/8] Update src/ucode/managed_files.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/ucode/managed_files.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index eef4eac22..dafd1e2e4 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -1256,7 +1256,6 @@ def update_workspace_policy( if desired_text is not None and can_prompt: _sudo_replace(path, desired_text) else: - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as source: with tempfile.NamedTemporaryFile(mode="wb", suffix=".json") as source: source.write( desired_text.encode("utf-8") if desired_text is not None else path.read_bytes() From c3f49b75fd17b7a3902cce530dd1df8cb84789dc Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:47:34 -0700 Subject: [PATCH 6/8] Update tests/integration/conftest.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- tests/integration/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4bd0e2021..e955df3b7 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -170,7 +170,7 @@ def session(request, installed_binary): policy = Path("/etc/unitygateway/managed-settings.json") sudo = shutil.which("sudo") if os.name == "posix" and policy.exists() and sudo: - user.run("-n", "rm", "-f", str(policy), binary=Path(sudo), timeout=30) + user.run("-n", "rm", "-f", str(policy), binary=Path(sudo), timeout=30, ok=False) @pytest.fixture From f8b2f1d12751e5569dedaabe9cc1ce7190929186 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 03:47:12 +0000 Subject: [PATCH 7/8] Test that policy removal compares the raw bytes Removal hands the replace script the exact bytes of the installed file, so a file with a BOM or CRLF endings still matches its byte-for-byte check. Add a regression test and a comment on why the copy is binary. Co-authored-by: Isaac --- src/ucode/managed_files.py | 1 + tests/test_managed_files.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index dafd1e2e4..5e355649c 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -1256,6 +1256,7 @@ def update_workspace_policy( if desired_text is not None and can_prompt: _sudo_replace(path, desired_text) else: + # Raw bytes, so removal's byte-for-byte check matches a file with a BOM or CRLF endings. with tempfile.NamedTemporaryFile(mode="wb", suffix=".json") as source: source.write( desired_text.encode("utf-8") if desired_text is not None else path.read_bytes() diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index a03e83934..e3d45cab9 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -732,6 +732,22 @@ def slow(command, **kwargs): assert "sudo: still waiting" in str(error.value) assert "b'" not in str(error.value) + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") + def test_policy_removal_compares_the_raw_bytes(self, policy_path, monkeypatch): + """A BOM or CRLF endings must survive into the copy the script compares before deleting.""" + raw = b'\xef\xbb\xbf{"workspace": "x",\r\n "config": {}}\r\n' + policy_path.write_bytes(raw) + seen = {} + + def remove(command, **kwargs): + seen["source"] = Path(command[-2]).read_bytes() + policy_path.unlink() + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", remove) + managed_files.update_workspace_policy(policy_path, None, allow_sudo=True) + assert seen["source"] == raw + @pytest.mark.skipif(sys.platform == "win32", reason="The workspace-policy writer is Unix-only") def test_root_policy_update_does_not_need_sudo(self, policy_path, monkeypatch): monkeypatch.setattr(managed_files.os, "geteuid", lambda: 0) From 56f0bcba4807b980c34be83af4e3c60c7398d2bf Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 03:57:38 +0000 Subject: [PATCH 8/8] Address UG review on launch enforcement messages and tests - The --agent refusal says the selection stays on the installed workspace and to rerun configure from a terminal with sudo access. - The launch refusal tells skip-mode users to switch without UG_SKIP_OS_SETTINGS, which is the only way the policy can change. - _launch_workspace_state resolves the target workspace once. - The configure integration test checks the policy folder is root-owned. Co-authored-by: Isaac --- src/ucode/cli.py | 23 ++++++++----------- src/ucode/managed_config.py | 4 +++- .../integration/test_ug_configure_managed.py | 1 + tests/test_cli.py | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 6d84bc659..cc09a6651 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -840,7 +840,8 @@ def _read_policy_for_other_agent(state: dict) -> ManagedConfigResult: if installed: raise RuntimeError( f"This machine is managed for {installed}, so it cannot be configured for " - f"{state['workspace']}. Run `ug configure --workspace {state['workspace']}` with sudo " + f"{state['workspace']}. Your selection stays on {installed}. Run " + f"`ug configure --workspace {state['workspace']}` from a terminal where you can use sudo " "to switch the machine, or ask your IT team or an administrator." ) return refresh_managed_config(state) @@ -851,20 +852,16 @@ def _launch_workspace_state(workspace: str | None) -> dict: Adopts the installed workspace when none is saved, so a fresh user launches it directly. """ - flag_workspace = managed_launch_workspace(workspace) if workspace else None - if flag_workspace: - set_current_workspace(flag_workspace) state = load_state() - if flag_workspace and state.get("workspace") != flag_workspace: - # A dry run saves nothing, so carry the requested workspace for this run. - state = {**state, "workspace": flag_workspace} - current = managed_launch_workspace(state.get("workspace")) - if current and not state.get("workspace"): - set_current_workspace(current) + saved = state.get("workspace") + # The flag wins over the saved selection; either is refused if it isn't the installed one. + target = managed_launch_workspace(workspace or saved) + if target and (not saved or normalize_workspace_url(saved) != target): + set_current_workspace(target) state = load_state() - if not state.get("workspace"): - # A dry run saves nothing, so carry the adopted workspace for this run. - state = {**state, "workspace": current} + if state.get("workspace") != target: + # A dry run saves nothing, so carry the workspace for this run. + state = {**state, "workspace": target} return state diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 4a408d3dc..9612c6fee 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -807,10 +807,12 @@ def managed_launch_workspace(workspace: str | None) -> str | None: installed = normalize_workspace_url(installed) if installed and installed.strip() else None selected = normalize_workspace_url(workspace) if workspace else None if installed and selected and selected != installed: + # UG_SKIP_OS_SETTINGS never writes the policy, so the switch must run without it. + without_skip = f" without {SKIP_OS_SETTINGS_ENV}" if managed_settings_skipped() else "" raise RuntimeError( f"This machine is managed for {installed}, but your selected workspace is {selected}. " f"Launch refused. Use `--workspace {installed}`, or ask your IT team or an administrator " - f"to switch the machine with `ug configure --workspace {selected}`." + f"to switch the machine with `ug configure --workspace {selected}`{without_skip}." ) return installed or selected diff --git a/tests/integration/test_ug_configure_managed.py b/tests/integration/test_ug_configure_managed.py index 6c8587181..adc31f70b 100644 --- a/tests/integration/test_ug_configure_managed.py +++ b/tests/integration/test_ug_configure_managed.py @@ -139,6 +139,7 @@ def test_ug_managed_config_launch_reuses_cache_within_ttl(live_session, workspac installed_stat = policy.stat() assert installed_stat.st_uid == 0 assert installed_stat.st_mode & 0o777 == 0o644 + assert policy.parent.stat().st_uid == 0 assert policy.parent.stat().st_mode & 0o777 == 0o755 assert set(json.loads(installed)) == {"workspace", "config"} fresh = json.loads(refresh.read_text())[workspace_key] diff --git a/tests/test_cli.py b/tests/test_cli.py index 70d23e66f..5a0566d8c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6431,7 +6431,7 @@ def boom(*_a, **_k): class TestDryRunAdoptsInstalledWorkspace: - def test_dry_run_launch_state_uses_the_installed_workspace(self, monkeypatch): + def test_dry_run_launch_state_uses_the_installed_workspace(self): """A dry run saves nothing, so the adopted workspace is carried for that run.""" from ucode.managed_config import save_managed_state