Skip to content
Open
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Fields live in `~/.claude/ucode-settings.json` and the OS-managed settings file
| `env.ENABLE_PROMPT_CACHING_1H` | Create/replace | Create/replace | Prompt-caching flag |
| `env.ENABLE_TOOL_SEARCH` | Create/replace | Create/replace | Tool-search flag |
| `env.CLAUDE_CODE_USE_GATEWAY` | Create/replace | Create/replace | Gateway-routing flag |
| `env.ANTHROPIC_AUTH_TOKEN`, `env.ANTHROPIC_API_KEY` | Create/replace | Create/replace | Written as empty strings so a value in the user's shell can't outrank `ug`'s credential; Claude Code applies settings `env` over the shell, so commands it runs see them empty. In `ucode-settings.json` and in `ug`'s own drop-in `managed-settings.d/50-ug-credentials.json` beside the OS-managed file, never in `managed-settings.json` itself, so a program that templates that file never drops them and `ug` never re-prompts for them |
| `env.ANTHROPIC_MODEL` | Ignore | Create/replace | Launch model, from the config's `default_model` or the budget recommendation when spend tiers are set; written at launch, not by `ug configure` |
| `env.ANTHROPIC_DEFAULT_*_MODEL` | Create/replace | Create/replace | Per-family default (opus, sonnet, haiku, fable); the config's family default wins under managed config. Without a managed config, left as the OS-managed file has them (model discovery skipped) when that file has a `modelPicker` with family defaults |
| Model picker | Ignore | Create/replace | `availableModels`, `enforceAvailableModels`, `modelPicker`; only for a managed static model list |
Expand Down
156 changes: 134 additions & 22 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from ucode.launcher import exec_or_spawn
from ucode.managed_config import refresh_managed_config
from ucode.managed_files import (
CLAUDE_CREDENTIALS_DROPIN,
OS,
ManagedFileSnapshots,
ManagedFileWriteUnavailable,
Expand All @@ -68,13 +69,15 @@
managed_file_status,
managed_files_supported,
managed_settings_skipped,
managed_write_session,
managed_writes_allowed,
mark_managed_file_verified,
plan_settings_passthrough,
read_managed_file,
reconcile_managed_file,
record_settings_passthrough,
record_ug_picker,
remove_empty_dropin_folder,
revert_managed_file,
warn_managed_write_skipped,
warn_skipped_settings_passthrough,
Expand Down Expand Up @@ -102,14 +105,19 @@
)
from ucode.state import MANAGED_OVERLAY_KEY, is_tool_managed, mark_tool_managed, save_state
from ucode.telemetry import agent_version, ug_version
from ucode.ui import print_note, print_success, print_warning
from ucode.ui import print_note, print_success, print_warning, print_warning_err

from .args import LaunchOptions, has_explicit_model_arg
from .request_tags import request_tags_header_value

GATEWAY_MODEL_DISCOVERY_ENV_VAR = "ENABLE_CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY"
# If set, Claude Code launches in headless mode instead of the interactive login flow.
CLAUDE_CODE_OAUTH_TOKEN_ENV_VAR = "CLAUDE_CODE_OAUTH_TOKEN"
# Blank inherited credentials so Claude uses ug's configured authentication.
SHADOWING_CREDENTIAL_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY")
Comment thread
luisscabral marked this conversation as resolved.
_SHADOWING_CREDENTIAL_PATHS = [["env", name] for name in SHADOWING_CREDENTIAL_ENV_VARS]
# Backup-manifest key for ug's managed drop-in, kept apart from managed-settings.json's "claude".
CREDENTIALS_DROPIN_MANIFEST_KEY = "claude-credentials"
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
CLAUDE_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "ucode-settings.json"
CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json"
Expand Down Expand Up @@ -295,6 +303,12 @@ def _managed_settings_path() -> Path | None:
return None


def _credentials_dropin_path() -> Path | None:
"""ug's managed drop-in, beside managed-settings.json, that blanks inherited credentials."""
path = _managed_settings_path()
return None if path is None else path.parent / CLAUDE_CREDENTIALS_DROPIN


def _parse_managed_settings(text: str) -> dict:
try:
settings = json.loads(text)
Expand Down Expand Up @@ -394,12 +408,22 @@ def managed_settings_status(state: dict) -> tuple[Path | None, str, str]:


