Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
26 changes: 19 additions & 7 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
8 changes: 6 additions & 2 deletions src/ucode/managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."""

Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
46 changes: 46 additions & 0 deletions tests/test_agent_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -779,3 +780,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"]})
2 changes: 1 addition & 1 deletion tests/test_managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading