diff --git a/README.md b/README.md index e6ae86135..01c122ce9 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,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 a3678a546..0b82a6be8 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,40 +13,110 @@ 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. -- `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. +`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. +`ug revert` removes the installed policy with sudo; without sudo 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. + +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 c49c2f83b..0dc1cc36c 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -99,9 +99,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, revert_machine_policy, @@ -803,6 +806,71 @@ 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']}. 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." + ) + # --user must see a managed config for every agent, so it reads fresh; global keeps the cache. + return refresh_managed_config(state, force_refresh=config_scope() == "user") + + +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. + """ + state = load_state() + 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) + # An explicit --workspace always saves the selection, as on main. + if target and (workspace or not saved or normalize_workspace_url(saved) != target): + set_current_workspace(target) + state = load_state() + if state.get("workspace") != target: + # A dry run saves nothing, so carry the workspace for this run. + state = {**state, "workspace": target} + return state + + def _setup_single_agent( workspace_entries: list[tuple[str, str | None]], tool: str, @@ -832,7 +900,8 @@ 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) refuse_user_scope_over_managed_config(state.get("workspace"), managed) _reject_disabled_agent(managed, tool) if managed is not None: @@ -840,8 +909,8 @@ def _setup_single_agent( if not managed_provider_service(managed, tool): parent_schema = managed_unity_catalog_location(managed, tool) elif apply_managed: - # --user must see a managed config for every agent, so it reads fresh; global keeps the cache. - managed, _ = refresh_managed_config(state, force_refresh=config_scope() == "user") + # Other agents only read the policy; they never install it. + managed, _ = _read_policy_for_other_agent(state) refuse_user_scope_over_managed_config(state.get("workspace"), managed) state = configure_single_tool(tool, state, parent_schema=parent_schema) if apply_managed and not managed_enabled_tools(managed or {}): @@ -1054,7 +1123,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) refuse_user_scope_over_managed_config(state.get("workspace"), managed) managed_tools = managed_enabled_tools(managed) if managed is not None else [] if managed is not None and managed_tools: @@ -3250,9 +3319,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. @@ -3815,9 +3882,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 145f8e774..9612c6fee 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,79 +649,56 @@ 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, ) @@ -729,41 +708,21 @@ def revert_machine_policy() -> str: 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(): + if not _machine_policy_installable() or not MANAGED_CONFIG_PATH.exists(): return "unchanged" try: - update_workspace_policy(MACHINE_POLICY_PATH, None, interactive=True, allow_sudo=True) + update_workspace_policy(MANAGED_CONFIG_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 " + f"Could not remove the workspace policy at {MANAGED_CONFIG_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. - - 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): @@ -794,78 +753,151 @@ 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: + # 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}`{without_skip}." + ) + 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: @@ -875,26 +907,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 7f37ed180..ed05edb9f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -549,7 +549,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 | @@ -751,21 +750,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 d7c78d921..f722eac5f 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 1076e5c32..8cd478434 100644 --- a/tests/e2e_cuj/test_cuj1_explicit_models.py +++ b/tests/e2e_cuj/test_cuj1_explicit_models.py @@ -27,6 +27,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 @@ -117,10 +118,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 9e46659ae..75b848883 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -276,6 +276,21 @@ from Copilot's own `~/.copilot/session-state/*/events.jsonl` (`utils/agents/copi Anthropic service's model, on the OpenAI and Azure OpenAI services' `gpt-5-nano`, and on a named model of the Bedrock allow-all service; launching on the relayed service fails before Copilot starts. +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 @@ -297,7 +312,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. @@ -714,7 +729,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..adc31f70b 100644 --- a/tests/integration/test_ug_configure_managed.py +++ b/tests/integration/test_ug_configure_managed.py @@ -119,58 +119,91 @@ def test_ug_configure_managed_is_idempotent(live_session, workspace): @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. +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 user-owned refresh timestamp is backdated past it. - 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. + 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") 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) - - installed = policy.stat() - assert installed.st_uid == 0 - assert installed.st_mode & 0o777 == 0o644 + 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_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. - - 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. - """ - session = live_session - use_managed_config_fixture(session, "managed_workspace_default") - cache = session.home / ".ucode" / "managed-config.json" - - session.run("configure", "--workspace", workspace, "--skip-upgrade", timeout=240) - fresh = json.loads(cache.read_text()) + 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 3d176e7bb..bd237ac5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6516,6 +6516,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): + """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 0585eb673..c2a8fb816 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: @@ -1079,162 +1678,13 @@ def test_unparseable_decimals_become_none(self, monkeypatch): 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 - - 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") + mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.MANAGED_CONFIG_PATH.write_text('{"workspace": "x", "config": {}}', encoding="utf-8") assert mc_mod.revert_machine_policy() == "removed" - assert not mc_mod.MACHINE_POLICY_PATH.exists() + assert not mc_mod.MANAGED_CONFIG_PATH.exists() def test_revert_without_a_policy_writes_nothing(self, monkeypatch): monkeypatch.setattr( @@ -1245,19 +1695,19 @@ def test_revert_without_a_policy_writes_nothing(self, monkeypatch): @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") + 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) assert mc_mod.revert_machine_policy() == "unchanged" - assert mc_mod.MACHINE_POLICY_PATH.exists() + assert mc_mod.MANAGED_CONFIG_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") + mc_mod.MANAGED_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.MANAGED_CONFIG_PATH.write_text("{}", encoding="utf-8") warnings = [] monkeypatch.setattr(mc_mod, "print_warning", warnings.append) @@ -1267,5 +1717,5 @@ def denied(*_a, **_k): monkeypatch.setattr(mc_mod, "update_workspace_policy", denied) assert mc_mod.revert_machine_policy() == "failed" - assert mc_mod.MACHINE_POLICY_PATH.exists() + assert mc_mod.MANAGED_CONFIG_PATH.exists() assert "ug revert" in warnings[0] 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