def revert_managed_settings() -> str:
return revert_managed_file(
result = revert_managed_file(
"claude",
display="Claude Code",
parser=_parse_managed_settings,
dumper=_dump_managed_settings,
)
dropin_result = revert_managed_file(
CREDENTIALS_DROPIN_MANIFEST_KEY,
display="Claude Code",
parser=_parse_managed_settings,
dumper=_dump_managed_settings,
)
dropin = _credentials_dropin_path()
if dropin_result == "removed" and dropin is not None:
remove_empty_dropin_folder(dropin)
return result


def _managed_relayed_conflicts(path: Path) -> list[str]:
Expand Down Expand Up @@ -534,6 +558,8 @@ def render_overlay(
"ENABLE_PROMPT_CACHING_1H": "1",
"ENABLE_TOOL_SEARCH": "true",
"CLAUDE_CODE_USE_GATEWAY": "1",
# Blank, so a credential in the user's shell can't outrank ug's on this or a bare `claude`.
**dict.fromkeys(SHADOWING_CREDENTIAL_ENV_VARS, ""),
Comment thread
luisscabral marked this conversation as resolved.
}
# Intentionally NOT setting ANTHROPIC_MODEL by default. Setting it produces a
# duplicate catalog row in Claude Code's /model picker (e.g. "Opus 4.8 (1M
Expand Down Expand Up @@ -1602,9 +1628,9 @@ def _compose(
)
previous_leaves = (managed_snapshots.settings_passthrough if managed_snapshots else None) or []
_warn_if_settings_disable_smart_routing(passthrough, previous_leaves)
_reconcile_managed_settings(
state,
lambda base: _allow_ug_mcp_servers(

def compose_managed(base: dict) -> dict:
return _allow_ug_mcp_servers(
apply_settings_passthrough(
_compose(
base,
Expand All @@ -1627,8 +1653,17 @@ def _compose(
base.get(MANAGED_MCP_SETTINGS_KEY),
web_search=bool(web_search_model and not external_search),
),
),
[*managed_file_keys, *passthrough.paths],
)

_reconcile_managed_settings(
state,
_keep_managed_file_credentials(compose_managed),
# ug's drop-in owns the blank credentials, so managed-settings.json doesn't.
[
path
for path in [*managed_file_keys, *passthrough.paths]
if path not in _SHADOWING_CREDENTIAL_PATHS
],
relayed,
[key for key in CLAUDE_MANAGED_PICKER_KEYS if key in overlay],
withdrawn_denies=withdrawn_list_items(
Expand Down Expand Up @@ -1928,27 +1963,82 @@ def _reconcile_managed_settings(
warn_managed_write_skipped(path, managed_before, desired_settings)
mark_managed_file_verified(state, "claude", path, scope="local-compatible")
return
# One privileged session for both files, so the drop-in never costs a second sudo prompt.
with managed_write_session():
try:
reconcile_managed_file(
path,
_dump_managed_settings(desired_settings),
tool="claude",
display="Claude Code",
owned_paths=owned_paths,
parser=_parse_managed_settings,
)
except ManagedFileWriteUnavailable:
if conflicts:
raise
print_warning(
f"Claude Code OS-managed settings could not be updated at {path}; continuing "
f"with local settings at {CLAUDE_SETTINGS_PATH}."
)
mark_managed_file_verified(state, "claude", path, scope="local-compatible")
return
_reconcile_credentials_dropin()
mark_managed_file_verified(state, "claude", path)
record_ug_picker("claude", {key: desired_settings[key] for key in picker_keys})
record_settings_passthrough("claude", passthrough_leaves or [])


def _reconcile_credentials_dropin() -> None:
"""Blank inherited Anthropic credentials for every Claude Code session on the machine.

The blanks live in ug's own drop-in rather than managed-settings.json: Claude Code merges
`managed-settings.d/*.json` after that file, `env` key by key, so another program that templates
managed-settings.json neither removes them nor makes ug rewrite them on every launch. A failure
only leaves a bare `claude` uncovered; `ug claude` gets the same blanks from ucode-settings.json.
Runs that can't prompt skip it quietly rather than warn on every non-interactive launch.
"""
path = _credentials_dropin_path()
if path is None or not managed_writes_allowed():
return
try:
reconcile_managed_file(
path,
_dump_managed_settings(desired_settings),
tool="claude",
_dump_managed_settings({"env": dict.fromkeys(SHADOWING_CREDENTIAL_ENV_VARS, "")}),
tool=CREDENTIALS_DROPIN_MANIFEST_KEY,
display="Claude Code",
owned_paths=owned_paths,
owned_paths=_SHADOWING_CREDENTIAL_PATHS,
parser=_parse_managed_settings,
)
except ManagedFileWriteUnavailable:
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)
record_ug_picker("claude", {key: desired_settings[key] for key in picker_keys})
record_settings_passthrough("claude", passthrough_leaves or [])
except RuntimeError as exc:
print_warning(f"Could not update Claude Code's credential drop-in at {path}: {exc}")


def _keep_managed_file_credentials(compose: Callable[[dict], dict]) -> Callable[[dict], dict]:
"""Compose managed-settings.json without ug's blank credentials, which its drop-in carries.

Values the file already holds for those variables are kept as they are.
"""

def composed(base: dict) -> dict:
base_env = base.get("env")
# Read before composing: the compose step merges ug's overlay into ``base`` in place.
kept = {
name: base_env[name]
for name in SHADOWING_CREDENTIAL_ENV_VARS
if isinstance(base_env, dict) and name in base_env
}
settings = compose(base)
env = settings.get("env")
if isinstance(env, dict):
for name in SHADOWING_CREDENTIAL_ENV_VARS:
if name in kept:
env[name] = kept[name]
else:
env.pop(name, None)
return settings

return composed


def _preserve_permission_denies(
Expand Down Expand Up @@ -2301,12 +2391,34 @@ def token_provider(force_refresh: bool) -> str:
raise SystemExit(returncode)


def shadowing_credentials_message() -> str:
"""Why a shell ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY doesn't reach ug's Claude sessions."""
return (
f"{' and '.join(SHADOWING_CREDENTIAL_ENV_VARS)} from your environment are ignored by "
"Claude Code sessions ug configures, which use ug's credentials. Commands Claude runs see "
"them empty. To authenticate with a PAT in scripts, set DATABRICKS_BEARER instead."
)


def _warn_shadowing_credentials() -> None:
"""Tell the user that ug's settings override inherited Anthropic credentials.

ug owns Claude Code's authentication on every launch path (the gateway `apiKeyHelper`, or the
subscription login behind the relayed proxy). Its settings blank these variables; see
``SHADOWING_CREDENTIAL_ENV_VARS``. The user's environment itself is left unchanged.
"""
if any(os.environ.get(name, "").strip() for name in SHADOWING_CREDENTIAL_ENV_VARS):
# stderr: headless launches keep stdout machine-readable.
print_warning_err(shadowing_credentials_message())


def launch(
state: dict,
tool_args: list[str],
*,
options: LaunchOptions,
) -> None:
_warn_shadowing_credentials()
tool_args = _external_web_search_args(state, tool_args)
binary = SPEC["binary"]
workspace = state.get("workspace")
Expand Down
26 changes: 8 additions & 18 deletions src/ucode/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
tool_version_error,
update_tool_binary,
)
from ucode.agents.claude import SHADOWING_CREDENTIAL_ENV_VARS, shadowing_credentials_message
from ucode.databricks import (
MIN_DATABRICKS_CLI_VERSION,
check_databricks_auth,
Expand Down Expand Up @@ -48,11 +49,6 @@
status_badge,
)

# Env vars that shadow the credential ucode configures for Claude Code. Claude
# warns when its own token and one of these are both set, so we surface them.
_CLAUDE_TOKEN_ENV_VARS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY")


# status -> (glyph, status_badge kind). "info" is a healthy line that still
# carries an optional suggestion (e.g. installing a missing dependency).
_BADGES = {
Expand Down Expand Up @@ -251,22 +247,16 @@ def _login() -> bool:


def _check_anthropic_env_collision() -> Check | None:
"""Warn when a Claude token env var is set that collides with ucode's own.
"""Warn when a Claude token env var is set, since ug's Claude settings blank it.

ucode authenticates Claude Code through the gateway; a stray
`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` in the environment shadows that
and makes Claude complain. We can't unset a parent shell's env, so this is
advisory (no fix). Returns None when nothing collides.
Claude Code ranks `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY` above ug's
`apiKeyHelper`, so ug's settings set both to empty strings; a shell value is
therefore ignored, which matters to scripts that relied on it. Advisory only
(no fix). Returns None when neither is set.
"""
set_vars = [name for name in _CLAUDE_TOKEN_ENV_VARS if os.environ.get(name, "").strip()]
if not set_vars:
if not any(os.environ.get(name, "").strip() for name in SHADOWING_CREDENTIAL_ENV_VARS):
return None
joined = ", ".join(set_vars)
return Check(
"Claude auth env",
"warn",
f"{joined} is set and can collide with ucode's Claude auth; unset it in your shell",
)
return Check("Claude auth env", "warn", shadowing_credentials_message())


def _check_ug() -> Check:
Expand Down
48 changes: 47 additions & 1 deletion src/ucode/managed_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,24 @@ class OS(Enum):

MANAGED_FILES_SUPPORTED_OSES = (OS.LINUX, OS.MACOS, OS.WINDOWS)

# ug's own Claude Code drop-in. Claude Code merges `managed-settings.d/*.json` after
# managed-settings.json, so another program that templates that file leaves this one alone.
CLAUDE_CREDENTIALS_DROPIN = Path("managed-settings.d") / "50-ug-credentials.json"

_SUDO_REPLACE_TARGETS = {
OS.LINUX: frozenset(
{
Path("/etc/claude-code/managed-settings.json"),
Path("/etc/claude-code/managed-settings.d/50-ug-credentials.json"),
Path("/etc/codex/managed_config.toml"),
}
),
OS.MACOS: frozenset(
{
Path("/Library/Application Support/ClaudeCode/managed-settings.json"),
Path(
"/Library/Application Support/ClaudeCode/managed-settings.d/50-ug-credentials.json"
),
Path("/etc/codex/managed_config.toml"),
}
),
Expand Down Expand Up @@ -138,6 +146,11 @@ def windows_claude_managed_settings_path() -> Path:
return system_cross_os.windows_program_files_dir() / "ClaudeCode" / "managed-settings.json"


def windows_claude_credentials_dropin_path() -> Path:
"""ug's Claude Code drop-in on Windows, beside the managed settings file."""
return windows_claude_managed_settings_path().parent / CLAUDE_CREDENTIALS_DROPIN


CODEX_REQUIREMENTS_FILE = "requirements.toml"


Expand Down Expand Up @@ -1347,8 +1360,41 @@ def _privileged_remove(path: Path) -> None:
path.unlink(missing_ok=True)


def remove_empty_dropin_folder(dropin: Path) -> None:
"""After ``ug revert`` removes ug's drop-in, remove its folder too if nothing else is in it.

Leaves the managed directory as ug found it. A folder holding other drop-ins is kept, and a
failed removal only leaves an empty folder behind.
"""
folder = dropin.parent
if folder.name != CLAUDE_CREDENTIALS_DROPIN.parent.name or folder.is_symlink():
return
try:
if not folder.is_dir() or any(folder.iterdir()):
return
except OSError:
return
if current_os() is OS.WINDOWS:
with suppress(OSError):
folder.rmdir()
return
_sudo_rmdir(folder)


def _sudo_rmdir(folder: Path) -> None:
# `rmdir` refuses a non-empty folder, so a drop-in added meanwhile is never lost.
subprocess_cross_os.run(
_sudo_command("rmdir", str(folder)), capture_output=True, text=True, check=False
)


def _validate_windows_target(path: Path) -> None:
if path not in (windows_claude_managed_settings_path(), windows_codex_managed_settings_path()):
allowed = (
windows_claude_managed_settings_path(),
windows_claude_credentials_dropin_path(),
windows_codex_managed_settings_path(),
)
if path not in allowed:
raise RuntimeError(f"Refusing unexpected managed-settings target: {path}")
if path.is_symlink():
raise RuntimeError(f"Refusing to replace symlinked managed settings: {path}")
Expand Down
Loading
Loading