From 441085848f103b18beb0fa38ff0df60bdd9330ea Mon Sep 17 00:00:00 2001 From: Andy Xu Date: Fri, 11 Sep 2026 04:40:12 +0000 Subject: [PATCH] Fall back to local settings when sudo is unavailable --- src/ucode/agents/claude.py | 26 +++++++++++++----- src/ucode/agents/codex.py | 26 +++++++++++++----- src/ucode/managed_files.py | 8 ++++-- tests/test_agent_claude.py | 54 +++++++++++++++++++++++++++++++++++++ tests/test_agent_codex.py | 46 +++++++++++++++++++++++++++++++ tests/test_managed_files.py | 2 +- 6 files changed, 145 insertions(+), 17 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index a77013d1..5b8e1666 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -34,6 +34,7 @@ from ucode.launcher import exec_or_spawn from ucode.managed_files import ( OS, + ManagedFileWriteUnavailable, current_os, managed_file_conflicts, managed_file_is_verified, @@ -846,13 +847,24 @@ def _reconcile_managed_settings( ) mark_managed_file_verified(state, "claude", path, scope="local-compatible") return - reconcile_managed_file( - path, - _dump_managed_settings(desired_settings), - tool="claude", - display="Claude Code", - owned_paths=owned_paths, - ) + try: + reconcile_managed_file( + path, + _dump_managed_settings(desired_settings), + tool="claude", + display="Claude Code", + owned_paths=owned_paths, + ) + except ManagedFileWriteUnavailable: + conflicts = managed_file_conflicts(managed_before, desired_settings, owned_paths) + if conflicts: + raise + print_warning( + f"Claude Code OS-managed settings could not be updated at {path}; continuing with " + f"local settings at {CLAUDE_SETTINGS_PATH}." + ) + mark_managed_file_verified(state, "claude", path, scope="local-compatible") + return mark_managed_file_verified(state, "claude", path) diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index f0f10429..5379be5f 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -29,6 +29,7 @@ from ucode.launcher import exec_or_spawn from ucode.managed_files import ( OS, + ManagedFileWriteUnavailable, current_os, managed_file_conflicts, managed_file_is_verified, @@ -463,13 +464,24 @@ def _reconcile_managed_config(state: dict, compose: Callable[[dict], dict]) -> N ) mark_managed_file_verified(state, "codex", path, scope="local-compatible") return - reconcile_managed_file( - path, - tomlkit.dumps(desired_doc), - tool="codex", - display="Codex", - owned_paths=MANAGED_KEYS, - ) + try: + reconcile_managed_file( + path, + tomlkit.dumps(desired_doc), + tool="codex", + display="Codex", + owned_paths=MANAGED_KEYS, + ) + except ManagedFileWriteUnavailable: + conflicts = managed_file_conflicts(managed_before, desired_doc, MANAGED_KEYS) + if conflicts: + raise + print_warning_err( + f"Codex OS-managed settings could not be updated at {path}; continuing with local " + f"settings at {CODEX_CONFIG_PATH}." + ) + mark_managed_file_verified(state, "codex", path, scope="local-compatible") + return mark_managed_file_verified(state, "codex", path) diff --git a/src/ucode/managed_files.py b/src/ucode/managed_files.py index 500cb0c7..0bffc81f 100644 --- a/src/ucode/managed_files.py +++ b/src/ucode/managed_files.py @@ -36,6 +36,10 @@ ManagedDumper = Callable[[dict], str] +class ManagedFileWriteUnavailable(RuntimeError): + """The privileged managed-file write failed, so callers may use local settings if safe.""" + + class OS(Enum): """The host OS families this module distinguishes, off `sys.platform`.""" @@ -269,13 +273,13 @@ def reconcile_managed_file( try: _sudo_replace(path, desired_text) except PermissionError as exc: - raise RuntimeError( + raise ManagedFileWriteUnavailable( f"{display} cannot start because ucode could not update {path}: {exc}. " "Run the ucode command from an interactive terminal and approve the administrator " "prompt, or contact your administrator." ) from exc except subprocess.CalledProcessError as exc: - raise RuntimeError(_sudo_failure_message(path, display, exc)) from exc + raise ManagedFileWriteUnavailable(_sudo_failure_message(path, display, exc)) from exc written_text = read_managed_file(path) if written_text == desired_text: diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 92f99fe2..1ce87a8b 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -10,6 +10,7 @@ import pytest +from ucode import managed_files from ucode.agents import LaunchOptions, claude from ucode.smart_routing import claude_routing, v2 from ucode.state import MANAGED_OVERLAY_KEY @@ -961,6 +962,59 @@ def test_noninteractive_fails_when_managed_file_conflicts(self, monkeypatch): assert managed_writes == [] + def test_sudo_failure_uses_local_settings_when_managed_file_is_compatible(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + warnings: list[str] = [] + verified: list[dict] = [] + self._patch(monkeypatch, private_writes, managed_writes) + + def deny_managed_write(*args, **kwargs): + raise managed_files.ManagedFileWriteUnavailable("sudo denied") + + monkeypatch.setattr( + claude, + "reconcile_managed_file", + deny_managed_write, + ) + monkeypatch.setattr(claude, "print_warning", warnings.append) + monkeypatch.setattr( + claude, + "mark_managed_file_verified", + lambda *args, **kwargs: verified.append(kwargs), + ) + + claude.write_tool_config( + {"workspace": WS, "codex_models": []}, "databricks-claude-sonnet-4" + ) + + assert private_writes + assert managed_writes == [] + assert "continuing with local settings" in warnings[0] + assert verified == [{"scope": "local-compatible"}] + + def test_sudo_failure_remains_fatal_when_managed_file_conflicts(self, monkeypatch): + private_writes: list = [] + managed_writes: list = [] + existing = { + str(FAKE_MANAGED_PATH): {"env": {"ANTHROPIC_BASE_URL": "https://other.example.com"}} + } + self._patch(monkeypatch, private_writes, managed_writes, existing) + + def deny_managed_write(*args, **kwargs): + raise managed_files.ManagedFileWriteUnavailable("sudo denied") + + monkeypatch.setattr( + claude, + "reconcile_managed_file", + deny_managed_write, + ) + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="sudo denied"): + claude.write_tool_config( + {"workspace": WS, "codex_models": []}, "databricks-claude-sonnet-4" + ) + class TestRegisterWebSearchMcp: def test_skips_registration_when_entry_is_current(self, monkeypatch): diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 4f4af970..e2a92880 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -7,6 +7,7 @@ import pytest +from ucode import managed_files from ucode.agents import LaunchOptions, codex from ucode.config_io import read_toml_safe from ucode.smart_routing import codex_routing @@ -732,3 +733,48 @@ def test_invalid_managed_toml_is_not_modified(self, tmp_path, monkeypatch): codex.write_tool_config({"workspace": WS, "codex_models": ["gpt-5"]}) assert managed_path.read_text(encoding="utf-8") == "[invalid" + + def test_sudo_failure_uses_local_config_when_managed_config_is_compatible( + self, tmp_path, monkeypatch + ): + config_path, _ = self._patch(tmp_path, monkeypatch) + warnings: list[str] = [] + verified: list[dict] = [] + + def deny_managed_write(*args, **kwargs): + raise managed_files.ManagedFileWriteUnavailable("sudo denied") + + monkeypatch.setattr( + codex, + "reconcile_managed_file", + deny_managed_write, + ) + monkeypatch.setattr(codex, "print_warning_err", warnings.append) + monkeypatch.setattr( + codex, + "mark_managed_file_verified", + lambda *args, **kwargs: verified.append(kwargs), + ) + + codex.write_tool_config({"workspace": WS, "codex_models": ["gpt-5"]}) + + assert config_path.exists() + assert "continuing with local settings" in warnings[0] + assert verified == [{"scope": "local-compatible"}] + + def test_sudo_failure_remains_fatal_when_managed_config_conflicts(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_provider = "enterprise"\n', encoding="utf-8") + + def deny_managed_write(*args, **kwargs): + raise managed_files.ManagedFileWriteUnavailable("sudo denied") + + monkeypatch.setattr( + codex, + "reconcile_managed_file", + deny_managed_write, + ) + + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="sudo denied"): + codex.write_tool_config({"workspace": WS, "codex_models": ["gpt-5"]}) diff --git a/tests/test_managed_files.py b/tests/test_managed_files.py index 80860b2a..aa047dff 100644 --- a/tests/test_managed_files.py +++ b/tests/test_managed_files.py @@ -133,7 +133,7 @@ def deny_write(path, text): monkeypatch.setattr(managed_files, "_sudo_replace", deny_write) - with pytest.raises(RuntimeError, match="could not update"): + with pytest.raises(managed_files.ManagedFileWriteUnavailable, match="could not update"): managed_files.reconcile_managed_file( path, '{"ucode": true}\n',