From 0b0645e2a3d1e33d60826b25340f611d79752314 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:00:46 +0000 Subject: [PATCH 1/7] 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 3338f7816..34d6bd4dd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -544,6 +544,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 | @@ -564,7 +565,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi With Claude and Codex selected there are **88 live cases** (12 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, @@ -745,6 +746,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 51d3511ef..fd4ea97be 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 9e1deca02..9e46659ae 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -709,7 +709,7 @@ journeys check setup and launch with existing services, without inference. There are **88 live cases** (including 12 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 fifteen 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 @@ -728,7 +728,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 d7325d928d8d8e27de7f0e7047e2fe2828ee83a6 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 02:52:27 +0000 Subject: [PATCH 2/7] 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 15432163b..bcea6baa9 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -103,6 +103,7 @@ load_managed_state, normalize_managed_config, refresh_managed_config, + revert_machine_policy, ) from ucode.managed_files import config_scope, managed_write_session, set_config_scope from ucode.managed_resolve import ( @@ -1468,6 +1469,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( @@ -1490,6 +1492,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 91c04c7bf216de15ca59532fc376404c37424e08 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:41:54 -0700 Subject: [PATCH 3/7] 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 367b416a8e7dd4936ae549c3e113a0294f2bafb2 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:46:55 -0700 Subject: [PATCH 4/7] 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 af6ab2412c36c4cf4170e01996d92d40a18fede7 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Thu, 22 Oct 2026 20:47:34 -0700 Subject: [PATCH 5/7] 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 146a07a997f44f6f45310c30b570083be0b4e267 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Fri, 23 Oct 2026 03:47:12 +0000 Subject: [PATCH 6/7] 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 6ba7a3011ea0819265eedf572012cac0bbbb4192 Mon Sep 17 00:00:00 2001 From: Bill Wang Date: Sat, 24 Oct 2026 00:57:09 +0000 Subject: [PATCH 7/7] Remove the empty policy folder with the workspace policy Removing /etc/unitygateway/managed-settings.json left an empty /etc/unitygateway/ behind. ug created that folder for the policy, so remove it too; rmdir leaves it in place if anything else is there. Co-authored-by: Isaac --- src/ucode/managed_replace.sh | 2 ++ tests/test_managed_files.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/ucode/managed_replace.sh b/src/ucode/managed_replace.sh index fca862699..8e7170217 100644 --- a/src/ucode/managed_replace.sh +++ b/src/ucode/managed_replace.sh @@ -246,6 +246,8 @@ remove_one() ( exit 1 fi rm -f "$target" + # The folder holds only the policy; rmdir leaves it in place if anything else is there. + rmdir "$(dirname "$target")" 2>/dev/null || true ) case "$mode" in diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index e3d45cab9..4783af283 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -863,12 +863,18 @@ def test_real_shell_enforces_workspace_policy_permissions(self, tmp_path, existi 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): + @pytest.mark.parametrize( + ("changed", "neighbor"), [(False, False), (True, False), (False, True)] + ) + def test_real_shell_removes_only_matching_workspace_policy(self, tmp_path, changed, neighbor): source = tmp_path / "expected.json" source.write_text("old policy") - target = tmp_path / "managed-settings.json" + policy_dir = tmp_path / "unitygateway" + policy_dir.mkdir() + target = policy_dir / "managed-settings.json" target.write_text("new policy" if changed else "old policy") + if neighbor: + (policy_dir / "other").write_text("not ours") script = managed_files._SUDO_REPLACE_SCRIPT.replace( "/etc/unitygateway/managed-settings.json", str(target) ) @@ -894,6 +900,8 @@ def test_real_shell_removes_only_matching_workspace_policy(self, tmp_path, chang else: assert result.returncode == 0, result.stderr assert not target.exists() + # The emptied folder goes too; one holding anything else stays. + assert policy_dir.exists() == neighbor @pytest.mark.parametrize("failed_step", ["", "metadata", "content", "rename"]) def test_real_shell_preserves_file_on_failed_step(self, tmp_path, failed_step):