Skip to content
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,12 @@ ucode publish # publish it to the workspace
```

`ucode setup` walks through the agents to enable and which one bare `ucode` launches, then per agent:
Databricks-hosted models or an external Model Provider Service, the models to expose, and (for Codex)
whether the config writes the agent's own OS-level settings file or a ucode-only one. Interactive
Claude Code configuration installs its gateway configuration in the OS-managed settings scope so
enterprise settings cannot silently override ucode. Non-interactive and CI runs use the local file
without invoking `sudo`, and stop with an actionable error if an existing managed value conflicts.
Claude subscription relay is local-only because its loopback proxy exists only for that session.
Databricks-hosted models or an external Model Provider Service and the models to expose. Interactive
Claude Code and Codex configuration installs gateway-critical values in the OS-managed settings
scope so enterprise settings cannot silently override ucode. Non-interactive and CI runs use local
files without invoking `sudo`, and stop with an actionable error if an existing managed value
conflicts. Claude subscription relay is local-only because its loopback proxy exists only for that
session.
Claude Code is asked one model per family (opus/sonnet/haiku/fable), since it selects models by family
alias; any family can be skipped.

Expand Down Expand Up @@ -386,6 +386,7 @@ control the installation.
| `~/.codex/ucode.config.toml` (or legacy `~/.codex/config.toml`) | Codex |
| `~/.claude/ucode-settings.json` | Claude Code settings generated by ucode |
| `/etc/claude-code/managed-settings.json` (Linux) or `/Library/Application Support/ClaudeCode/managed-settings.json` (macOS) | Claude Code OS-managed settings |
| `/etc/codex/managed_config.toml` | Codex OS-managed settings |
| `~/.gemini/.env` | Gemini CLI |
| `~/.config/opencode/opencode.json` | OpenCode |
| `~/.copilot/.env` | GitHub Copilot CLI |
Expand Down
30 changes: 9 additions & 21 deletions docs/os-managed-settings-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
## Summary

Claude Code and Codex give OS-managed settings higher precedence than user settings. Previously,
ucode wrote only its local configuration unless an administrator enabled
`use_as_global_settings`. An existing machine-managed file could therefore silently override the
gateway endpoint, authentication helper, provider headers, or model selected by ucode.
ucode could write only its local configuration while an existing machine-managed file silently
overrode the gateway endpoint, authentication helper, provider headers, or model.

The two stacked PRs make precedence handling deterministic:

1. The Claude PR adds the shared managed-file lifecycle and applies it to Claude Code.
2. The Codex PR reuses that lifecycle for TOML, applies it to Codex, and removes
`use_as_global_settings`.
2. The Codex PR reuses that lifecycle for TOML, applies it to Codex, and removes the old optional
managed-settings path.

After both PRs merge, interactive configuration reconciles the agent's OS-managed file by default.
Non-interactive and CI execution never elevates privileges and instead uses local settings when the
Expand Down Expand Up @@ -59,7 +58,7 @@ request administrator permission. A first-time non-interactive launch remains lo
For each agent, ucode:

1. Strictly parses the existing managed JSON or TOML document.
2. Produces the desired document by applying the same overlay used for the local ucode file.
2. Produces the desired document by applying the same gateway overlay used for the local ucode file.
3. Preserves settings outside the paths owned by ucode.
4. Preserves enterprise Claude permission-deny entries while adding ucode-required entries.
5. Records the original baseline before the first change.
Expand Down Expand Up @@ -204,19 +203,6 @@ Write, verification, parse, symlink, and managed-conflict failures block the age
information always identifies the file and recommends either an interactive configure/revert or
administrator help.

## Removal of `use_as_global_settings`

The final behavior has no managed-config scope choice:

- Claude and Codex reconcile OS-managed settings automatically during interactive configuration.
- Other agents continue using their existing local configuration because they do not have the same
supported self-refreshing managed-file path.
- The Codex PR removes `use_as_global_settings` from schemas, resolution, setup prompts, summaries,
documentation, and tests.

The Claude PR temporarily leaves Codex's legacy interpretation in place so that the first PR is
independently safe. The stacked Codex PR removes the remaining field and transitional code.

## PR Boundaries

### PR 1: Claude Code
Expand All @@ -227,12 +213,14 @@ independently safe. The stacked Codex PR removes the remaining field and transit
- Add non-interactive local fallback with conflict detection.
- Add Claude relay-specific safety checks.
- Add Claude managed status and revert output.
- Remove Claude from the legacy `use_as_global_settings` setup choice.
- Remove Claude's old managed-settings scope choice.

### PR 2: Codex

- Stack on the Claude PR and reuse the shared lifecycle with strict TOML parsing and serialization.
- Make interactive Codex configuration reconcile OS-managed TOML by default.
- Add non-interactive local fallback with conflict detection.
- Add Codex fingerprinted cached launches, status, and revert behavior.
- Remove all remaining `use_as_global_settings` code and documentation.
- Keep opt-in smart-routing hooks in the local Codex config rather than adding them by default to
machine-managed policy.
- Remove the remaining managed-settings scope schema, resolution, setup prompt, summary, and tests.
6 changes: 1 addition & 5 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,7 @@

DEFAULT_TOOL = "codex"
BUNDLE_VERSION = 1
_MANAGED_SETTINGS_TOOLS = {"claude"}

# Codex still honors the legacy managed-config opt-in until its follow-up migration. Claude always
# reconciles its OS-managed settings and therefore no longer appears in the setup prompt.
GLOBAL_SETTINGS_AGENTS = frozenset({"codex"})
_MANAGED_SETTINGS_TOOLS = {"claude", "codex"}

# ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported.
AITOOLS_AGENT_TOKENS = {
Expand Down
136 changes: 94 additions & 42 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

from __future__ import annotations

import copy
import os
import re
import subprocess
import sys
import time
from collections.abc import Callable
from pathlib import Path

import tomlkit
from tomlkit.exceptions import ParseError

from ucode.config_io import (
APP_DIR,
Expand All @@ -25,7 +28,18 @@
get_databricks_token,
)
from ucode.launcher import exec_or_spawn
from ucode.managed_files import OS, current_os, write_managed_file
from ucode.managed_files import (
OS,
current_os,
managed_file_conflicts,
managed_file_is_verified,
managed_file_status,
managed_writes_allowed,
mark_managed_file_verified,
read_managed_file,
reconcile_managed_file,
revert_managed_file,
)
from ucode.smart_routing.codex_hooks import (
remove_smart_routing_hooks,
routing_models,
Expand Down Expand Up @@ -335,27 +349,24 @@ def write_tool_config(state: dict, model: str | None = None, provider: str | Non
use_pat=bool(state.get("use_pat")),
provider=provider,
)

def compose(base: dict) -> dict:
deep_merge_dict(base, copy.deepcopy(overlay))
# deep_merge can't drop keys, so clear model preferences from an earlier run.
if chosen_model is None:
for key in ("model", "model_reasoning_effort"):
base.pop(key, None)
return base

doc = read_toml_safe(CODEX_CONFIG_PATH)
deep_merge_dict(doc, overlay)
# deep_merge can't drop keys, so clear model preferences from an earlier run.
if chosen_model is None:
for key in ("model", "model_reasoning_effort"):
doc.pop(key, None)
compose(doc)
sync_smart_routing_hooks(
doc,
state,
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, None, databricks_profile, bool(state.get("use_pat")), provider
)
_reconcile_managed_config(state, compose)
state = mark_tool_managed(state, "codex", MANAGED_KEYS)
save_state(state)
return state
Expand All @@ -370,44 +381,85 @@ def _is_gpt_family(model: str) -> bool:


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.
"""
"""Return Codex's managed config path on platforms supported by ucode's sudo writer."""
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.
def _parse_managed_config(text: str) -> dict:
try:
return tomlkit.parse(text)
except ParseError as exc:
raise RuntimeError(f"invalid TOML: {exc}") from exc

Written via the sudo path in `managed_files` (drift-suppressed).
"""

def managed_config_is_current(state: dict) -> bool:
path = _managed_config_path()
if path is None:
return True
required_scope = "managed" if managed_writes_allowed() else None
return managed_file_is_verified(state, "codex", path, required_scope=required_scope)


def managed_config_status(state: dict) -> tuple[Path | None, str, str]:
path = _managed_config_path()
status, backup = managed_file_status(state, "codex", path, parser=_parse_managed_config)
return path, status, backup


def revert_managed_config() -> str:
return revert_managed_file(
"codex",
display="Codex",
parser=_parse_managed_config,
dumper=tomlkit.dumps,
)


def _reconcile_managed_config(state: dict, compose: Callable[[dict], dict]) -> None:
"""Reconcile Codex's highest-precedence config while preserving unrelated policy."""
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."
"config."
)
return
overlay = render_overlay(
workspace, model, databricks_profile, use_pat=use_pat, provider=provider
if path.is_symlink():
raise RuntimeError(
f"Refusing to use Codex managed settings through symlink {path}. Replace it with a "
"regular file or contact your administrator."
)
current_text = read_managed_file(path)
try:
existing = _parse_managed_config(current_text) if current_text is not None else {}
except RuntimeError as exc:
raise RuntimeError(
f"Cannot safely update Codex managed settings at {path}: {exc}. ucode did not modify "
"the file. Repair it or contact your administrator."
) from exc
managed_before = copy.deepcopy(existing)
desired_doc = compose(existing)
if not managed_writes_allowed():
conflicts = managed_file_conflicts(managed_before, desired_doc, MANAGED_KEYS)
if conflicts:
raise RuntimeError(
"Codex configuration cannot be applied non-interactively because OS-managed "
f"settings at {path} override ucode values: {', '.join(conflicts)}. Run `ucode "
"configure --agent codex` from an interactive terminal or contact your "
"administrator."
)
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,
)
doc = read_toml_safe(path)
deep_merge_dict(doc, overlay)
# deep_merge can't drop keys, so clear a model pinned by an earlier run.
doc.pop("model", None)
write_managed_file(path, tomlkit.dumps(doc), display="Codex")
mark_managed_file_verified(state, "codex", path)


