diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index ecb57f7d..b54e1360 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -70,6 +70,16 @@ DEFAULT_TOOL = "codex" BUNDLE_VERSION = 1 +# Agents that can mirror ucode's managed config into the tool's NATIVE default config file, so a +# bare `claude` / `codex` (not just `ucode `) picks up the gateway settings. This is gated by +# the admin's `use_as_global_settings` choice in `ucode setup`. Only agents whose gateway auth +# self-refreshes qualify: claude's `apiKeyHelper` and codex's `ucode auth-token` command both re-mint +# tokens on their own, so the native file keeps working indefinitely. The other agents bake a +# short-lived bearer token with no bare-launch refresher (opencode/pi/gemini) or expose no native +# config file at all (copilot is env-var only), so they're excluded — and `ucode setup` doesn't even +# ask them the machine-wide question. +GLOBAL_SETTINGS_AGENTS = frozenset({"claude", "codex"}) + # ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported. AITOOLS_AGENT_TOKENS = { "claude": "claude-code", diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 2ad47402..e818cdf9 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -10,8 +10,8 @@ import signal import socket import subprocess -import sys import threading +from collections.abc import Callable from pathlib import Path from typing import cast @@ -30,6 +30,7 @@ get_databricks_token, ) from ucode.launcher import exec_or_spawn +from ucode.managed_files import OS, current_os, write_managed_file from ucode.smart_routing.claude_hooks import ( remove_smart_routing_hooks, sync_smart_routing_hooks, @@ -135,9 +136,9 @@ def _resolve_web_search_model(state: dict) -> str | None: def _managed_settings_path() -> Path | None: """OS-specific location of Claude Code's enterprise managed-settings.json. Returns None on unsupported platforms.""" - if sys.platform.startswith("linux"): + if current_os() is OS.LINUX: return Path("/etc/claude-code/managed-settings.json") - if sys.platform == "darwin": + if current_os() is OS.MACOS: return Path("/Library/Application Support/ClaudeCode/managed-settings.json") return None @@ -516,43 +517,49 @@ def write_tool_config( "to install the Claude Stop hook — traces won't be emitted. Re-run " "`ucode configure tracing`." ) - - existing = read_json_safe(CLAUDE_SETTINGS_PATH) - merged = deep_merge_dict(existing, overlay) - # Drop any apiKeyHelper a prior non-relayed launch left in the file; relayed - # must not carry one (it would outrank the subscription OAuth). - if relayed: - merged.pop("apiKeyHelper", None) - if tracing_env_vars and stop_hook_command: - _upsert_tracing_stop_hook(merged, stop_hook_command) - if not tracing_env_vars: - env_block = merged.get("env") - if isinstance(env_block, dict): - for key in CLAUDE_TRACING_ENV_KEYS: - env_block.pop(key, None) - # Strip only ucode's tracing Stop hook so user hooks stay intact. - _remove_tracing_stop_hook(merged) - # Prune ucode-managed model env keys we deliberately don't write this run - # (e.g. ANTHROPIC_MODEL — see render_overlay). - overlay_env = overlay.get("env", {}) - merged_env = merged.get("env") - if isinstance(merged_env, dict): - for key in CLAUDE_MANAGED_MODEL_ENV_KEYS: - if key not in overlay_env: - merged_env.pop(key, None) - # deep_merge_dict keeps keys already in the file, so drop the ones ucode no - # longer writes. - if isinstance(merged_env, dict): - for key in CLAUDE_REMOVED_ENV_KEYS: - merged_env.pop(key, None) - # Smart-routing hooks: install ucode's PreToolUse/SessionStart/ - # SubagentStart hooks when routing is enabled (and not under a provider, - # which pins no Databricks model), else surgically strip only ucode's own. + # Smart-routing hooks: install ucode's PreToolUse/SessionStart/SubagentStart hooks when routing + # is enabled (and not under a provider, which pins no Databricks model), else surgically strip + # only ucode's own. Applied per file inside _compose_claude_settings. routing_enabled = smart_routing_enabled(state) and provider is None - sync_smart_routing_hooks(merged, state, enabled=routing_enabled) if routing_enabled: managed_keys = managed_keys + [["hooks", event] for event in CLAUDE_ROUTING_HOOK_EVENTS] - write_json_file(CLAUDE_SETTINGS_PATH, merged) + + def _compose(base: dict) -> dict: + # deepcopy the overlay per file so merging into one base can't alias nested dicts into + # the other (deep_merge_dict grafts overlay's own dict objects onto a base missing the key). + merged = deep_merge_dict(base, copy.deepcopy(overlay)) + # Drop any apiKeyHelper a prior non-relayed launch left in the file; relayed + # must not carry one (it would outrank the subscription OAuth). + if relayed: + merged.pop("apiKeyHelper", None) + if tracing_env_vars and stop_hook_command: + _upsert_tracing_stop_hook(merged, stop_hook_command) + if not tracing_env_vars: + env_block = merged.get("env") + if isinstance(env_block, dict): + for key in CLAUDE_TRACING_ENV_KEYS: + env_block.pop(key, None) + # Strip only ucode's tracing Stop hook so user hooks stay intact. + _remove_tracing_stop_hook(merged) + # Prune ucode-managed model env keys we deliberately don't write this run + # (e.g. ANTHROPIC_MODEL — see render_overlay). + overlay_env = overlay.get("env", {}) + merged_env = merged.get("env") + if isinstance(merged_env, dict): + for key in CLAUDE_MANAGED_MODEL_ENV_KEYS: + if key not in overlay_env: + merged_env.pop(key, None) + # deep_merge_dict keeps keys already in the file, so drop the ones ucode no + # longer writes. + for key in CLAUDE_REMOVED_ENV_KEYS: + merged_env.pop(key, None) + sync_smart_routing_hooks(merged, state, enabled=routing_enabled) + return merged + + write_json_file(CLAUDE_SETTINGS_PATH, _compose(read_json_safe(CLAUDE_SETTINGS_PATH))) + + if state.get("write_managed_config"): + _write_managed_settings(_compose, relayed) if web_search_model: _register_web_search_mcp(state["workspace"], web_search_model, state.get("profile")) @@ -569,6 +576,36 @@ def write_tool_config( return state +def _write_managed_settings(compose: Callable[[dict], dict], relayed: bool) -> None: + """Write ucode's config into Claude Code's OS managed-settings.json so a bare `claude` works. + + Runs only under use_as_global_settings. The managed file is root-owned and the highest-precedence + scope, so it applies whether or not `ucode` launches `claude`. The same compose (merge overlay + + prune stale keys) that produced the private file is applied to the existing managed file, so any + real IT-authored keys already there survive. The write goes through the sudo path in + `managed_files` (drift-suppressed, so no password prompt when unchanged). + + Relayed launches are skipped: they depend on a per-session loopback refresh proxy that only runs + during `ucode claude`, so a bare `claude` could not reach the gateway anyway. + """ + if relayed: + print_warning( + "Claude subscription-relay launches use a per-session proxy a bare `claude` can't " + "reach, so ucode did not write the managed settings file. Launch with `ucode claude` " + "to use the relay." + ) + return + path = _managed_settings_path() + if path is None: + print_warning( + "Machine-wide Claude settings aren't supported on this platform; skipped the managed " + "settings write." + ) + return + desired = json.dumps(compose(read_json_safe(path)), indent=2) + write_managed_file(path, desired, display="Claude Code") + + def _is_tracing_stop_hook(hook: object) -> bool: if not isinstance(hook, dict): return False diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index f64747d4..0e153cb4 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -9,6 +9,8 @@ import time from pathlib import Path +import tomlkit + from ucode.agent_updates import available_npm_package_update from ucode.config_io import ( APP_DIR, @@ -24,6 +26,7 @@ get_databricks_token, ) from ucode.launcher import exec_or_spawn +from ucode.managed_files import OS, current_os, write_managed_file from ucode.smart_routing.codex_hooks import ( remove_smart_routing_hooks, sync_smart_routing_hooks, @@ -354,11 +357,62 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non enabled=smart_routing_enabled(state) and provider is None, ) write_toml_file(CODEX_CONFIG_PATH, doc) + # use_as_global_settings: also write the modern overlay to Codex's OS managed config + # (/etc/codex/managed_config.toml), the highest-precedence scope a bare `codex` reads — so it + # defaults to the gateway without `--profile ucode`. codex auth self-refreshes via + # `ucode auth-token`, so the file keeps working. The write goes through the sudo path in + # `managed_files`. + if state.get("write_managed_config"): + _write_managed_config( + workspace, chosen_model, databricks_profile, bool(state.get("use_pat")), provider + ) state = mark_tool_managed(state, "codex", MANAGED_KEYS) save_state(state) return state +def _managed_config_path() -> Path | None: + """OS-level Codex managed config file, or None on unsupported platforms. + + Linux and macOS use ``/etc/codex/managed_config.toml`` (root-owned, highest precedence). See + https://learn.chatgpt.com/docs/enterprise/managed-configuration. Codex also supports a + ``~/.codex/managed_config.toml`` on Windows, but ucode's write path is sudo/Unix-only + (see :func:`managed_files.managed_files_supported`), so Windows returns None here too. + """ + if current_os() in (OS.LINUX, OS.MACOS): + return Path("/etc/codex/managed_config.toml") + return None + + +def _write_managed_config( + workspace: str, + model: str | None, + databricks_profile: str | None, + use_pat: bool, + provider: str | None, +) -> None: + """Merge the modern overlay into Codex's OS managed_config.toml, preserving any other keys there. + + Written via the sudo path in `managed_files` (drift-suppressed). + """ + path = _managed_config_path() + if path is None: + print_warning_err( + "Machine-wide Codex settings aren't supported on this platform; skipped the managed " + "config write." + ) + return + overlay = render_overlay( + workspace, model, databricks_profile, use_pat=use_pat, provider=provider + ) + doc = read_toml_safe(path) + deep_merge_dict(doc, overlay) + if provider: + # deep_merge can't drop keys; clear a `model` a prior non-provider run pinned. + doc.pop("model", None) + write_managed_file(path, tomlkit.dumps(doc), display="Codex") + + def default_model(state: dict) -> str | None: """Pick the newest GPT model when multiple are available. diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 602ae1ae..561c9a55 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -76,6 +76,7 @@ managed_provider_service, managed_supplies_models, managed_unservable_models, + managed_use_as_global_settings, recommended_agent, resolve_state, ) @@ -1602,8 +1603,10 @@ def _launch_tool( ) # The enterprise scope outranks the --settings file ucode writes, so a model pinned # there quietly beats the admin's — point at the file rather than let the mismatch - # look like a ucode bug. - if tool == "claude": + # look like a ucode bug. Suppressed under use_as_global_settings: there ucode itself + # authored that managed-settings file, so its model keys are the admin's config, not an + # external override. + if tool == "claude" and not managed_use_as_global_settings(managed, "claude"): overrides = claude_agent.managed_settings_model_overrides() if overrides is not None: print_warning( @@ -1711,11 +1714,16 @@ def _launch_tool( # the id can't ride `resolved_model` — it is threaded separately as `custom_model`. if model and tool != "claude": resolved_model = model - # Claude Code's enterprise managed-settings scope (e.g. an Isaac/dbexec install) + # Claude Code's enterprise managed-settings scope (e.g. a dbexec install) # outranks the --settings file ucode writes AND can't be excluded with --setting-sources, # so a model pinned there silently wins over `--model`. Warn so a launch that ignores the # requested model looks like the misconfiguration it is, not a ucode bug. - if model and tool == "claude": + # Suppressed when ucode authored the managed-settings file itself (use_as_global_settings) + # — the pinned model is then ucode's own, deliberately applied, not a surprise override. + managed_owns_claude = managed is not None and managed_use_as_global_settings( + managed, "claude" + ) + if model and tool == "claude" and not managed_owns_claude: enterprise = claude_agent.managed_settings_model_overrides() if enterprise is not None: print_warning( diff --git a/src/ucode/config_io.py b/src/ucode/config_io.py index 06446b9a..6a92066d 100644 --- a/src/ucode/config_io.py +++ b/src/ucode/config_io.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import TypedDict +from typing import TypedDict, cast import tomlkit import tomlkit.exceptions @@ -107,10 +107,47 @@ def deep_merge_dict(base: dict, overlay: dict) -> dict: return base +def prune_key_paths(doc: dict, key_paths: list[list[str]]) -> bool: + """Surgically remove each ``key_path`` from a nested dict, dropping emptied parents. + + ``key_paths`` is a list of paths like ``[["env", "ANTHROPIC_BASE_URL"], ["apiKeyHelper"]]``. + Only the exact leaves are removed; sibling keys the user set themselves stay untouched, and a + parent dict left empty by the removal is dropped too. Returns True if anything changed. + + Used to undo ucode's writes to an agent's *native* config file (which it shares with the + user's own settings), where restoring a backup would clobber edits made since ucode first ran. + """ + changed = False + for path in key_paths: + if _prune_one(doc, list(path)): + changed = True + return changed + + +def _prune_one(node: object, path: list[str]) -> bool: + if not path or not isinstance(node, dict): + return False + mapping = cast("dict[str, object]", node) + key = path[0] + if key not in mapping: + return False + if len(path) == 1: + mapping.pop(key, None) + return True + child = mapping.get(key) + removed = _prune_one(child, path[1:]) + if removed and isinstance(child, dict) and not child: + mapping.pop(key, None) + return removed + + def read_json_safe(path: Path) -> dict: - if not path.exists(): - return {} + # `path.exists()` is inside the try: stat-ing a file under a root-locked dir (e.g. a + # root-owned /etc/codex) raises PermissionError, which must read as "absent/unreadable → {}" + # rather than crash a launch that only wanted to merge into it. try: + if not path.exists(): + return {} data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} @@ -118,9 +155,11 @@ def read_json_safe(path: Path) -> dict: def read_toml_safe(path: Path) -> tomlkit.TOMLDocument: - if not path.exists(): - return tomlkit.document() + # See read_json_safe: keep `path.exists()` inside the try so a PermissionError on a locked + # parent directory is treated as an empty document rather than propagating. try: + if not path.exists(): + return tomlkit.document() return tomlkit.parse(path.read_text(encoding="utf-8")) except (OSError, tomlkit.exceptions.TOMLKitError): return tomlkit.document() diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py new file mode 100644 index 00000000..40e58803 --- /dev/null +++ b/src/ucode/managed_files.py @@ -0,0 +1,188 @@ +"""Write agent config into OS-level *managed settings* files. + +These files are root-owned and the highest-precedence config scope for their agent — a bare +``claude`` / ``codex`` (launched directly, without ucode) reads them, so writing here is what makes +the gateway config apply outside ``ucode ``: + +- Claude Code: ``/etc/claude-code/managed-settings.json`` (Linux), + ``/Library/Application Support/ClaudeCode/managed-settings.json`` (macOS) +- Codex: ``/etc/codex/managed_config.toml`` (Linux + macOS) + +The write is guarded by a **drift check**: it reads the world-readable file WITHOUT sudo and does +nothing when it already matches, so the common no-op launch never prompts for a password; only a +real change shells out to ``sudo`` (temp file → ``sudo cp``), clearing and restoring the immutable +flag (``chattr``/``chflags``) that a fleet golden image may have set. Writing needs root, so the +first write (or one after the config changes) prompts for the developer's sudo password. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +import tempfile +from enum import Enum +from pathlib import Path + +from ucode.config_io import is_dry_run +from ucode.ui import console, print_err, print_warning + +# Absolute path so a stripped PATH (desktop/GUI launchers) still finds it. +_SUDO = "/usr/bin/sudo" + + +class OS(Enum): + """The host OS families this module distinguishes, off `sys.platform`.""" + + LINUX = "linux" + MACOS = "macos" + WINDOWS = "windows" + OTHER = "other" + + +def current_os() -> OS: + """Map `sys.platform` onto :class:`OS` (lowercased, so a mixed-case value can't slip through).""" + platform = sys.platform.lower() + if platform.startswith("linux"): + return OS.LINUX + if platform == "darwin": + return OS.MACOS + if platform.startswith("win"): + return OS.WINDOWS + return OS.OTHER + + +def managed_files_supported() -> bool: + """True on the platforms whose managed-settings write path is implemented (Linux, macOS). + + The write path needs `sudo` (`sudo cp`, `chattr`/`chflags`), which is Unix-only — so Windows and + any other platform are unsupported. + """ + return current_os() in (OS.LINUX, OS.MACOS) + + +def _read_existing(path: Path) -> str: + """Current file contents, or "" when absent. No sudo — the managed file is world-readable.""" + try: + return path.read_text(encoding="utf-8") if path.exists() else "" + except OSError: + return "" + + +def write_managed_file(path: Path, desired_text: str, *, display: str) -> str: + """Write ``desired_text`` to a root-owned managed file, only when it differs (drift check). + + Returns ``"written"``, ``"unchanged"``, or ``"skipped"``. Never raises: a permission or immutable + failure is surfaced as an actionable message and reported as ``"skipped"`` so the launch still + proceeds (the private ucode config already lets ``ucode `` work). + """ + if not managed_files_supported(): + print_warning( + f"{display}: machine-wide managed settings aren't supported on this platform; " + f"skipped {path}." + ) + return "skipped" + # Drift check first — reading is unprivileged, so an unchanged file never triggers a sudo prompt. + if _read_existing(path) == desired_text: + return "unchanged" + if is_dry_run(): + console.print(f"\n[bold]\\[dry run] {path} (via sudo)[/bold]\n{desired_text}") + return "written" + try: + _sudo_replace(path, desired_text) + except PermissionError as exc: + print_err( + f"{display}: cannot write {path} without root ({exc}). Re-run with `sudo ucode ...` to " + "apply the config machine-wide." + ) + return "skipped" + except subprocess.CalledProcessError as exc: + _report_sudo_failure(path, display, exc) + return "skipped" + return "written" + + +def _sudo_replace(path: Path, desired_text: str) -> None: + """Replace ``path`` with ``desired_text`` via sudo (temp file → ``sudo cp``), handling immutability. + + Writes the payload to a user-owned temp file first (no sudo), then copies it into place with + ``sudo`` and makes it world-readable so the file it lays down is readable by the agent binary + regardless of who launched it. + """ + subprocess.run([_SUDO, "mkdir", "-p", str(path.parent)], check=True) + with tempfile.NamedTemporaryFile( + mode="w", suffix=path.suffix or ".tmp", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(desired_text) + tmp_path = tmp.name + try: + restore_immutable = _clear_immutable(path) + try: + # capture_output so the CalledProcessError on failure (e.g. still-immutable dest) carries + # cp's stderr for an actionable message. + subprocess.run( + [_SUDO, "cp", tmp_path, str(path)], capture_output=True, text=True, check=True + ) + subprocess.run([_SUDO, "chmod", "a+rx", str(path.parent)], check=True) + subprocess.run([_SUDO, "chmod", "a+r", str(path)], check=True) + finally: + if restore_immutable: + _restore_immutable(path) + finally: + os.unlink(tmp_path) + + +def _clear_immutable(path: Path) -> bool: + """Clear an immutable flag a fleet golden image may have set. Returns whether to restore it. + + macOS: preserve JAMF's system-immutable ``schg`` across the update — inspect, unlock only when + set, and report that it must be restored. Linux: best-effort ``chattr -i`` (not every filesystem + supports it), never restored. + """ + try: + # `path.exists()` stats the file; under a root-locked parent dir (e.g. a 750 /etc/codex we + # haven't opened yet) that raises PermissionError. There's nothing to unlock we can see, and + # the subsequent `sudo cp` (as root) overwrites regardless, so treat it as "nothing to clear". + if not path.exists(): + return False + except OSError: + return False + if current_os() is OS.MACOS: + result = subprocess.run( + ["/usr/bin/stat", "-f", "%Sf", str(path)], capture_output=True, text=True, check=False + ) + if result.returncode == 0 and "schg" in result.stdout.strip().split(","): + subprocess.run( + [_SUDO, "chflags", "noschg", str(path)], capture_output=True, text=True, check=True + ) + return True + return False + subprocess.run([_SUDO, "chattr", "-i", str(path)], capture_output=True, text=True, check=False) + return False + + +def _restore_immutable(path: Path) -> None: + """Re-set macOS's ``schg`` flag after a write. Best-effort so it never masks the write result.""" + result = subprocess.run( + [_SUDO, "chflags", "schg", str(path)], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + print_warning(f"Could not restore the immutable flag on {path}.") + + +def _report_sudo_failure(path: Path, display: str, exc: subprocess.CalledProcessError) -> None: + """Surface a sudo helper failure with a concrete fix. An immutable destination is the common + cause — cp fails with EPERM even under root — so point at the OS-specific clear command.""" + stderr = (exc.stderr or "").strip() if isinstance(exc.stderr, str) else "" + cmd = exc.cmd or [] + cp_failed = len(cmd) >= 2 and cmd[1] == "cp" + if cp_failed and "Operation not permitted" in stderr: + quoted = shlex.quote(str(path)) + clear_cmd = f"sudo {'chflags noschg' if current_os() is OS.MACOS else 'chattr -i'} {quoted}" + print_err( + f"{display}: {path} appears to be immutable. Clear the immutable attribute and re-run:\n" + f" {clear_cmd}\n ucode ..." + ) + else: + print_err(f"{display}: failed to write managed settings at {path}: {stderr or exc}") diff --git a/src/ucode/managed_resolve.py b/src/ucode/managed_resolve.py index b6658d4e..811b9f1f 100644 --- a/src/ucode/managed_resolve.py +++ b/src/ucode/managed_resolve.py @@ -21,6 +21,7 @@ from typing import cast +from ucode.agents import GLOBAL_SETTINGS_AGENTS from ucode.databricks import ANTHROPIC_FAMILIES, classify_model_family from ucode.state import MANAGED_OVERLAY_KEY @@ -176,6 +177,21 @@ def managed_provider_service(managed: dict, tool: str) -> str | None: return _str(_agent_model_config(managed, tool).get("model_provider_service")) +def managed_use_as_global_settings(managed: dict, tool: str) -> bool: + """True when the admin marked ``tool`` machine-wide AND ``tool`` can support it. + + ``use_as_global_settings`` means: also write the agent's OS-level managed settings file + (``/etc/claude-code/managed-settings.json``, ``/etc/codex/managed_config.toml``) so a bare + ``claude`` / ``codex`` picks up the gateway config. Only agents in + :data:`~ucode.agents.GLOBAL_SETTINGS_AGENTS` have such a file, so the flag is ignored for any + other agent — a hand-written ``--from-file`` config can't turn it on for an agent that has no + managed settings path. + """ + if tool not in GLOBAL_SETTINGS_AGENTS: + return False + return bool(_agent_entry(managed, tool).get("use_as_global_settings")) + + def managed_default_model(managed: dict, tool: str) -> str | None: """Return the model the managed config wants ``tool`` to launch on, if it names one. @@ -274,6 +290,12 @@ def resolve_state(managed: dict, state: dict, tool: str) -> dict: overlay["provider_services"] = state.get("provider_services") providers[tool] = provider resolved["provider_services"] = providers + if managed_use_as_global_settings(managed, tool): + # Transient: recorded in the overlay so `save_state` strips it before persisting. It exists + # only for this config-write, telling the agent's `write_tool_config` to also write the OS + # managed settings file. A non-managed launch never sets it, so default behavior is unchanged. + overlay["write_managed_config"] = state.get("write_managed_config") + resolved["write_managed_config"] = True if overlay: resolved[MANAGED_OVERLAY_KEY] = overlay return resolved diff --git a/src/ucode/managed_wizard.py b/src/ucode/managed_wizard.py index ea548ef9..2940b8b9 100644 --- a/src/ucode/managed_wizard.py +++ b/src/ucode/managed_wizard.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import cast -from ucode.agents import TOOL_SPECS, check_gateway_endpoint +from ucode.agents import GLOBAL_SETTINGS_AGENTS, TOOL_SPECS, check_gateway_endpoint from ucode.databricks import ( ANTHROPIC_FAMILIES, all_users_can_use_schema, @@ -74,13 +74,19 @@ spinner, ) -# What `use_as_global_settings` actually does, in plain terms. Admins are choosing whether to write -# the agent's own global settings file (so it points at the gateway even when launched directly) or -# a ucode-specific one (so the agent only routes through the gateway when launched via ucode). +# The OS-level managed settings file `use_as_global_settings` writes for each agent — named in the +# prompt so an admin sees exactly what answering "yes" touches. +GLOBAL_SETTINGS_FILES = { + "claude": "Claude Code's managed-settings.json", + "codex": "Codex's managed_config.toml", +} + +# What `use_as_global_settings` actually does, in plain terms. `{binary}` is filled in per agent. GLOBAL_SETTINGS_BLURB = ( - "Answer Yes to write this agent's own global settings file, so it points at the Databricks " - "gateway even when launched directly, without ucode. Answer no to write a ucode-specific " - "settings file instead, so the agent only routes through the gateway when launched via ucode." + "Answer yes to write the gateway config into that file (needs sudo once), so a bare `{binary}` " + "reaches the Databricks gateway on its own — you don't have to launch it through ucode. Answer " + "no to write a ucode-only settings file instead, so `{binary}` uses the gateway only when " + "started with `ucode {binary}`." ) BUDGET_POLICY_BLURB = ( @@ -966,8 +972,14 @@ def _render_summary(workspace: str, manifest: dict) -> None: provider = model_config.get("model_provider_service") if provider: detail = f"{detail} via {provider}" - scope = "global settings" if agent_config.get("use_as_global_settings") else "ucode-only" - lines.append(kv_line(display, f"{detail} ({scope})")) + # Only agents that can use global settings carry the scope label; for the rest it's not a choice. + if tool in GLOBAL_SETTINGS_AGENTS: + scope = ( + "global settings" if agent_config.get("use_as_global_settings") else "ucode-only" + ) + lines.append(kv_line(display, f"{detail} ({scope})")) + else: + lines.append(kv_line(display, detail)) # Spell out the per-family slots and model lists: the one-line default alone doesn't show # which families an admin configured, which is most of what they chose for claude. models = model_config.get("models") @@ -1225,11 +1237,16 @@ def setup_command( agent_config: dict = { "model_config": _prompt_models_for_agent(tool, state, provider_service) } - agent_config["use_as_global_settings"] = prompt_yes_no_default( - f"Write {TOOL_SPECS[tool]['display']}'s config to its global settings file? " - f"({GLOBAL_SETTINGS_BLURB})", - default=False, - ) + # Only claude and codex have an OS-level managed settings file that a bare `claude`/`codex` + # reads (`/etc/claude-code/managed-settings.json`, `/etc/codex/managed_config.toml`); the + # other agents don't, so we don't offer them the choice. + if tool in GLOBAL_SETTINGS_AGENTS: + binary = TOOL_SPECS[tool]["binary"] + agent_config["use_as_global_settings"] = prompt_yes_no_default( + f"Write {TOOL_SPECS[tool]['display']}'s config to {GLOBAL_SETTINGS_FILES[tool]}? " + f"({GLOBAL_SETTINGS_BLURB.format(binary=binary)})", + default=False, + ) enabled_agents[tool] = agent_config manifest: dict = {"default_agent": default_agent, "enabled_agents": enabled_agents} diff --git a/src/ucode/state.py b/src/ucode/state.py index 0031bc4d..a73d9a2c 100644 --- a/src/ucode/state.py +++ b/src/ucode/state.py @@ -237,6 +237,7 @@ def clear_state() -> None: def mark_tool_managed(state: dict, tool: str, managed_keys: list) -> dict: + """Record which config keys ucode manages for ``tool``.""" managed_configs = dict(state.get("managed_configs") or {}) managed_configs[tool] = {"keys": list(managed_keys)} state["managed_configs"] = managed_configs diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 10f5815b..b45b6303 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -4,6 +4,7 @@ import json import os +from pathlib import Path import pytest @@ -468,6 +469,82 @@ def test_strips_stale_disable_experimental_betas(self, monkeypatch): assert written[0]["env"]["CLAUDE_CODE_USE_GATEWAY"] == "1" +FAKE_MANAGED_PATH = Path("/tmp/ucode-test/managed-settings.json") + + +class TestWriteToolConfigManagedSettings: + """use_as_global_settings: also write Claude Code's OS managed-settings.json (via sudo, mocked).""" + + def _patch(self, monkeypatch, private_writes, managed_writes, existing_by_path=None): + existing_by_path = existing_by_path or {} + monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True) + # Deep-copy the seeded existing content so the compose step can't mutate the fixture. + monkeypatch.setattr( + claude, + "read_json_safe", + lambda path: json.loads(json.dumps(existing_by_path.get(str(path), {}))), + ) + monkeypatch.setattr( + claude, + "write_json_file", + lambda path, payload: private_writes.append((str(path), payload)), + ) + monkeypatch.setattr(claude, "save_state", lambda state: None) + monkeypatch.setattr(claude, "_register_web_search_mcp", lambda *a, **kw: True) + # Deterministic managed path, and a mocked sudo writer so NO real sudo/`/etc` write happens. + monkeypatch.setattr(claude, "_managed_settings_path", lambda: FAKE_MANAGED_PATH) + + def fake_write_managed(path, text, *, display): + managed_writes.append((str(path), text)) + return "written" + + monkeypatch.setattr(claude, "write_managed_file", fake_write_managed) + + def test_writes_managed_file_when_flagged(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + self._patch(monkeypatch, private_writes, managed_writes) + state = {"workspace": WS, "codex_models": [], "write_managed_config": True} + claude.write_tool_config(state, "databricks-claude-sonnet-4") + # Private file still written; managed file written too. + assert str(claude.CLAUDE_SETTINGS_PATH) in [p for p, _ in private_writes] + assert [p for p, _ in managed_writes] == [str(FAKE_MANAGED_PATH)] + + def test_managed_file_preserves_other_keys(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + # An IT-authored key already in the managed file must survive the merge. + existing = {str(FAKE_MANAGED_PATH): {"env": {"MY_OWN": "keep"}}} + self._patch(monkeypatch, private_writes, managed_writes, existing) + state = {"workspace": WS, "codex_models": [], "write_managed_config": True} + claude.write_tool_config(state, "databricks-claude-sonnet-4") + _, text = managed_writes[0] + written = json.loads(text) + assert written["env"]["MY_OWN"] == "keep" + assert written["env"]["ANTHROPIC_BASE_URL"] + assert written["apiKeyHelper"] + + def test_no_managed_write_by_default(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + self._patch(monkeypatch, private_writes, managed_writes) + state = {"workspace": WS, "codex_models": []} + claude.write_tool_config(state, "databricks-claude-sonnet-4") + assert managed_writes == [] + + def test_relayed_skips_managed_write(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + warns: list = [] + self._patch(monkeypatch, private_writes, managed_writes) + monkeypatch.setattr(claude, "print_warning", lambda msg: warns.append(msg)) + monkeypatch.setattr(claude, "relayed_proxy_base_url", lambda state: "http://127.0.0.1:9999") + state = {"workspace": WS, "codex_models": [], "write_managed_config": True} + claude.write_tool_config(state, "databricks-claude-sonnet-4", relayed=True) + assert managed_writes == [] + assert any("bare `claude`" in w for w in warns) + + class TestRegisterWebSearchMcp: def test_clears_existing_then_adds(self, monkeypatch): import ucode.mcp as mcp_mod diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index fa8894ed..d39afd8e 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from pathlib import Path import pytest @@ -652,3 +653,56 @@ def test_fast_success_does_not_retry(self, monkeypatch): codex.launch({"workspace": WS}, []) assert exc.value.code == 0 assert fallbacks == [] + + +class TestCodexManagedConfig: + """use_as_global_settings: also write Codex's OS managed_config.toml (via sudo, mocked).""" + + def _patch(self, tmp_path, monkeypatch): + config_path = tmp_path / ".codex" / "ucode.config.toml" + managed_path = tmp_path / "etc-codex" / "managed_config.toml" + monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_BACKUP_PATH", tmp_path / "codex-ucode-config.backup.toml") + monkeypatch.setattr(codex, "agent_version", lambda binary: "0.134.0") + monkeypatch.setattr(codex, "save_state", lambda state: None) + # Deterministic managed path + a mocked sudo writer that writes straight to disk, so the test + # can read the TOML back and NO real sudo/`/etc` write ever happens. + monkeypatch.setattr(codex, "_managed_config_path", lambda: managed_path) + + def fake_write_managed(path, text, *, display): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(text, encoding="utf-8") + return "written" + + monkeypatch.setattr(codex, "write_managed_file", fake_write_managed) + return config_path, managed_path + + def test_writes_managed_config_when_flagged(self, tmp_path, monkeypatch): + _, managed_path = self._patch(tmp_path, monkeypatch) + state = {"workspace": WS, "codex_models": ["gpt-5"], "write_managed_config": True} + codex.write_tool_config(state) + + doc = read_toml_safe(managed_path) + assert doc["model_provider"] == "ucode-databricks" + assert doc["model"] == "gpt-5" + assert "ucode-databricks" in doc["model_providers"] + + def test_managed_config_preserves_other_keys(self, tmp_path, monkeypatch): + _, managed_path = self._patch(tmp_path, monkeypatch) + managed_path.parent.mkdir(parents=True, exist_ok=True) + managed_path.write_text( + 'model = "my-own"\napproval_policy = "on-request"\n', encoding="utf-8" + ) + state = {"workspace": WS, "codex_models": ["gpt-5"], "write_managed_config": True} + codex.write_tool_config(state) + + doc = read_toml_safe(managed_path) + # ucode pins its own model, but other keys already in the managed file survive. + assert doc["approval_policy"] == "on-request" + assert doc["model"] == "gpt-5" + + def test_no_managed_write_by_default(self, tmp_path, monkeypatch): + _, managed_path = self._patch(tmp_path, monkeypatch) + state = {"workspace": WS, "codex_models": ["gpt-5"]} + codex.write_tool_config(state) + assert not managed_path.exists() diff --git a/tests/test_config_io.py b/tests/test_config_io.py index 1d59c33c..d8852592 100644 --- a/tests/test_config_io.py +++ b/tests/test_config_io.py @@ -14,6 +14,7 @@ ensure_parent_dir, is_dry_run, parse_dotenv, + prune_key_paths, read_json_safe, read_toml_safe, restore_file, @@ -313,3 +314,62 @@ def test_mutates_and_returns_base(self): base = {"a": 1} result = deep_merge_dict(base, {"b": 2}) assert result is base + + +# --------------------------------------------------------------------------- +# prune_key_paths +# --------------------------------------------------------------------------- + + +class _StatDeniedPath: + """Path stand-in whose existence check raises PermissionError, as stat-ing a file under a + root-locked directory (e.g. a root-owned /etc/codex) does for a non-root process.""" + + def exists(self): + raise PermissionError(13, "Permission denied") + + def read_text(self, encoding="utf-8"): + raise AssertionError("read_text must not be reached once exists() denies") + + +class TestReadSafePermissionDenied: + def test_read_json_safe_returns_empty_on_permission_error(self): + # Regression: a locked parent dir made path.exists() raise and crashed the whole launch. + assert read_json_safe(_StatDeniedPath()) == {} + + def test_read_toml_safe_returns_empty_on_permission_error(self): + result = read_toml_safe(_StatDeniedPath()) + assert dict(result) == {} + + +class TestPruneKeyPaths: + def test_removes_leaf_and_leaves_siblings(self): + doc = {"env": {"MANAGED": "1", "USER": "2"}, "apiKeyHelper": "x"} + changed = prune_key_paths(doc, [["env", "MANAGED"], ["apiKeyHelper"]]) + assert changed is True + assert doc == {"env": {"USER": "2"}} + + def test_drops_emptied_parent(self): + doc = {"env": {"ONLY": "1"}} + assert prune_key_paths(doc, [["env", "ONLY"]]) is True + assert doc == {} + + def test_keeps_parent_with_remaining_siblings(self): + doc = {"env": {"A": "1", "B": "2"}} + assert prune_key_paths(doc, [["env", "A"]]) is True + assert doc == {"env": {"B": "2"}} + + def test_missing_path_is_noop(self): + doc = {"env": {"A": "1"}} + assert prune_key_paths(doc, [["env", "NOPE"], ["absent"]]) is False + assert doc == {"env": {"A": "1"}} + + def test_removes_top_level_key(self): + doc = {"model_provider": "ucode", "model": "gpt-5", "other": True} + assert prune_key_paths(doc, [["model_provider"], ["model"]]) is True + assert doc == {"other": True} + + def test_partial_path_through_non_dict_is_noop(self): + doc = {"model": "scalar"} + assert prune_key_paths(doc, [["model", "nested"]]) is False + assert doc == {"model": "scalar"} diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py new file mode 100644 index 00000000..dbb14f7b --- /dev/null +++ b/tests/test_managed_files.py @@ -0,0 +1,106 @@ +"""Tests for managed_files.py — the isaac-style sudo writer for OS managed settings files. + +Every test mocks the actual privileged step (`_sudo_replace`), so NO real `sudo` / `/etc` write +ever runs. The behavior that matters here is the drift check: an unchanged file must not shell out. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +import ucode.config_io as config_io +from ucode import managed_files + + +@pytest.fixture(autouse=True) +def _reset_dry_run(): + config_io.set_dry_run(False) + yield + config_io.set_dry_run(False) + + +@pytest.fixture(autouse=True) +def _supported(monkeypatch): + # Pin platform support on so tests are deterministic on any host. + monkeypatch.setattr(managed_files, "managed_files_supported", lambda: True) + + +def _capture_sudo(monkeypatch): + calls: list = [] + monkeypatch.setattr( + managed_files, "_sudo_replace", lambda path, text: calls.append((str(path), text)) + ) + return calls + + +class TestWriteManagedFile: + def test_unchanged_content_does_not_sudo(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + path.write_text("same", encoding="utf-8") + calls = _capture_sudo(monkeypatch) + assert managed_files.write_managed_file(path, "same", display="X") == "unchanged" + # The whole point: an unchanged file never prompts for a password. + assert calls == [] + + def test_changed_content_sudo_writes(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + path.write_text("old", encoding="utf-8") + calls = _capture_sudo(monkeypatch) + assert managed_files.write_managed_file(path, "new", display="X") == "written" + assert calls == [(str(path), "new")] + + def test_absent_file_sudo_writes(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + calls = _capture_sudo(monkeypatch) + assert managed_files.write_managed_file(path, "new", display="X") == "written" + assert calls == [(str(path), "new")] + + def test_dry_run_does_not_sudo(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + calls = _capture_sudo(monkeypatch) + config_io.set_dry_run(True) + assert managed_files.write_managed_file(path, "new", display="X") == "written" + assert calls == [] + + def test_unsupported_platform_skips(self, tmp_path, monkeypatch): + monkeypatch.setattr(managed_files, "managed_files_supported", lambda: False) + calls = _capture_sudo(monkeypatch) + path = tmp_path / "managed.json" + assert managed_files.write_managed_file(path, "new", display="X") == "skipped" + assert calls == [] + + def test_permission_error_is_skipped_not_raised(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + + def boom(path, text): + raise PermissionError("no root") + + monkeypatch.setattr(managed_files, "_sudo_replace", boom) + # Never raises — the launch proceeds; the private ucode config still works. + assert managed_files.write_managed_file(path, "new", display="X") == "skipped" + + def test_sudo_failure_is_skipped_not_raised(self, tmp_path, monkeypatch): + path = tmp_path / "managed.json" + + def boom(path, text): + raise subprocess.CalledProcessError(1, ["/usr/bin/sudo", "cp"], stderr="denied") + + monkeypatch.setattr(managed_files, "_sudo_replace", boom) + assert managed_files.write_managed_file(path, "new", display="X") == "skipped" + + +class TestClearImmutableStatDenied: + def test_stat_denied_path_returns_false_without_raising(self, monkeypatch): + # Regression: `_clear_immutable` ran an unguarded path.exists() inside the sudo write; under a + # root-locked /etc/codex that raised PermissionError and aborted the write ("without root"). + class _StatDenied: + def exists(self): + raise PermissionError(13, "Permission denied") + + # Ensure no sudo subprocess is attempted if the guard ever regresses. + monkeypatch.setattr( + managed_files.subprocess, "run", lambda *a, **k: pytest.fail("should not shell out") + ) + assert managed_files._clear_immutable(_StatDenied()) is False diff --git a/tests/test_managed_resolve.py b/tests/test_managed_resolve.py index 5dbf41d3..07c1eacd 100644 --- a/tests/test_managed_resolve.py +++ b/tests/test_managed_resolve.py @@ -18,10 +18,11 @@ managed_state_overrides, managed_supplies_models, managed_unservable_models, + managed_use_as_global_settings, recommended_agent, resolve_state, ) -from ucode.state import MANAGED_OVERLAY_KEY +from ucode.state import MANAGED_OVERLAY_KEY, _without_managed_overlay WORKSPACE = "https://ws.example.com" @@ -183,6 +184,41 @@ def test_layers_provider_without_dropping_other_tools(self): } +class TestGlobalSettings: + def test_only_claude_and_codex_support_global_settings(self): + # This set gates both the write path AND the `ucode setup` machine-wide prompt. Adding an + # agent whose token can't self-refresh here would re-introduce a config that breaks in ~1h. + from ucode.agents import GLOBAL_SETTINGS_AGENTS + + assert GLOBAL_SETTINGS_AGENTS == frozenset({"claude", "codex"}) + + def test_flag_true_for_opted_in_supported_agent(self): + assert managed_use_as_global_settings(MANAGED, "claude") is True + + def test_flag_false_when_not_opted_in(self): + # codex is enabled but never marked machine-wide. + assert managed_use_as_global_settings(MANAGED, "codex") is False + + def test_flag_ignored_for_unsupported_agent(self): + # A hand-written --from-file config can't turn it on for an agent whose token can't refresh. + managed = {"enabled_agents": {"gemini": {"use_as_global_settings": True}}} + assert managed_use_as_global_settings(managed, "gemini") is False + + def test_resolve_sets_transient_write_managed_config(self): + resolved = resolve_state(MANAGED, _state(), "claude") + assert resolved["write_managed_config"] is True + + def test_resolve_omits_flag_when_not_opted_in(self): + resolved = resolve_state(MANAGED, _state(), "codex") + assert "write_managed_config" not in resolved + + def test_write_managed_config_is_not_persisted(self): + # It lives only for the config-write; save_state (via _without_managed_overlay) drops it so + # a later non-managed launch never writes the managed settings file. + resolved = resolve_state(MANAGED, _state(), "claude") + assert "write_managed_config" not in _without_managed_overlay(resolved) + + class TestStateFileIsNotRewritten: """The managed config must win by precedence, not by overwriting the developer's state file. diff --git a/tests/test_managed_wizard.py b/tests/test_managed_wizard.py index 85d048e2..4872b790 100644 --- a/tests/test_managed_wizard.py +++ b/tests/test_managed_wizard.py @@ -1688,6 +1688,25 @@ def test_single_model_agent_needs_no_extra_line(self, capsys): assert "system.ai.gemini-3-flash" in out assert "models:" not in out + def test_scope_label_only_for_global_capable_agents(self, capsys): + # claude/codex can use global settings, so they carry the scope; gemini can't, so it doesn't. + manifest = { + "default_agent": "claude", + "enabled_agents": { + "claude": { + "model_config": {"default_model": "system.ai.claude-opus-4-8"}, + "use_as_global_settings": True, + }, + "gemini": {"model_config": {"default_model": "system.ai.gemini-3-flash"}}, + }, + } + wizard._render_summary(WORKSPACE, manifest) + out = capsys.readouterr().out + assert "global settings" in out + # The gemini line names its model but carries no global-settings/ucode-only scope. + gemini_line = next(line for line in out.splitlines() if "gemini-3-flash" in line) + assert "ucode-only" not in gemini_line and "global settings" not in gemini_line + class TestSetupFromFile: def _write(self, tmp_path, payload): diff --git a/tests/test_state.py b/tests/test_state.py index 4555af67..ff8c3ccd 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -289,3 +289,7 @@ def test_preserves_existing_managed_configs(self): result = mark_tool_managed(state, "codex", [["profile"]]) assert "gemini" in result["managed_configs"] assert "codex" in result["managed_configs"] + + def test_records_only_keys(self): + result = mark_tool_managed({}, "codex", [["model"]]) + assert result["managed_configs"]["codex"] == {"keys": [["model"]]}