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
10 changes: 10 additions & 0 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>`) 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",
Expand Down
111 changes: 74 additions & 37 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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

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

Expand Down
16 changes: 12 additions & 4 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
managed_provider_service,
managed_supplies_models,
managed_unservable_models,
managed_use_as_global_settings,
recommended_agent,
resolve_state,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 44 additions & 5 deletions src/ucode/config_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import json
from pathlib import Path
from typing import TypedDict
from typing import TypedDict, cast

import tomlkit
import tomlkit.exceptions
Expand Down Expand Up @@ -107,20 +107,59 @@ 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 {}
return data if isinstance(data, dict) else {}


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()
Expand Down
Loading
Loading