def default_model(state: dict) -> str | None:
Expand Down Expand Up @@ -501,8 +553,8 @@ def _app_server_start_model() -> str:
# since execvp replaces this process.
print_warning_err(
"ucode's `--profile` isn't accepted here (error above). Retrying "
f"without it: this run uses {LEGACY_CODEX_CONFIG_PATH}, NOT the "
"Databricks gateway."
f"without it: Codex will resolve {LEGACY_CODEX_CONFIG_PATH} and any OS-managed "
"settings instead of the ucode profile."
)
exec_or_spawn([binary, *tool_args])
return # unreachable in production (exec replaces the process)
Expand Down
9 changes: 8 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,11 @@ def status() -> int:
print_kv("OS-managed settings", managed_status)
print_kv("Managed settings file", str(managed_path) if managed_path else "unsupported")
print_kv("Managed settings backup", backup_status)
elif tool == "codex":
managed_path, managed_status, backup_status = codex_agent.managed_config_status(state)
print_kv("OS-managed settings", managed_status)
print_kv("Managed settings file", str(managed_path) if managed_path else "unsupported")
print_kv("Managed settings backup", backup_status)
console.print()

print_heading("Skills")
Expand Down Expand Up @@ -1105,6 +1110,7 @@ def revert() -> int:
managed_configs = state.get("managed_configs") or {}
mcp_results = revert_mcp_configs(state)
claude_managed_result = claude_agent.revert_managed_settings()
codex_managed_result = codex_agent.revert_managed_config()

