From 836d0883893016ffb9e3c2eb17e86b62c67b497f Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:00:46 +0000 Subject: [PATCH 1/9] 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/9] 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/9] 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 1d230abf113ea0670dab9749b0060e1ec08ef874 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 03:03:04 +0000 Subject: [PATCH 4/9] Install and enforce the workspace policy on Windows Windows now holds the installed workspace policy like Linux and macOS: %ProgramData%\UnityGateway\managed-settings.json, readable by every user. - An elevated `ug configure` writes, replaces, or removes it atomically; the UnityGateway folder is secured to Administrators (standard users read only) and links are refused. An elevated `ug revert` removes it. - A non-elevated configure fails with an administrator hint; a launch never elevates, and a non-elevated launch applies the fetched policy for that run when nothing is installed. - Launches on Windows read and enforce only the ProgramData file. - The per-user cache remains only for UG_SKIP_OS_SETTINGS and --user. - The integration preflight and cache-path helpers include the Windows policy path. Co-authored-by: Isaac --- docs/managed-workspace-policy.md | 25 ++-- scripts/run_integration.py | 12 +- specs/features/machine-managed-policy.md | 4 +- src/ucode/managed_config.py | 37 +++-- src/ucode/managed_files.py | 161 ++++++++++++++------ tests/README.md | 4 +- tests/integration/utils/managed.py | 6 +- tests/test_integration_runner.py | 1 + tests/test_managed_config.py | 180 ++++++++++++++++++++--- tests/test_managed_files.py | 130 ++++++++++++++++ 10 files changed, 461 insertions(+), 99 deletions(-) diff --git a/docs/managed-workspace-policy.md b/docs/managed-workspace-policy.md index 0b82a6be8..faa188b52 100644 --- a/docs/managed-workspace-policy.md +++ b/docs/managed-workspace-policy.md @@ -2,7 +2,8 @@ `ug configure` reads the workspace's published `CodingAgentConfig` through `GET /api/ai-gateway/v2/coding-agent-configs` and installs it at -`/etc/unitygateway/managed-settings.json` on Linux/macOS. +`/etc/unitygateway/managed-settings.json` on Linux/macOS or +`%ProgramData%\UnityGateway\managed-settings.json` on Windows. The file contains only: @@ -16,7 +17,10 @@ The file contains only: `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. +users can read without sudo. +On Windows the `UnityGateway` folder is owned by Administrators (SYSTEM and Administrators have +full control, standard users read and execute only), and the file inherits that access, so only an +elevated process can change it. ug refuses to write through a linked folder. 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 @@ -89,23 +93,26 @@ All other raw fields participate in comparison. Policy is otherwise preserved wh ## Migration and limits -On Linux and macOS, the per-user `~/.ucode/managed-config.json` is no longer read and is never +On Linux, macOS, and Windows, 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. -`ug revert` removes the installed policy with sudo; without sudo it warns and keeps the file. +`ug revert` removes the installed policy with sudo (Windows: from an elevated terminal); without +them it warns and keeps the file. 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. +Windows has no sudo: only an elevated terminal (Run as administrator) can run `ug configure` or +`ug revert` against the policy, and a standard-user `ug configure` fails with that hint. A launch +never elevates; like root on Linux and macOS, an already-elevated launch may update the file. The +Windows folder permissions are applied with `icacls` and are not yet verified on a real Windows +host. Real sudo/password, `icacls`, 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 +Where the machine cannot hold the policy (`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 +minutes, and a failed fetch falls back to it. A non-root, non-elevated 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 diff --git a/scripts/run_integration.py b/scripts/run_integration.py index c40493304..933822be1 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -77,12 +77,14 @@ def managed_policy_paths( selected = set(agents) paths: list[Path] = [] if platform_name == "nt": + program_data = environment.get("PROGRAMDATA", "").strip() + if not program_data: + raise RuntimeError( + "Cannot verify Unity Gateway workspace policy because PROGRAMDATA is unavailable." + ) + # Unity Gateway's installed workspace policy affects every agent. + paths.append(Path(program_data) / "UnityGateway" / "managed-settings.json") if "codex" in selected: - program_data = environment.get("PROGRAMDATA", "").strip() - if not program_data: - raise RuntimeError( - "Cannot verify Codex system policy because PROGRAMDATA is unavailable." - ) codex_system = Path(program_data) / "OpenAI" / "Codex" paths.extend([codex_system / "requirements.toml", codex_system / "config.toml"]) if "claude" in selected: diff --git a/specs/features/machine-managed-policy.md b/specs/features/machine-managed-policy.md index bc766f769..0b26166cc 100644 --- a/specs/features/machine-managed-policy.md +++ b/specs/features/machine-managed-policy.md @@ -7,7 +7,7 @@ created: 2026-10-12 ## 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. +Install workspace policy in `/etc/unitygateway/managed-settings.json` (Windows: `%ProgramData%\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 @@ -40,7 +40,7 @@ The existing privileged writer atomically installs allowlisted Claude/Codex sett ## 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. + A1: Linux and macOS use `/etc/unitygateway/managed-settings.json`; Windows uses `%ProgramData%\UnityGateway\managed-settings.json`, written only by an elevated process into an Administrators-owned folder (standard users read only; no sudo, so a non-elevated configure fails with an administrator hint, and revert warns and keeps the file). 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? diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 4a408d3dc..f964b5f6e 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -713,9 +713,13 @@ def revert_machine_policy() -> str: try: update_workspace_policy(MANAGED_CONFIG_PATH, None, interactive=True, allow_sudo=True) except RuntimeError as exc: + retry = ( + "Rerun `ug revert` from an elevated terminal (Run as administrator)." + if current_os() is OS.WINDOWS + else "Rerun `ug revert` from a terminal where you can use sudo." + ) print_warning( - f"Could not remove the workspace policy at {MANAGED_CONFIG_PATH}: {exc} Rerun " - "`ug revert` from a terminal where you can use sudo." + f"Could not remove the workspace policy at {MANAGED_CONFIG_PATH}: {exc} {retry}" ) return "failed" return "removed" @@ -762,8 +766,8 @@ def load_managed_configuration(workspace: str | None) -> dict | None: 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. + On Windows only the protected ProgramData file is read, never a POSIX-style path under a drive + root that a standard user may be able to create. """ if not _machine_policy_supported(): return None @@ -930,10 +934,18 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag ) except RuntimeError as exc: if force_refresh and _machine_policy_installable(): + if current_os() is OS.WINDOWS: + hint = ( + "Run `ug configure` from an elevated terminal (Run as administrator), or ask " + "your IT team or an administrator to run it." + ) + else: + hint = ( + "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." + ) 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." + f"Could not install the workspace policy at {MANAGED_CONFIG_PATH}: {exc} {hint}" ) raise ManagedPolicyInstallDenied(message) from exc installed = load_managed_state(workspace) @@ -973,13 +985,16 @@ def refresh_managed_config(state: dict, *, force_refresh: bool = False) -> Manag 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) + """Whether ug installs a machine policy on this OS at all (Linux, macOS, and Windows). + + On Windows the path is the protected ProgramData file, never the POSIX one. + """ + return current_os() in (OS.LINUX, OS.MACOS, OS.WINDOWS) 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``).""" + """Whether this machine can hold the installed workspace policy (Linux, macOS, or Windows, not + opted out with ``UG_SKIP_OS_SETTINGS``).""" return not managed_settings_skipped() and _machine_policy_supported() diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 9009ef37e..4bf4f8594 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -42,7 +42,6 @@ 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 @@ -68,36 +67,50 @@ class OS(Enum): MANAGED_FILES_SUPPORTED_OSES = (OS.LINUX, OS.MACOS, OS.WINDOWS) + +def current_os() -> OS: + """Map `sys.platform` onto :class:`OS` (lowercased, so a mixed-case value can't slip through).""" + platform = sys.platform.lower() + if platform.startswith("linux"): + return OS.LINUX + if platform == "darwin": + return OS.MACOS + if platform.startswith("win"): + return OS.WINDOWS + return OS.OTHER + + +_POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH = Path("/etc/unitygateway/managed-settings.json") + + +def unity_gateway_managed_settings_path() -> Path: + """The machine-wide workspace policy: root-owned on Linux/macOS, administrator-only under + ProgramData on Windows (readable by every user, like the other machine-wide agent files).""" + if current_os() is OS.WINDOWS: + return system_cross_os.windows_program_data_dir() / "UnityGateway" / "managed-settings.json" + return _POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH + + +UNITY_GATEWAY_MANAGED_SETTINGS_PATH = unity_gateway_managed_settings_path() + _SUDO_REPLACE_TARGETS = { OS.LINUX: frozenset( { Path("/etc/claude-code/managed-settings.json"), Path("/etc/codex/managed_config.toml"), - UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + _POSIX_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, + _POSIX_UNITY_GATEWAY_MANAGED_SETTINGS_PATH, } ), } -def current_os() -> OS: - """Map `sys.platform` onto :class:`OS` (lowercased, so a mixed-case value can't slip through).""" - platform = sys.platform.lower() - if platform.startswith("linux"): - return OS.LINUX - if platform == "darwin": - return OS.MACOS - if platform.startswith("win"): - return OS.WINDOWS - return OS.OTHER - - def set_config_scope(scope: str) -> None: global _config_scope if scope not in CONFIG_SCOPES: @@ -1215,6 +1228,23 @@ def _sudo_replace_command(mode: str, *args: str, non_interactive: bool = False) return _sudo_command(*command) +def _write_windows_workspace_policy(path: Path, desired_text: str | None) -> None: + """Install or remove the policy from an elevated process.""" + try: + if desired_text is None: + _privileged_remove(path) + else: + _privileged_replace(path, desired_text) + except PermissionError as exc: + raise ManagedFileWriteUnavailable( + f"Could not update workspace policy at {path}: {exc}" + ) from exc + if read_managed_file(path) != desired_text: + raise ManagedFileWriteUnavailable( + f"Workspace policy at {path} changed during installation; run `ug configure` again." + ) + + def update_workspace_policy( path: Path, desired_text: str | None, @@ -1222,30 +1252,45 @@ def update_workspace_policy( interactive: bool = False, allow_sudo: bool = False, ) -> None: - """Install or remove changed policy; unprivileged launches never invoke sudo.""" + """Install or remove changed policy; unprivileged launches never invoke sudo. + + Windows has no sudo: only an already-elevated process writes, whether it is a launch or an + explicit configure, like root on Linux and macOS. + """ 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) + windows = current_os() is OS.WINDOWS + if not windows and current_os() not in (OS.LINUX, OS.MACOS): + raise ManagedFileWriteUnavailable( + "Machine-wide workspace policy is supported only on Linux, macOS, and Windows." + ) + if not windows: + _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: + if windows: + if not system_cross_os.is_admin(): + raise ManagedFileWriteUnavailable( + _windows_admin_required_message("Unity Gateway workspace", path, "ug configure") + ) + elif not allow_sudo and os.geteuid() != 0: raise ManagedFileWriteUnavailable( "Workspace policy updates require an administrator or root provisioning run." ) if is_dry_run(): + via = "as administrator" if windows else "via sudo" console.print( - f"\n[bold]\\[dry run] {path} (via sudo)[/bold]\n" + f"\n[bold]\\[dry run] {path} ({via})[/bold]\n" f"{desired_text if desired_text is not None else '(remove)'}" ) return + if windows: + _write_windows_workspace_policy(path, desired_text) + 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 @@ -1438,8 +1483,16 @@ def _privileged_remove(path: Path) -> None: path.unlink(missing_ok=True) +def _windows_managed_targets() -> tuple[Path, ...]: + return ( + windows_claude_managed_settings_path(), + windows_codex_managed_settings_path(), + UNITY_GATEWAY_MANAGED_SETTINGS_PATH, + ) + + def _validate_windows_target(path: Path) -> None: - if path not in (windows_claude_managed_settings_path(), windows_codex_managed_settings_path()): + if path not in _windows_managed_targets(): raise RuntimeError(f"Refusing unexpected managed-settings target: {path}") if path.is_symlink(): raise RuntimeError(f"Refusing to replace symlinked managed settings: {path}") @@ -1448,6 +1501,9 @@ def _validate_windows_target(path: Path) -> None: for folder in (path.parent, path.parent.parent): if folder.is_symlink() or folder.is_junction(): raise RuntimeError(f"Refusing to write managed settings through link {folder}") + elif path == UNITY_GATEWAY_MANAGED_SETTINGS_PATH: + if path.parent.is_symlink() or path.parent.is_junction(): + raise RuntimeError(f"Refusing to write managed settings through link {path.parent}") # Well-known SIDs, because Windows translates the group names. @@ -1456,36 +1512,43 @@ def _validate_windows_target(path: Path) -> None: _WINDOWS_USERS_SID = "*S-1-5-32-545" +def _run_windows_icacls(folder: Path, *change: str) -> None: + """Apply one ``icacls`` change to a folder itself (``/L``), failing the write if it can't.""" + icacls = str(system_cross_os.windows_system32_dir() / "icacls.exe") + result = subprocess_cross_os.run( + [icacls, str(folder), *change, "/L"], capture_output=True, text=True, check=False + ) + if result.returncode: + output = f"{result.stdout}{result.stderr}".strip() + raise PermissionError(f"could not secure {folder}: {output}") + + +def _secure_windows_leaf_folder(folder: Path) -> None: + """Give a folder to Administrators and let standard users only read it, like the root-owned + 0755 folders on Linux and macOS.""" + # Set the owner before resetting: the inherited CREATOR OWNER entry maps to the current owner. + _run_windows_icacls(folder, "/setowner", _WINDOWS_ADMINISTRATORS_SID) + _run_windows_icacls( + folder, + "/inheritance:r", + "/grant:r", + f"{_WINDOWS_SYSTEM_SID}:(OI)(CI)F", + f"{_WINDOWS_ADMINISTRATORS_SID}:(OI)(CI)F", + f"{_WINDOWS_USERS_SID}:(OI)(CI)RX", + ) + + def _secure_windows_codex_folders(path: Path) -> None: """Give the ``OpenAI`` and ``Codex`` folders to Administrators and let standard users only read - ``Codex``, like the root-owned 0755 folders on Linux and macOS. A standard user may have created - them first and would otherwise keep control. + ``Codex``. A standard user may have created them first and would otherwise keep control. Only the folders change, never their contents, so a planted hard link can't redirect the change onto another file. ``/L`` changes a link itself rather than its target, and the caller checks for links again afterwards.""" - icacls = str(system_cross_os.windows_system32_dir() / "icacls.exe") codex, openai = path.parent, path.parent.parent - # Set the owner before resetting: the inherited CREATOR OWNER entry maps to the current owner. - for folder, *change in ( - (openai, "/setowner", _WINDOWS_ADMINISTRATORS_SID), - (openai, "/reset"), - (codex, "/setowner", _WINDOWS_ADMINISTRATORS_SID), - ( - codex, - "/inheritance:r", - "/grant:r", - f"{_WINDOWS_SYSTEM_SID}:(OI)(CI)F", - f"{_WINDOWS_ADMINISTRATORS_SID}:(OI)(CI)F", - f"{_WINDOWS_USERS_SID}:(OI)(CI)RX", - ), - ): - result = subprocess_cross_os.run( - [icacls, str(folder), *change, "/L"], capture_output=True, text=True, check=False - ) - if result.returncode: - output = f"{result.stdout}{result.stderr}".strip() - raise PermissionError(f"could not secure {folder}: {output}") + _run_windows_icacls(openai, "/setowner", _WINDOWS_ADMINISTRATORS_SID) + _run_windows_icacls(openai, "/reset") + _secure_windows_leaf_folder(codex) def _windows_replace(path: Path, desired_text: str) -> None: @@ -1493,13 +1556,15 @@ def _windows_replace(path: Path, desired_text: str) -> None: The temp file is created beside the target so the rename stays on one volume and the new file inherits the folder's ACL, which lets standard users read but not write: Program Files comes - that way, and the Codex folders under ProgramData are secured first.""" + that way, and the Codex and Unity Gateway folders under ProgramData are secured first.""" _validate_windows_target(path) if not system_cross_os.is_admin(): raise PermissionError("administrator rights are required") path.parent.mkdir(parents=True, exist_ok=True) if path == windows_codex_managed_settings_path(): _secure_windows_codex_folders(path) + elif path == UNITY_GATEWAY_MANAGED_SETTINGS_PATH: + _secure_windows_leaf_folder(path.parent) _validate_windows_target(path) # a folder may have been created or swapped for a link meanwhile fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") try: diff --git a/tests/README.md b/tests/README.md index 2b0a20204..e0af38fae 100644 --- a/tests/README.md +++ b/tests/README.md @@ -742,7 +742,9 @@ 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 +retrieval-only drift, configure-only sudo selection, Windows ProgramData install/replace/removal +with a stubbed `icacls` (elevated only, administrator hint otherwise, linked folders refused), +Windows launch/revert rules, per-user cache only for `UG_SKIP_OS_SETTINGS`, 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. diff --git a/tests/integration/utils/managed.py b/tests/integration/utils/managed.py index 442acf983..a2a450b34 100644 --- a/tests/integration/utils/managed.py +++ b/tests/integration/utils/managed.py @@ -41,12 +41,16 @@ 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 installed policy and its refresh stamps, or, where the machine cannot hold the - installed policy (Windows), the per-user cache of the fetched policy. + installed policy (UG_SKIP_OS_SETTINGS), the per-user cache of the fetched policy. The installed + policy lives under ProgramData on Windows, read from the environment like the other Windows + policy paths. """ + program_data = os.environ.get("PROGRAMDATA", "").strip() or r"C:\ProgramData" return path in ( home / ".ucode" / "refresh_ts.json", home / ".ucode" / "managed-config.json", Path("/etc/unitygateway/managed-settings.json"), + Path(program_data) / "UnityGateway" / "managed-settings.json", ) diff --git a/tests/test_integration_runner.py b/tests/test_integration_runner.py index 37861b039..006ea6093 100644 --- a/tests/test_integration_runner.py +++ b/tests/test_integration_runner.py @@ -133,6 +133,7 @@ def test_windows_policy_paths_match_pinned_agent_locations(): ) assert paths == ( + Path("C:/ProgramData/UnityGateway/managed-settings.json"), Path("C:/ProgramData/OpenAI/Codex/requirements.toml"), Path("C:/ProgramData/OpenAI/Codex/config.toml"), Path("C:/Program Files/ClaudeCode/managed-settings.json"), diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index c2a8fb816..2244f2b95 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -5,6 +5,7 @@ import json import os import stat +import subprocess import sys from datetime import UTC, datetime, timedelta @@ -13,6 +14,7 @@ import ucode.config_io as config_io_mod import ucode.databricks as db_mod import ucode.managed_config as mc_mod +import ucode.managed_files as mf_mod from ucode.managed_config import ( get_managed_config, load_managed_configuration, @@ -1355,8 +1357,157 @@ def test_fetch_failure_preserves_pending_hash_and_check_time(self, monkeypatch): assert mc_mod.REFRESH_TS_PATH.read_bytes() == original +_REAL_UPDATE_WORKSPACE_POLICY = mf_mod.update_workspace_policy +_REAL_WINDOWS_REPLACE = mf_mod._windows_replace + + +class TestWindowsInstalledPolicy: + """Windows holds the installed policy under ProgramData and follows the Linux/macOS rules.""" + + @pytest.fixture(autouse=True) + def windows(self, tmp_path, monkeypatch): + path = tmp_path / "ProgramData" / "UnityGateway" / "managed-settings.json" + 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)) + for module in (mc_mod, mf_mod): + monkeypatch.setattr(module, "current_os", lambda: mf_mod.OS.WINDOWS) + monkeypatch.setattr(mc_mod, "MANAGED_CONFIG_PATH", path) + monkeypatch.setattr(mf_mod, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setattr(mc_mod, "update_workspace_policy", _REAL_UPDATE_WORKSPACE_POLICY) + monkeypatch.setattr(mf_mod, "_windows_replace", _REAL_WINDOWS_REPLACE) + # Never run the real icacls; record the folder changes instead. + self.icacls = [] + + def run(args, **kwargs): + self.icacls.append(args) + return subprocess.CompletedProcess(args, 0, "", "") + + monkeypatch.setattr(mf_mod.subprocess_cross_os, "run", run) + self.path = path + self.set_admin(monkeypatch, True) + + def set_admin(self, monkeypatch, admin): + monkeypatch.setattr(mf_mod.system_cross_os, "is_admin", lambda: admin) + monkeypatch.setattr(mc_mod, "is_admin", lambda: admin) + + def test_policy_path_is_under_program_data(self, tmp_path, monkeypatch): + monkeypatch.setenv("ProgramData", str(tmp_path / "PD")) + monkeypatch.setattr(mf_mod, "current_os", lambda: mf_mod.OS.WINDOWS) + + assert mf_mod.unity_gateway_managed_settings_path() == ( + tmp_path / "PD" / "UnityGateway" / "managed-settings.json" + ) + + def test_elevated_configure_installs_replaces_and_removes(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + assert json.loads(self.path.read_text(encoding="utf-8"))["workspace"] == WORKSPACE + assert [entry.name for entry in self.path.parent.iterdir()] == [self.path.name] + assert self.icacls + + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + refresh_managed_config(_state(), force_refresh=True) + assert load_managed_configuration(WORKSPACE) == changed + + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (None, None)) + refresh_managed_config(_state(), force_refresh=True) + assert not self.path.exists() + + def test_non_elevated_configure_requires_an_administrator(self, monkeypatch): + self.set_admin(monkeypatch, False) + + with pytest.raises(mc_mod.ManagedPolicyInstallDenied, match="elevated terminal"): + refresh_managed_config(_state(), force_refresh=True) + + assert not self.path.exists() + assert not self.icacls + + def test_non_admin_launch_with_nothing_installed_applies_fetched_policy(self, monkeypatch): + self.set_admin(monkeypatch, False) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert not self.path.exists() + assert not mc_mod.USER_MANAGED_CONFIG_PATH.exists() + assert not self.icacls + + def test_non_admin_launch_never_replaces_the_installed_policy(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + original = self.path.read_bytes() + self.icacls.clear() + self.set_admin(monkeypatch, False) + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + changed = {**RAW_MANIFEST, "default_agent": "CODING_AGENT_CODEX"} + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, token: (changed, None)) + + result = refresh_managed_config(_state()) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert self.path.read_bytes() == original + assert not self.icacls + + def test_unchanged_launch_does_not_write(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + self.icacls.clear() + original = self.path.stat().st_mtime_ns + monkeypatch.setattr(mc_mod, "_utcnow", lambda: NOW + timedelta(minutes=6)) + + refresh_managed_config(_state()) + + assert self.path.stat().st_mtime_ns == original + assert not self.icacls + + def test_foreign_workspace_is_refused(self): + refresh_managed_config(_state(), force_refresh=True) + foreign = _state(workspace="https://other.example.com") + + with pytest.raises(RuntimeError, match="Launch refused"): + refresh_managed_config(foreign) + + def test_only_the_program_data_file_is_read(self, tmp_path): + # A POSIX-style path under a drive root is never consulted on Windows. + planted = tmp_path / "etc" / "unitygateway" / "managed-settings.json" + planted.parent.mkdir(parents=True) + planted.write_text( + json.dumps({"workspace": "https://planted.example.com", "config": RAW_MANIFEST}), + encoding="utf-8", + ) + + assert mc_mod.managed_state_workspace() is None + assert load_managed_configuration("https://planted.example.com") is None + refresh_managed_config(_state(), force_refresh=True) + assert mc_mod.managed_state_workspace() == WORKSPACE + + def test_skip_mode_uses_the_per_user_cache(self, monkeypatch): + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + + result = refresh_managed_config(_state(), force_refresh=True) + + assert result.manifest == normalize_managed_config(RAW_MANIFEST) + assert mc_mod.USER_MANAGED_CONFIG_PATH.exists() + assert not self.path.exists() + + def test_elevated_revert_removes_the_policy(self): + refresh_managed_config(_state(), force_refresh=True) + + assert mc_mod.revert_machine_policy() == "removed" + assert not self.path.exists() + + def test_non_elevated_revert_warns_and_keeps_the_policy(self, monkeypatch): + refresh_managed_config(_state(), force_refresh=True) + self.set_admin(monkeypatch, False) + warnings = [] + monkeypatch.setattr(mc_mod, "print_warning", warnings.append) + + assert mc_mod.revert_machine_policy() == "failed" + assert self.path.exists() + assert "elevated terminal" in warnings[0] + + class TestPolicyWithoutInstallation: - """Windows and UG_SKIP_OS_SETTINGS can't hold an installed policy.""" + """UG_SKIP_OS_SETTINGS can't hold an installed policy.""" @pytest.fixture(autouse=True) def _stub_clock_and_fetch(self, monkeypatch): @@ -1364,11 +1515,11 @@ def _stub_clock_and_fetch(self, monkeypatch): 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"]) + @pytest.fixture(params=["linux", "windows"]) def not_installable(self, request, monkeypatch): - if request.param == "skip_os_settings": - monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") - else: + # The opt-out applies on every OS, Windows included. + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + if request.param == "windows": monkeypatch.setattr(mc_mod, "current_os", lambda: mc_mod.OS.WINDOWS) monkeypatch.setattr( mc_mod, @@ -1418,17 +1569,6 @@ def test_per_user_cache_is_private(self, not_installable): 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()) @@ -1693,14 +1833,10 @@ def test_revert_without_a_policy_writes_nothing(self, monkeypatch): 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): + def test_revert_leaves_the_policy_where_ug_does_not_manage_it(self, monkeypatch): mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) mc_mod.MANAGED_CONFIG_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) + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") assert mc_mod.revert_machine_policy() == "unchanged" assert mc_mod.MANAGED_CONFIG_PATH.exists() diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index a03e83934..536af4654 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -772,6 +772,136 @@ def test_policy_symlink_is_rejected_before_sudo(self, policy_path, tmp_path, mon assert target.read_text() == "user settings" +class TestWindowsWorkspacePolicyWriter: + """Windows holds the workspace policy under ProgramData, written only by an elevated process.""" + + @pytest.fixture + def policy_path(self, tmp_path, monkeypatch): + monkeypatch.setenv("ProgramData", str(tmp_path / "ProgramData")) + monkeypatch.setenv("SystemRoot", str(tmp_path / "Windows")) + monkeypatch.setattr(managed_files, "current_os", lambda: managed_files.OS.WINDOWS) + monkeypatch.setattr(managed_files, "_windows_replace", _REAL_WINDOWS_REPLACE) + path = managed_files.unity_gateway_managed_settings_path() + monkeypatch.setattr(managed_files, "UNITY_GATEWAY_MANAGED_SETTINGS_PATH", path) + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: True) + self.calls = [] + + def run(args, **kwargs): + self.calls.append(args) + return subprocess.CompletedProcess(args, self.returncode, "Access is denied.", "") + + self.returncode = 0 + monkeypatch.setattr(managed_files.subprocess_cross_os, "run", run) + return path + + def test_path_is_under_program_data(self, policy_path, tmp_path): + assert policy_path == tmp_path / "ProgramData" / "UnityGateway" / "managed-settings.json" + + def test_posix_path_is_unchanged_off_windows(self, monkeypatch): + monkeypatch.setattr(managed_files, "current_os", lambda: managed_files.OS.LINUX) + + assert managed_files.unity_gateway_managed_settings_path() == Path( + "/etc/unitygateway/managed-settings.json" + ) + + def test_elevated_run_installs_replaces_and_removes(self, policy_path, tmp_path): + managed_files.update_workspace_policy(policy_path, "one") + assert policy_path.read_text(encoding="utf-8") == "one" + managed_files.update_workspace_policy(policy_path, "two", allow_sudo=True) + assert policy_path.read_text(encoding="utf-8") == "two" + assert [entry.name for entry in policy_path.parent.iterdir()] == [policy_path.name] + + managed_files.update_workspace_policy(policy_path, None, allow_sudo=True) + assert not policy_path.exists() + + def test_folder_is_given_to_administrators_with_read_for_users(self, policy_path, tmp_path): + managed_files.update_workspace_policy(policy_path, "one") + + icacls = str(tmp_path / "Windows" / "System32" / "icacls.exe") + folder = str(policy_path.parent) + assert self.calls == [ + [icacls, folder, "/setowner", "*S-1-5-32-544", "/L"], + [ + icacls, + folder, + "/inheritance:r", + "/grant:r", + "*S-1-5-18:(OI)(CI)F", + "*S-1-5-32-544:(OI)(CI)F", + "*S-1-5-32-545:(OI)(CI)RX", + "/L", + ], + ] + + def test_unsecured_folder_blocks_the_write(self, policy_path): + self.returncode = 5 + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="could not secure"): + managed_files.update_workspace_policy(policy_path, "one") + + assert not policy_path.exists() + + @pytest.mark.parametrize("allow_sudo", [False, True]) + @pytest.mark.parametrize("desired_text", ["new", None]) + def test_standard_user_gets_the_admin_hint( + self, policy_path, monkeypatch, allow_sudo, desired_text + ): + policy_path.parent.mkdir(parents=True) + policy_path.write_text("old", encoding="utf-8") + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: False) + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="administrator rights"): + managed_files.update_workspace_policy( + policy_path, desired_text, interactive=True, allow_sudo=allow_sudo + ) + + assert policy_path.read_text(encoding="utf-8") == "old" + assert not self.calls + + def test_elevated_launch_writes_like_root(self, policy_path): + managed_files.update_workspace_policy(policy_path, "new", allow_sudo=False) + + assert policy_path.read_text(encoding="utf-8") == "new" + + def test_unchanged_policy_needs_no_elevation(self, policy_path, monkeypatch): + policy_path.parent.mkdir(parents=True) + policy_path.write_text("same", encoding="utf-8") + monkeypatch.setattr(managed_files.system_cross_os, "is_admin", lambda: False) + + managed_files.update_workspace_policy(policy_path, "same") + + def test_skip_mode_is_rejected(self, policy_path, monkeypatch): + monkeypatch.setenv("UG_SKIP_OS_SETTINGS", "1") + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="skipped"): + managed_files.update_workspace_policy(policy_path, "new") + + assert not policy_path.exists() + + def test_dry_run_writes_nothing(self, policy_path): + config_io.set_dry_run(True) + + managed_files.update_workspace_policy(policy_path, "new") + + assert not policy_path.exists() + assert not self.calls + + def test_refuses_an_unexpected_target(self, policy_path, tmp_path): + with pytest.raises(RuntimeError, match="unexpected workspace-policy target"): + managed_files.update_workspace_policy(tmp_path / "elsewhere.json", "new") + + def test_refuses_a_linked_folder(self, policy_path, tmp_path): + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + policy_path.parent.parent.mkdir() + policy_path.parent.symlink_to(elsewhere, target_is_directory=True) + + with pytest.raises(RuntimeError, match="through link"): + managed_files.update_workspace_policy(policy_path, "new") + + assert list(elsewhere.iterdir()) == [] + + 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.""" From b25da2d8b865ff94b2acb39ce25e46269e468ab9 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:41:54 -0700 Subject: [PATCH 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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