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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,16 @@ ug configure
ug configure --agents claude,codex
ug configure --workspace https://first.databricks.com
ug configure --profile DEFAULT --agents claude,codex
ug configure --user
```

`--global` (the default) writes the machine-wide Claude Code and Codex managed settings with sudo.
`--user` skips them, like `UG_SKIP_OS_SETTINGS`, and needs no sudo; use it when you launch through
`ug`. Configure fails while a managed file for the agent exists, since managed settings take
precedence; later launches use whatever files are present.
The choice is saved for launches after a successful configure; `ug configure` without `--user`
and `ug revert` return to `--global`.

Available coding agents are `codex`, `claude`, `gemini`, `opencode`,
`copilot`, and `pi`. `cursor` can be included in `--agents` for MCP-only setup;
Cursor models still run through your Cursor account.
Expand Down
11 changes: 11 additions & 0 deletions docs/os-managed-settings-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ for a password. If that repair is denied, the agent does not launch.
The local file is always written. The OS-managed file is additionally reconciled during interactive
configuration, except for Claude subscription relay.

## Per-user Scope

`ug configure --user` (saved as `config_scope` in `~/.ucode/state.json`, default `global`) skips the
OS-managed files like `UG_SKIP_OS_SETTINGS`: no administrator permission, and managed MCP servers
register in user scope. Because OS-managed settings take precedence over user settings,
`ug configure --user` fails before configuring any agent while a selected agent's OS-managed file
exists (including Windows `requirements.toml`), whoever wrote it; `ug revert`, removal, or
`--global` resolves it. Launches don't check: a managed file added later simply takes precedence,
without the `UG_SKIP_OS_SETTINGS` stale-override error. `ug configure` without `--user` and
`ug revert` reset the scope to `global`.

## Interactive Detection

Standard input determines whether UG may request administrator permission interactively. Standard
Expand Down
21 changes: 20 additions & 1 deletion src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
)
from ucode.env import env_bool
from ucode.managed_config import refresh_managed_config
from ucode.managed_files import managed_write_batch
from ucode.managed_files import config_scope, managed_write_batch
from ucode.os_compatibility import subprocess_cross_os
from ucode.state import get_model_location, get_provider_service, load_state, save_state
from ucode.telemetry import agent_version
Expand Down Expand Up @@ -414,6 +414,25 @@ def resolve_gemini_provider_model(
)


def refuse_user_scope_over_managed_files(tools: list[str]) -> None:
"""Run by ``ug configure`` before any agent is configured; launches use the files present."""
if config_scope() != "user":
return
for tool in tools:
if tool == AGENT_CLAUDE:
display, path = "Claude Code", claude.managed_settings_path()
elif tool == AGENT_CODEX:
display, path = "Codex", codex.codex_managed_config_path()
else:
continue
if path is not None and path.exists():
raise RuntimeError(
f"`ug configure --user` can't be used while {display} managed settings exist at "
f"{path}; they take precedence over user settings. Run `ug revert` if ug wrote "
"them, ask your administrator to remove them, or use `ug configure --global`."
)


def configure_tool(
tool: str,
state: dict,
Expand Down
22 changes: 14 additions & 8 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
SettingsPassthrough,
apply_settings_passthrough,
check_skipped_managed_overrides,
config_scope,
current_os,
managed_file_conflicts,
managed_file_is_verified,
Expand Down Expand Up @@ -278,6 +279,10 @@ def configured_paths(state: dict) -> list[str]:
return [str(CLAUDE_SETTINGS_PATH)]


def managed_settings_path() -> Path | None:
return _managed_settings_path()


def _managed_settings_path() -> Path | None:
"""OS-specific location of Claude Code's enterprise managed-settings.json.
Returns None on unsupported platforms."""
Expand Down Expand Up @@ -998,7 +1003,7 @@ def reconcile_managed_mcp(
user-scope fallback), so an admin's ``agent_native_settings`` MCP allowlist keeps admitting them.
"""
path = _managed_settings_path()
if path is None or not managed_writes_allowed():
if path is None or managed_settings_skipped() or not managed_writes_allowed():
return False
if path.is_symlink():
raise RuntimeError(
Expand Down Expand Up @@ -1874,13 +1879,14 @@ def _reconcile_managed_settings(
during `ucode claude`, so a bare `claude` could not reach the gateway anyway.
"""
if managed_settings_skipped():
check_skipped_managed_overrides(
_managed_settings_path(),
parser=_parse_managed_settings,
compose=compose,
owned_paths=owned_paths,
display="Claude Code",
)
if config_scope() != "user":
check_skipped_managed_overrides(
_managed_settings_path(),
parser=_parse_managed_settings,
compose=compose,
owned_paths=owned_paths,
display="Claude Code",
)
return
path = _managed_settings_path()
if path is None:
Expand Down
23 changes: 15 additions & 8 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
OS,
ManagedFileWriteUnavailable,
check_skipped_managed_overrides,
config_scope,
current_os,
managed_file_conflicts,
managed_file_is_verified,
Expand Down Expand Up @@ -764,13 +765,14 @@ def _reconcile_managed_config(state: dict, compose: Callable[[dict], dict]) -> N
if path is not None and path.name == CODEX_REQUIREMENTS_FILE:
compose = _as_requirements(compose)
if managed_settings_skipped():
check_skipped_managed_overrides(
path,
parser=_parse_managed_config,
compose=compose,
owned_paths=MANAGED_KEYS,
display="Codex",
)
if config_scope() != "user":
check_skipped_managed_overrides(
path,
parser=_parse_managed_config,
compose=compose,
owned_paths=MANAGED_KEYS,
display="Codex",
)
return
if path is None:
print_warning_err(
Expand Down Expand Up @@ -947,7 +949,12 @@ def reconcile_managed_mcp(state: dict, servers: dict[str, dict]) -> bool:
platform or a non-interactive run), so the caller routes those servers to the user-scope
registration instead."""
path = codex_managed_config_path()
if path is None or path.name == CODEX_REQUIREMENTS_FILE or not managed_writes_allowed():
if (
path is None
or path.name == CODEX_REQUIREMENTS_FILE
or managed_settings_skipped()
or not managed_writes_allowed()
):
return False
if path.is_symlink():
raise RuntimeError(
Expand Down
31 changes: 30 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
install_databricks_ai_tools_for_agents,
install_tool_binary,
normalize_tool,
refuse_user_scope_over_managed_files,
resolve_gemini_provider_model,
resolve_launch_model,
resolve_provider_models,
Expand Down Expand Up @@ -99,7 +100,7 @@
normalize_managed_config,
refresh_managed_config,
)
from ucode.managed_files import managed_write_session
from ucode.managed_files import managed_write_session, set_config_scope
from ucode.managed_resolve import (
managed_claude_family_models,
managed_default_model,
Expand Down Expand Up @@ -163,9 +164,11 @@
get_model_location,
get_provider_service,
is_self_managed,
load_config_scope,
load_full_state,
load_state,
remove_self_managed_agent,
save_config_scope,
save_state,
set_current_workspace,
set_model_location,
Expand Down Expand Up @@ -974,6 +977,7 @@ def _configure_workspace_command(
) -> int:
if tool is not None and selected_tools is not None:
raise RuntimeError("Use either --agent or --agents, not both.")
refuse_user_scope_over_managed_files([tool] if tool else selected_tools or [])

# The Databricks-vs-Model-Provider-Service picker is shown only on the fully
# interactive path (`ug configure` with no --agent/--agents). Naming agents
Expand Down Expand Up @@ -1011,6 +1015,7 @@ def _configure_workspace_command(
managed, _ = refresh_managed_config(state, force_refresh=True)
managed_tools = managed_enabled_tools(managed) if managed is not None else []
if managed is not None and managed_tools:
refuse_user_scope_over_managed_files(managed_tools)
configured_tools: list[str] = []
for tool_name in managed_tools:
resolved = resolve_state(managed, state, tool_name)
Expand Down Expand Up @@ -1089,6 +1094,7 @@ def _configure_workspace_command(
if not picked:
print_note("No coding agents selected — nothing to configure.")
return 0
refuse_user_scope_over_managed_files(picked)

for tool_name in picked:
install_tool_binary(
Expand Down Expand Up @@ -3643,6 +3649,8 @@ def default(
workspace: WorkspaceOption = None,
) -> None:
"""Configure and launch coding agents through Databricks AI Gateway."""
if ctx.invoked_subcommand != "revert":
set_config_scope(load_config_scope())
if ctx.invoked_subcommand is not None:
return
set_dry_run(dry_run)
Expand Down Expand Up @@ -4167,6 +4175,22 @@ def configure(
"compatibility. Accepted for backward compatibility.",
),
] = False,
global_scope: Annotated[
bool,
typer.Option(
"--global",
help="Write machine-wide Claude Code and Codex managed settings with sudo (default). "
"The choice is saved for later commands.",
),
] = False,
user_scope: Annotated[
bool,
typer.Option(
"--user",
help="Skip the machine-wide Claude Code and Codex managed settings, without sudo. "
"The choice is saved for later commands.",
),
] = False,
verbose: Annotated[
str,
typer.Option(
Expand All @@ -4185,6 +4209,10 @@ def configure(
set_dry_run(dry_run)
set_verbosity(verbose)
try:
if global_scope and user_scope:
raise RuntimeError("Use either --global or --user, not both.")
scope = "user" if user_scope else "global"
set_config_scope(scope)
custom_oauth = _custom_oauth_config(client_id, redirect_url, scopes)
if custom_oauth is not None and use_pat:
raise RuntimeError("--client-id cannot be combined with --use-pat.")
Expand Down Expand Up @@ -4334,6 +4362,7 @@ def configure(
and prompt_yes_no("Configure MCP servers now?")
):
configure_mcp_command()
save_config_scope(scope)
except typer.Exit:
# `typer.Exit` subclasses RuntimeError, so it has to be re-raised ahead of the handler
# below. Otherwise a clean exit (e.g. `_reject_configure_under_managed_config` under a
Expand Down
20 changes: 17 additions & 3 deletions src/ucode/managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
_SUDO = "/usr/bin/sudo"
# Opt out of all OS-managed settings work (read, conflict check, and the privileged sudo write).
SKIP_OS_SETTINGS_ENV = "UG_SKIP_OS_SETTINGS"
CONFIG_SCOPES = ("global", "user")
DEFAULT_CONFIG_SCOPE = "global"
_config_scope = DEFAULT_CONFIG_SCOPE
MANAGED_BACKUP_DIR = APP_DIR / "managed-backups"
MANAGED_BACKUP_MANIFEST_PATH = MANAGED_BACKUP_DIR / "manifest.json"
MANAGED_FINGERPRINT_VERSION = 1
Expand Down Expand Up @@ -92,8 +95,19 @@ def current_os() -> OS:
return OS.OTHER


def set_config_scope(scope: str) -> None:
global _config_scope
if scope not in CONFIG_SCOPES:
raise ValueError(f"Unknown config scope {scope!r}; expected one of {CONFIG_SCOPES}.")
_config_scope = scope


def config_scope() -> str:
return _config_scope


def managed_settings_skipped() -> bool:
"""Whether the developer opted out of OS-managed settings via ``UG_SKIP_OS_SETTINGS``.
"""Whether OS-managed settings are opted out via ``UG_SKIP_OS_SETTINGS`` or the ``user`` scope.

Set the variable to ``1``/``true``/``yes``/``on`` to make ucode neither read nor update the
root-owned managed files (Claude Code ``managed-settings.json``, Codex ``managed_config.toml``
Expand All @@ -102,9 +116,9 @@ def managed_settings_skipped() -> bool:
always launches through ``ug`` and never needs a bare ``claude``/``codex`` to reach the gateway.
Skips the privileged ``sudo`` step entirely, so an interactive run is never prompted for a
password and never hits the "OS-managed settings could not be updated" failure on hosts where
that write is blocked.
that write is blocked. ``ug configure --user`` has the same effect.
"""
return env_bool(SKIP_OS_SETTINGS_ENV)
return env_bool(SKIP_OS_SETTINGS_ENV) or _config_scope == "user"


def managed_files_supported() -> bool:
Expand Down
22 changes: 21 additions & 1 deletion src/ucode/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
build_auth_token_argv,
build_shared_base_urls,
)
from ucode.managed_files import CONFIG_SCOPES, DEFAULT_CONFIG_SCOPE

STATE_PATH = APP_DIR / "state.json"
STATE_VERSION = 3
CONFIG_SCOPE_KEY = "config_scope"
# Transient key holding the developer's own values for whatever a managed config layered over them.
# Present only in memory: the layered values render the agent settings files, while `save_state`
# restores what's under it so `state.json` keeps recording the developer's own configuration.
Expand All @@ -41,6 +43,23 @@ def load_full_state() -> dict:
return data


def load_config_scope() -> str:
scope = load_full_state().get(CONFIG_SCOPE_KEY)
return scope if scope in CONFIG_SCOPES else DEFAULT_CONFIG_SCOPE


def save_config_scope(scope: str) -> None:
if is_dry_run() or load_config_scope() == scope:
return
full = load_full_state()
full[CONFIG_SCOPE_KEY] = scope
try:
APP_DIR.mkdir(parents=True, exist_ok=True)
STATE_PATH.write_text(json.dumps(full, indent=2), encoding="utf-8")
except OSError as exc:
raise RuntimeError(f"Failed to write state file: {STATE_PATH}") from exc


def load_state() -> dict:
"""Load the current workspace's state as a flat dict."""
full = load_full_state()
Expand Down Expand Up @@ -240,8 +259,9 @@ def build_agent_state(state: dict) -> dict[str, dict]:


def clear_state() -> None:
"""Remove the current workspace entry from state."""
"""Remove the current workspace entry and the config scope from state."""
full = load_full_state()
full.pop(CONFIG_SCOPE_KEY, None)
workspace = full.get("current_workspace")
if workspace:
full.get("workspaces", {}).pop(workspace, None)
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _isolate_ucode_state(tmp_path, monkeypatch):
monkeypatch.setattr(
managed_files_mod, "MANAGED_BACKUP_MANIFEST_PATH", backup_dir / "manifest.json"
)
monkeypatch.setattr(managed_files_mod, "_config_scope", "global")
monkeypatch.setattr(codex_mod, "codex_managed_config_path", lambda: None)
monkeypatch.setattr(
codex_mod, "CODEX_MODEL_CATALOG_PATH", state_dir / "codex-model-catalog.json"
Expand Down
Loading
Loading