results: dict[str, bool] = {
tool: restore_file(
Expand All @@ -1127,6 +1133,7 @@ def revert() -> int:
if legacy_codex_stripped:
print_kv("Codex shared config", "ucode entries removed")
print_kv("Claude Code OS-managed settings", claude_managed_result)
print_kv("Codex OS-managed settings", codex_managed_result)
print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged")
for client, spec in MCP_CLIENTS.items():
print_kv(
Expand Down Expand Up @@ -1872,7 +1879,7 @@ def _can_launch_from_cached_config(
claude_agent.CLAUDE_SETTINGS_PATH.exists()
and claude_agent.managed_settings_are_current(state)
)
return codex_agent.has_ucode_config()
return codex_agent.has_ucode_config() and codex_agent.managed_config_is_current(state)


def _launch_tool(
Expand Down
2 changes: 0 additions & 2 deletions src/ucode/managed_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,6 @@ def _normalize_enabled_agent(entry: object) -> tuple[str, dict] | None:
return None
config_in = _as_dict(entry_dict.get("config"))
agent_config: dict = {}
if isinstance(config_in.get("use_as_global_settings"), bool):
agent_config["use_as_global_settings"] = config_in["use_as_global_settings"]
headers = config_in.get("custom_headers")
if isinstance(headers, dict):
clean = {
Expand Down
Loading
Loading