diff --git a/README.md b/README.md index f26ea6ba..79d4b02d 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,8 @@ their next ucode run. | `~/.copilot/.env` | GitHub Copilot CLI | | `~/.pi/agent/models.json` | Pi | | `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) | -| `~/.ucode/managed-state.json` | The managed config — authored by `ucode setup` (admins) and refreshed from the workspace on launch | +| `~/.ucode/managed-state.json` | The editable managed-config draft authored by `ucode setup` | +| `~/.ucode/managed-cache/.json` | Per-workspace published-config cache with fetch metadata | Existing files are backed up before being overwritten. `ucode revert` restores backups. diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 910af654..fd6bbfd2 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -65,7 +65,7 @@ from ucode.managed_config import ( MANAGED_CONFIG_ENV_VAR, get_model_recommendation, - load_managed_state, + load_managed_cache, managed_agent_config_enabled, refresh_managed_config, ) @@ -1983,7 +1983,7 @@ def _launch_managed_default( apply_pat_environment(state) # --dry-run avoids the fetch but still applies the last saved config. if dry_run: - managed = load_managed_state(current) + managed = load_managed_cache(current) else: with spinner("Loading..."): managed, coding_agent_config_feature_disabled = refresh_managed_config(state) diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index 5cde1127..6d9c97fe 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -1,20 +1,18 @@ -"""Admin-authored managed coding-agent config: fetch, normalize, and local persistence. +"""Managed coding-agent config: fetch, normalize, and local persistence. An org admin authors a ``CodingAgentConfig`` through 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-state.json`` (0600), that both roles share: +(non-admin) and ``ucode`` applies it locally. This module owns the fetch/normalize side and keeps +the admin's local draft separate from the last published config fetched by a launch: - 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 admin-write side - (``managed_setup`` / ``managed_wizard``) authors the manifest here, and the launch path pulls the - published copy back into the same file, and -- re-reading it on each launch, falling back to the persisted copy when the read fails. +- ``~/.ucode/managed-state.json`` is the editable draft authored by ``ucode setup`` and published by + ``ucode apply``; +- ``~/.ucode/managed-cache/.json`` is launch-owned and contains only the last + workspace-published config plus provenance metadata, for outage fallback. -There is deliberately one file, not a separate authored ``managed-settings.json``: the workspace is -the source of truth, so an authored draft and the pulled copy are the same shape and coexist in -``managed-state.json``. ``ucode setup`` authors the draft; ``ucode apply`` publishes it; a launch -then pulls the published copy back into the same file. +Keeping the files separate is important: an ordinary launch must never overwrite or accidentally +apply an admin's unpublished edits. Only a launch with ``--local`` reads the authored draft. :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 @@ -25,8 +23,11 @@ from __future__ import annotations +import hashlib import json import os +import tempfile +from datetime import UTC, datetime from pathlib import Path from typing import cast @@ -39,6 +40,11 @@ from ucode.ui import console, print_warning MANAGED_STATE_PATH = config_io.APP_DIR / "managed-state.json" +MANAGED_CACHE_DIR = config_io.APP_DIR / "managed-cache" +# Read-only migration fallback for the single-cache layout used by earlier builds. New writes always +# go to MANAGED_CACHE_DIR so switching workspaces cannot discard another workspace's fallback. +LEGACY_MANAGED_CACHE_PATH = config_io.APP_DIR / "managed-cache.json" +MANAGED_STATE_SCHEMA_VERSION = "1.0" # Opt-in switch while the feature is in bug bash: unset means launches ignore managed configs # entirely and behave exactly as they did before. @@ -341,29 +347,89 @@ def _is_permission_denied(reason: str) -> bool: return "http 403" in lowered or "permission_denied" in lowered -def save_managed_state(workspace: str, config: dict) -> None: - """Persist the normalized managed config to ``~/.ucode/managed-state.json`` at mode 0600. +def _config_digest(config: dict) -> str: + canonical = json.dumps(config, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" + + +def _managed_cache_path(workspace: str) -> Path: + """Return a stable, filesystem-safe cache path for one workspace.""" + workspace_hash = hashlib.sha256(workspace.encode("utf-8")).hexdigest() + return MANAGED_CACHE_DIR / f"{workspace_hash}.json" - The file is org-authored, not developer-editable — 0600 keeps it readable/writable only by the - user (a light guard; hard enforcement / sudo ownership is a separate concern). 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. +def _save_managed_payload( + path: Path, + workspace: str, + config: dict, + *, + metadata: dict[str, object] | None = None, +) -> None: + """Atomically write one versioned managed-config envelope at mode 0600. + + The temporary file is created beside the destination, flushed, and replaced into place. Readers + therefore observe either the complete old document or the complete new one, including when two + launches refresh the same cache concurrently. """ - payload = {"workspace": workspace, "config": config} + # Dict insertion order is preserved by json.dumps: keep the format version first so humans and + # future readers can identify the file schema before interpreting any payload fields. Version + # 1.0 is the only managed-state schema this ucode release writes. + payload: dict[str, object] = { + "schema_version": MANAGED_STATE_SCHEMA_VERSION, + "workspace": workspace, + } + if metadata is not None: + payload["metadata"] = metadata + payload["config"] = config 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_STATE_PATH}[/bold]\n{json.dumps(payload, indent=2)}\n" - ) + console.print(f"\n[bold]\\[dry run] {path}[/bold]\n{json.dumps(payload, indent=2)}\n") return - config_io.ensure_parent_dir(MANAGED_STATE_PATH) + config_io.ensure_parent_dir(path) + serialized = json.dumps(payload, indent=2) + "\n" + temporary_path: Path | None = None try: - MANAGED_STATE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(serialized) + temporary.flush() + os.fsync(temporary.fileno()) + _restrict_permissions(temporary_path) + os.replace(temporary_path, path) except OSError as exc: - raise RuntimeError(f"Failed to write managed state file: {MANAGED_STATE_PATH}") from exc - _restrict_permissions(MANAGED_STATE_PATH) + raise RuntimeError(f"Failed to write managed config file: {path}") from exc + finally: + if temporary_path is not None: + try: + temporary_path.unlink(missing_ok=True) + except OSError: + pass + + +def save_managed_state(workspace: str, config: dict) -> None: + """Save the admin-authored draft to ``~/.ucode/managed-state.json``.""" + _save_managed_payload(MANAGED_STATE_PATH, workspace, config) + + +def save_managed_cache(workspace: str, config: dict) -> None: + """Save the last workspace-published config for launch fallback. + + An empty config records an authoritative successful read of "no published config", preventing + an older cached policy from being resurrected after a later transient failure. + """ + metadata: dict[str, object] = { + "source": "workspace", + "fetched_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + "config_digest": _config_digest(config), + } + _save_managed_payload(_managed_cache_path(workspace), workspace, config, metadata=metadata) def _restrict_permissions(path: Path) -> None: @@ -375,45 +441,106 @@ def _restrict_permissions(path: Path) -> None: pass +def _read_managed_state_v1(data: dict) -> tuple[str, dict] | None: + """Parse the v1.0 managed-state envelope. + + Keeping this version-specific prevents a future schema from being accidentally interpreted as + v1 just because it happens to reuse a field name. New versions add a new reader and registry + entry rather than accumulating conditionals in the launch path. + """ + workspace = data.get("workspace") + config = data.get("config") + if not isinstance(workspace, str) or not workspace or not isinstance(config, dict): + return None + return workspace, config + + +_MANAGED_STATE_READERS = {"1.0": _read_managed_state_v1} + + +def _read_managed_payload( + path: Path, *, require_cache_metadata: bool = False +) -> tuple[str, dict] | None: + data = config_io.read_json_safe(path) + version = data.get("schema_version") + if version is None: + # Files written before schema_version was introduced already use the v1.0 envelope. Read + # them as v1 so upgrading ucode does not discard an otherwise valid cached config. + version = MANAGED_STATE_SCHEMA_VERSION + if not isinstance(version, str): + return None + reader = _MANAGED_STATE_READERS.get(version) + if reader is None: + # An older ucode must not guess how to interpret a future managed-state schema. + return None + payload = reader(data) + if payload is None or not require_cache_metadata: + return payload + _workspace, config = payload + metadata = data.get("metadata") + if not isinstance(metadata, dict): + return None + if metadata.get("source") != "workspace": + return None + if metadata.get("config_digest") != _config_digest(config): + return None + fetched_at = metadata.get("fetched_at") + if not isinstance(fetched_at, str) or not fetched_at: + return None + return payload + + def load_managed_state(workspace: str | None) -> dict | None: - """Load the persisted managed config for ``workspace``, or None if absent/mismatched. + """Load the admin-authored draft for ``workspace``, or None if absent/mismatched. Returns the normalized config dict (the ``config`` field), only when the stored file is for the same workspace — so a stale file from another workspace is ignored rather than misapplied. - This is the single local managed config: ``ucode setup`` authors it here, ``ucode apply`` - publishes it, and a launch refreshes it from the workspace. The admin-authored draft and the - pulled copy share one file because the workspace is the source of truth — to keep a draft, - publish it with ``ucode apply``. + ``ucode setup`` authors this file, ``ucode --local`` tests it, and ``ucode apply`` + publishes it. Ordinary launches never write or read it. """ if not workspace: return None - data = config_io.read_json_safe(MANAGED_STATE_PATH) - if data.get("workspace") != workspace: + payload = _read_managed_payload(MANAGED_STATE_PATH) + if payload is None: return None - config = data.get("config") - return config if isinstance(config, dict) else None + stored_workspace, config = payload + return config if stored_workspace == workspace else None + + +def load_managed_cache(workspace: str | None) -> dict | None: + """Load the last workspace-published config cached by an ordinary launch.""" + if not workspace: + return None + cache_path = _managed_cache_path(workspace) + payload = _read_managed_payload(cache_path, require_cache_metadata=True) + if payload is None and not cache_path.exists(): + payload = _read_managed_payload(LEGACY_MANAGED_CACHE_PATH) + if payload is None: + return None + stored_workspace, config = payload + return config if stored_workspace == workspace 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. + """The workspace the on-disk draft was authored for, 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. """ - workspace = config_io.read_json_safe(MANAGED_STATE_PATH).get("workspace") - return workspace if isinstance(workspace, str) and workspace else None + payload = _read_managed_payload(MANAGED_STATE_PATH) + return payload[0] if payload is not None else None def refresh_managed_config(state: dict) -> tuple[dict | None, bool]: - """Fetch the workspace's managed config and persist it, returning ``(manifest, coding_agent_config_feature_disabled)``. + """Fetch and cache the workspace-published config. Runs on every launch so a developer picks up an admin's edits without re-running ``ucode configure``. 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 + coding. Instead it falls back to the last published config cached 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. @@ -438,9 +565,9 @@ def refresh_managed_config(state: dict) -> tuple[dict | None, bool]: # 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, {}) + save_managed_cache(workspace, {}) return None, False - save_managed_state(workspace, managed) + save_managed_cache(workspace, managed) return managed, False @@ -459,7 +586,7 @@ def _persisted_fallback(workspace: str, reason: str, *, refused: bool = False) - """ # An empty persisted config means the last successful read found none, so there is no admin # policy to fall back to — treat it the same as having no file at all. - persisted = load_managed_state(workspace) + persisted = load_managed_cache(workspace) if not persisted: return None summary = _summarize_read_failure(reason) diff --git a/src/ucode/managed_setup.py b/src/ucode/managed_setup.py index a26e185b..985bd524 100644 --- a/src/ucode/managed_setup.py +++ b/src/ucode/managed_setup.py @@ -13,9 +13,10 @@ inverting that module's maps rather than restated, so a new agent or MCP type only has to be added once. -Local persistence is not duplicated here: the authored manifest is saved to and loaded from the one -local file, ``~/.ucode/managed-state.json``, via :func:`ucode.managed_config.save_managed_state` and -:func:`ucode.managed_config.load_managed_state` — the same file the launch path pulls into. +Local persistence is not duplicated here: the authored manifest is saved to and loaded from +``~/.ucode/managed-state.json`` via :func:`ucode.managed_config.save_managed_state` and +:func:`ucode.managed_config.load_managed_state`. Ordinary launches keep their published-config +fallback in a separate cache, so they cannot overwrite unpublished edits. The interactive wizard that calls these helpers, and the publish step, live in :mod:`ucode.managed_wizard`. diff --git a/tests/test_cli.py b/tests/test_cli.py index ad45a9a7..f84ef454 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2461,7 +2461,7 @@ def test_disabled_reads_nothing_at_all(self, monkeypatch, env_value): monkeypatch.delenv("ENABLE_MANAGED_AGENT_CONFIG", raising=False) else: monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", env_value) - for name in ("refresh_managed_config", "load_managed_state"): + for name in ("refresh_managed_config", "load_managed_cache"): monkeypatch.setattr( f"ucode.cli.{name}", lambda *a, called=name, **k: pytest.fail(f"{called} must not run when disabled"), @@ -2494,7 +2494,7 @@ def test_a_removed_model_list_no_longer_skips_discovery(self, monkeypatch): "enabled_agents": {"claude": {"model_config": {"models": {"default_opus_model": "m"}}}} } fresh = {"enabled_agents": {"claude": {"model_config": {}}}} - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: stale_cache) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: stale_cache) monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: (fresh, False)) state = dict(MINIMAL_STATE) @@ -2550,7 +2550,7 @@ def test_fetches_the_config_rather_than_reading_a_cold_cache(self, monkeypatch): monkeypatch.setattr("ucode.cli.set_current_workspace", lambda ws: None) monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) # Cold cache — a cache read would wrongly fall through to the local configure flow. - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: None) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: None) monkeypatch.setattr( "ucode.cli.refresh_managed_config", lambda state: ({"enabled_agents": {"claude": {}}}, False), @@ -2678,7 +2678,7 @@ def test_passes_entries_through_when_the_env_var_is_off(self, monkeypatch, capsy else: monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", env_value) monkeypatch.setattr( - "ucode.cli.load_managed_state", + "ucode.cli.load_managed_cache", lambda ws: pytest.fail("must not read the config when disabled"), ) monkeypatch.setattr( @@ -2775,7 +2775,7 @@ def _run( else: monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: (managed, False)) - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: cached) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: cached) monkeypatch.setattr("ucode.cli.get_databricks_token", lambda *a, **k: "tok") monkeypatch.setattr("ucode.cli.is_workspace_admin", lambda *a, **k: is_admin) monkeypatch.setattr( @@ -2835,7 +2835,7 @@ def test_dry_run_uses_the_cache_and_does_not_fetch(self, monkeypatch): "ucode.cli.refresh_managed_config", lambda state: pytest.fail("--dry-run must not fetch"), ) - monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: self.MANAGED) + monkeypatch.setattr("ucode.cli.load_managed_cache", lambda ws: self.MANAGED) launched: list[tuple] = [] monkeypatch.setattr( "ucode.cli._launch_tool", lambda tool, ctx, **kw: launched.append((tool, kw)) diff --git a/tests/test_managed_config.py b/tests/test_managed_config.py index 8115c47c..45429ad6 100644 --- a/tests/test_managed_config.py +++ b/tests/test_managed_config.py @@ -13,10 +13,12 @@ import ucode.managed_config as mc_mod from ucode.managed_config import ( get_managed_config, + load_managed_cache, load_managed_state, managed_state_workspace, normalize_managed_config, refresh_managed_config, + save_managed_cache, save_managed_state, ) from ucode.managed_setup import serialize_managed_config @@ -195,6 +197,10 @@ class TestPersistence: @pytest.fixture(autouse=True) def _managed_path(self, tmp_path, monkeypatch): path = tmp_path / ".ucode" / "managed-state.json" + monkeypatch.setattr(mc_mod, "MANAGED_CACHE_DIR", tmp_path / ".ucode" / "managed-cache") + monkeypatch.setattr( + mc_mod, "LEGACY_MANAGED_CACHE_PATH", tmp_path / ".ucode" / "managed-cache.json" + ) monkeypatch.setattr(mc_mod, "MANAGED_STATE_PATH", path) return path @@ -204,12 +210,75 @@ def test_save_then_load_round_trips(self, _managed_path): loaded = load_managed_state("https://ws.example.com") assert loaded == cfg + def test_schema_version_is_the_first_field(self, _managed_path): + save_managed_state("https://ws.example.com", {"default_agent": "claude"}) + payload = json.loads(_managed_path.read_text(encoding="utf-8")) + assert list(payload) == ["schema_version", "workspace", "config"] + assert payload["schema_version"] == "1.0" + + def test_v1_schema_is_read(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "schema_version": "1.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") == {"default_agent": "claude"} + + def test_unversioned_legacy_state_is_read_as_v1(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") == {"default_agent": "claude"} + + def test_unknown_future_schema_is_not_interpreted_as_v1(self, _managed_path): + _managed_path.parent.mkdir(parents=True, exist_ok=True) + _managed_path.write_text( + json.dumps( + { + "schema_version": "2.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + assert load_managed_state("https://ws.example.com") is None + assert managed_state_workspace() is None + def test_saved_file_is_0600(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 + def test_atomic_replace_failure_preserves_the_previous_draft(self, _managed_path, monkeypatch): + original = {"default_agent": "claude"} + save_managed_state("https://ws.example.com", original) + + def fail_replace(*_args): + raise OSError + + monkeypatch.setattr(mc_mod.os, "replace", fail_replace) + + with pytest.raises(RuntimeError, match="Failed to write managed config file"): + save_managed_state("https://ws.example.com", {"default_agent": "codex"}) + + assert load_managed_state("https://ws.example.com") == original + assert list(_managed_path.parent.glob(f".{_managed_path.name}.*.tmp")) == [] + def test_load_ignores_other_workspace(self, _managed_path): save_managed_state("https://ws-a.example.com", {"default_agent": "claude"}) assert load_managed_state("https://ws-b.example.com") is None @@ -233,6 +302,59 @@ def test_workspace_is_stored_alongside_the_config(self, _managed_path): save_managed_state("https://ws.example.com", {"default_agent": "claude"}) assert managed_state_workspace() == "https://ws.example.com" + def test_published_cache_is_separate_from_the_editable_draft(self, _managed_path): + draft = {"default_agent": "claude"} + published = {"default_agent": "codex"} + save_managed_state("https://ws.example.com", draft) + save_managed_cache("https://ws.example.com", published) + + assert load_managed_state("https://ws.example.com") == draft + assert load_managed_cache("https://ws.example.com") == published + + def test_workspace_caches_are_independent_and_include_provenance(self, _managed_path): + first_workspace = "https://first.example.com" + second_workspace = "https://second.example.com" + first = {"default_agent": "claude"} + second = {"default_agent": "codex"} + + save_managed_cache(first_workspace, first) + save_managed_cache(second_workspace, second) + + assert load_managed_cache(first_workspace) == first + assert load_managed_cache(second_workspace) == second + cache_files = sorted(mc_mod.MANAGED_CACHE_DIR.glob("*.json")) + assert len(cache_files) == 2 + payload = json.loads(mc_mod._managed_cache_path(first_workspace).read_text()) + assert payload["workspace"] == first_workspace + assert payload["metadata"]["source"] == "workspace" + assert payload["metadata"]["fetched_at"].endswith("Z") + assert payload["metadata"]["config_digest"] == mc_mod._config_digest(first) + + def test_reads_the_legacy_single_workspace_cache(self, _managed_path): + mc_mod.LEGACY_MANAGED_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + mc_mod.LEGACY_MANAGED_CACHE_PATH.write_text( + json.dumps( + { + "schema_version": "1.0", + "workspace": "https://ws.example.com", + "config": {"default_agent": "claude"}, + } + ), + encoding="utf-8", + ) + + assert load_managed_cache("https://ws.example.com") == {"default_agent": "claude"} + + def test_rejects_a_cache_whose_config_does_not_match_its_digest(self, _managed_path): + workspace = "https://ws.example.com" + save_managed_cache(workspace, {"default_agent": "claude"}) + cache_path = mc_mod._managed_cache_path(workspace) + payload = json.loads(cache_path.read_text(encoding="utf-8")) + payload["config"]["default_agent"] = "codex" + cache_path.write_text(json.dumps(payload), encoding="utf-8") + + assert load_managed_cache(workspace) is None + def test_workspace_is_none_when_absent(self, _managed_path): assert managed_state_workspace() is None @@ -329,13 +451,27 @@ def _stub_token(self, monkeypatch): def test_persists_and_returns_the_manifest(self, monkeypatch): saved: list[tuple] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (MANAGED, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: saved.append((ws, cfg))) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: saved.append((ws, cfg))) assert refresh_managed_config(_state()) == (MANAGED, False) assert saved == [(WORKSPACE, MANAGED)] + def test_refresh_never_overwrites_the_authored_draft(self, tmp_path, monkeypatch): + draft = {"default_agent": "codex", "enabled_agents": {"codex": {}}} + state_path = tmp_path / "managed-state.json" + cache_dir = tmp_path / "managed-cache" + monkeypatch.setattr(mc_mod, "MANAGED_STATE_PATH", state_path) + monkeypatch.setattr(mc_mod, "MANAGED_CACHE_DIR", cache_dir) + monkeypatch.setattr(mc_mod, "LEGACY_MANAGED_CACHE_PATH", tmp_path / "legacy-cache.json") + monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (MANAGED, None)) + save_managed_state(WORKSPACE, draft) + + assert refresh_managed_config(_state()) == (MANAGED, False) + assert load_managed_state(WORKSPACE) == draft + assert load_managed_cache(WORKSPACE) == MANAGED + def test_no_managed_config_returns_none(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) result, _ = refresh_managed_config(_state()) assert result is None @@ -343,7 +479,7 @@ def test_read_failure_falls_back_to_the_persisted_config(self, monkeypatch): # The admin's last known policy beats no policy, so a failed fetch reuses what we saved. warnings: list[str] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) assert refresh_managed_config(_state()) == (MANAGED, False) assert "HTTP 500" in warnings[0] @@ -353,7 +489,7 @@ def test_read_failure_without_persisted_config_is_silent(self, monkeypatch): # Nothing persisted means no managed config is in play, so an expired session shouldn't # produce a warning about a feature this developer doesn't use. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -367,7 +503,7 @@ def boom(ws, profile): raise RuntimeError("no token") monkeypatch.setattr(mc_mod, "get_databricks_token", boom) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) assert refresh_managed_config(_state()) == (MANAGED, False) assert "no token" in warnings[0] @@ -377,7 +513,7 @@ def boom(ws, profile): raise RuntimeError("no token") monkeypatch.setattr(mc_mod, "get_databricks_token", boom) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -389,7 +525,7 @@ def test_permission_denied_without_cache_is_silent(self, monkeypatch): # config in play and warning would be a false positive. denied = 'HTTP 403 Forbidden: {"error_code":"PERMISSION_DENIED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, denied)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -402,10 +538,10 @@ def test_permission_denied_warns_and_keeps_the_cached_config(self, monkeypatch): warnings: list[str] = [] denied = 'HTTP 403 Forbidden: {"error_code":"PERMISSION_DENIED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, denied)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: warnings.append(msg)) monkeypatch.setattr( - mc_mod, "save_managed_state", lambda ws, cfg: pytest.fail("must not clear the cache") + mc_mod, "save_managed_cache", lambda ws, cfg: pytest.fail("must not clear the cache") ) assert refresh_managed_config(_state()) == (MANAGED, False) assert "not readable by you" in warnings[0] @@ -414,9 +550,9 @@ def test_no_config_on_the_server_does_not_use_a_stale_persisted_file(self, monke # A successful read saying "no config" means the admin removed it — that's authoritative, # so a previously persisted file must not resurrect the old policy. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) monkeypatch.setattr( - mc_mod, "load_managed_state", lambda ws: pytest.fail("must not fall back") + mc_mod, "load_managed_cache", lambda ws: pytest.fail("must not fall back") ) result, _ = refresh_managed_config(_state()) assert result is None @@ -426,8 +562,8 @@ def test_no_config_on_the_server_clears_the_persisted_one(self, monkeypatch): # failed read would put a dead policy back into force. saved: list[tuple] = [] monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: saved.append((ws, cfg))) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: saved.append((ws, cfg))) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) result, _ = refresh_managed_config(_state()) assert result is None assert saved == [(WORKSPACE, {})] @@ -436,7 +572,7 @@ def test_empty_persisted_config_is_not_treated_as_a_fallback(self, monkeypatch): # The empty marker means "no admin policy", so a later failed read falls through to the # developer's own settings rather than reporting a managed config. monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: {}) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: {}) monkeypatch.setattr( mc_mod, "print_warning", lambda msg: pytest.fail(f"should not warn: {msg}") ) @@ -455,7 +591,7 @@ def test_feature_disabled_sets_flag_when_there_is_no_fallback(self, monkeypatch) # publish anything yet. The flag lets callers suppress the setup recommendation. reason = 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, reason)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -467,7 +603,7 @@ def test_feature_disabled_with_a_fallback_does_not_set_the_flag(self, monkeypatc # feature-off flag is irrelevant and must not be set. reason = 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED"}' monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, reason)) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: MANAGED) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: MANAGED) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -476,7 +612,7 @@ def test_feature_disabled_with_a_fallback_does_not_set_the_flag(self, monkeypatc def test_transient_failure_does_not_set_the_flag(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, "HTTP 500")) - monkeypatch.setattr(mc_mod, "load_managed_state", lambda ws: None) + monkeypatch.setattr(mc_mod, "load_managed_cache", lambda ws: None) monkeypatch.setattr(mc_mod, "print_warning", lambda msg: None) state = _state() result, flag = refresh_managed_config(state) @@ -485,7 +621,7 @@ def test_transient_failure_does_not_set_the_flag(self, monkeypatch): def test_successful_no_config_clears_the_flag(self, monkeypatch): monkeypatch.setattr(mc_mod, "get_managed_config", lambda ws, tok: (None, None)) - monkeypatch.setattr(mc_mod, "save_managed_state", lambda ws, cfg: None) + monkeypatch.setattr(mc_mod, "save_managed_cache", lambda ws, cfg: None) state = _state() result, flag = refresh_managed_config(state) assert result is None