Skip to content
Open
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +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 Claude
Code and Codex) whether the config writes the agent's own OS-level settings file or a ucode-only one.
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 @@ -381,12 +385,15 @@ 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 |
| `~/.pi/agent/models.json` | Pi |
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
| `~/.ucode/managed-state.json` | The managed config — authored by `ucode setup` (admins) and refreshed from the workspace on launch |
| `~/.ucode/managed-backups/` | Baseline backups for OS-managed files changed by ucode |

Existing files are backed up before being overwritten. `ucode revert` restores backups.

Expand Down
226 changes: 226 additions & 0 deletions docs/os-managed-settings-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# OS-Managed Agent Settings

## Summary

Claude Code and Codex give OS-managed settings higher precedence than user settings. Previously,
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 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
managed file is compatible.

## Configuration Files

| Agent | Local ucode configuration | OS-managed configuration |
| --- | --- | --- |
| Claude Code | `~/.claude/ucode-settings.json` | Linux: `/etc/claude-code/managed-settings.json`; macOS: `/Library/Application Support/ClaudeCode/managed-settings.json` |
| Codex | `~/.codex/ucode.config.toml` | `/etc/codex/managed_config.toml` |

The local file is always written. The OS-managed file is additionally reconciled during interactive
configuration, except for Claude subscription relay.

## Interactive Detection

An invocation may modify OS-managed settings only when standard input is a TTY. Standard output does
not affect the decision, so piping logs does not disable an otherwise interactive configuration.
CI, pipes, cron jobs, and headless subprocesses normally have non-TTY standard input and therefore
remain local-only.

This is a new shared ucode distinction. The previous implementation inferred interactivity from
command shape in some flows and did not guard managed-file writes consistently.

## Behavior Matrix

| Invocation | Managed file | Behavior |
| --- | --- | --- |
| Interactive | Absent | Create it from the ucode configuration after recording an absent baseline. |
| Interactive | Unrelated or partially populated | Preserve unrelated values and add or update all ucode-owned values. |
| Interactive | Conflicting | Back up the baseline, replace the conflicting ucode-owned values, and verify. |
| Interactive | Already identical | Continue without a backup, write, or `sudo` invocation. |
| Non-interactive | Absent | Use the local ucode file. Do not create the managed file. |
| Non-interactive | Ucode-owned values absent or equal | Use the local ucode file. Do not modify the managed file. |
| Non-interactive | Ucode-owned value conflicts | Stop before launching because the higher-precedence value would override ucode. |
| Any | Invalid, unreadable, or symlinked | Stop without modifying the file because precedence cannot be established safely. |

`ucode configure`, first-time `ucode claude` or `ucode codex`, and later launches all use the same
agent-specific reconciliation path. A first-time launch from an interactive terminal can therefore
request administrator permission. A first-time non-interactive launch remains local-only.

## Interactive Reconciliation

For each agent, ucode:

1. Strictly parses the existing managed JSON or TOML document.
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.
6. Requests administrator permission and performs an atomic privileged replacement.
7. Reads the installed file back and verifies its exact contents.
8. Records the last-applied snapshot, owned paths, and a launch fingerprint.

An existing managed file is reconciled even when it does not currently conflict. This ensures every
ucode-required value exists at the highest-precedence scope and avoids separate behavior for absent,
partial, and conflicting files.

## Privileged Write Transaction

The shared writer handles ordinary MDM-installed and root-owned files rather than treating them as
errors:

- refuses symlink destinations;
- creates a root-owned destination directory when it is absent;
- stages the new file in the destination directory for a same-filesystem atomic rename;
- clones an existing file before replacing its contents, preserving ownership, mode, ACLs, and
extended attributes;
- creates new files as root-owned and world-readable;
- detects, clears, and restores macOS `schg`, `uchg`, `sappnd`, and `uappnd` flags;
- detects, clears, and restores Linux immutable and append-only attributes;
- verifies the resulting contents after replacement;
- retries once only when device management restored the exact pre-write contents;
- preserves a concurrently changed policy instead of overwriting it.

The privilege boundary is interactive. Non-interactive paths do not invoke either normal `sudo` or
`sudo -n`.

Root access cannot sustainably override an actively enforced policy. If an MDM process immediately
restores the original file twice, ucode stops with an error instead of repeatedly fighting the
management agent. A different concurrent update is also preserved and reported.

## Backup Model

Backups live under `~/.ucode/managed-backups/` with directory mode `0700` and file mode `0600`.
The manifest records, per agent:

- whether the managed file originally existed;
- its absolute path;
- the baseline snapshot and SHA-256 digest;
- the last ucode-applied snapshot and SHA-256 digest;
- the setting paths owned by ucode.

The baseline is created before the first managed change and is not replaced by subsequent
configurations. Later configurations update only the last-applied snapshot. Snapshot filenames are
validated, digests are checked before use, and symlinked backup directories or manifests are
rejected.

If ucode cannot complete a write or revert, the backup remains available for a later retry.

## `ucode revert`

`ucode revert` extends the existing local-file restoration with managed-file restoration:

- If the current file exactly matches ucode's last-applied snapshot, restore the exact baseline.
- If ucode created the file, delete it.
- If an administrator or MDM changed the file later, perform a three-way revert.
- Restore original values only where the current value still matches ucode's last-applied value.
- Remove only list entries added by ucode while preserving externally added entries.
- Preserve externally changed values rather than replacing them with stale baseline values.
- Refuse an unsafe or unparsable merge and retain the backup.

A revert that needs to change an OS-managed file must run interactively. A successful revert removes
that agent's backup record. The existing local configuration and ucode state cleanup still occur as
part of the command.

## Cached Launches

After a successful managed reconciliation or compatibility check, ucode stores:

- the managed path;
- the verification scope;
- device and inode numbers;
- size;
- nanosecond modification and change times.

A cached launch performs one `stat()` call and compares this fingerprint. When it matches, ucode
does not read, parse, back up, write, or invoke `sudo`. When it changes, the normal reconciliation or
compatibility path runs again. This catches later MDM replacement without adding meaningful latency
to unchanged launches.

The verification scopes distinguish:

- an interactively reconciled managed file;
- a managed file verified as compatible with local settings;
- a managed file verified as compatible with Claude relay.

A compatibility fingerprint created non-interactively cannot suppress the next interactive
reconciliation.

## Claude Subscription Relay

Claude relay is intentionally local-only. It routes through a loopback refresh proxy whose address
and lifetime belong to one `ucode claude` session. Persisting that address in OS-managed settings
would break bare `claude` launches and future sessions.

Relay therefore never creates or updates the managed file. It allows an absent file or unrelated
enterprise settings, but blocks these higher-precedence conflicts:

- `apiKeyHelper`;
- `env.ANTHROPIC_BASE_URL`;
- `env.ANTHROPIC_CUSTOM_HEADERS`.

If ucode wrote those values during an earlier standard configuration, the user runs `ucode revert`
interactively before switching to relay. External conflicting values require administrator action or
standard Databricks authentication.

## Status and Messages

`ucode status` reports, for Claude and Codex:

- managed settings path;
- state: not configured, current, compatible local settings, compatible relay settings, drifted,
invalid, unreadable, missing, or unsupported;
- whether a managed baseline backup is available.

Interactive updates announce the backup location, administrator-permission request, and verified
result. An identical file produces no elevation message.

Representative blockers are:

```text
Claude Code configuration cannot be applied non-interactively because OS-managed settings at
<path> override ucode values: env.ANTHROPIC_BASE_URL. Run `ucode configure --agent claude` from an
interactive terminal or contact your administrator.
```

```text
Claude Code managed settings at <path> were updated but immediately restored by device management.
Contact your administrator.
```

```text
Cannot safely update Codex managed settings at <path>: <parse error>. ucode did not modify the file.
Repair it or contact your administrator.
```

Write, verification, parse, symlink, and managed-conflict failures block the agent launch. Recovery
information always identifies the file and recommends either an interactive configure/revert or
administrator help.

## PR Boundaries

### PR 1: Claude Code

- Add shared strict reads, fingerprints, compatibility checks, secure backups, atomic privileged
writes, immutable-flag handling, verification, status, and three-way revert helpers.
- Make interactive Claude configuration reconcile OS-managed JSON by default.
- Add non-interactive local fallback with conflict detection.
- Add Claude relay-specific safety checks.
- Add Claude managed status and revert output.
- 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.
- 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.
32 changes: 0 additions & 32 deletions src/ucode/agent_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,35 +68,3 @@ def latest_version_below(package: str, ceiling: tuple[int, int, int]) -> str | N
pool = stable or at_max
# npm returns versions in ascending order, so the last entry is newest.
return pool[-1]


def available_npm_package_update(package: str) -> tuple[str, str] | None:
if not shutil.which("npm"):
return None
try:
result = subprocess.run(
["npm", "outdated", "-g", "--json", package],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None

# npm outdated exits 1 when it finds outdated packages.
if result.returncode not in (0, 1) or not result.stdout.strip():
return None
try:
outdated = json.loads(result.stdout)
except json.JSONDecodeError:
return None

package_update = outdated.get(package)
if not isinstance(package_update, dict):
return None
current = package_update.get("current")
latest = package_update.get("latest")
if not isinstance(current, str) or not isinstance(latest, str):
return None
return current, latest
44 changes: 16 additions & 28 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
map_claude_family_models,
resolve_provider_service,
)
from ucode.managed_files import managed_write_batch
from ucode.state import get_provider_service, load_state, save_state
from ucode.telemetry import agent_version
from ucode.ui import (
Expand Down Expand Up @@ -70,16 +71,7 @@

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"})
_MANAGED_SETTINGS_TOOLS = {"claude", "codex"}

# ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported.
AITOOLS_AGENT_TOKENS = {
Expand Down Expand Up @@ -148,16 +140,6 @@ def _required_update_message(tool: str) -> str | None:
return checker()


def _confirm_update_installed_tool_binary(tool: str) -> bool:
spec = TOOL_SPECS[tool]
update = _MODULES[tool].is_update_available()

if not update:
return False
current, latest = update
return prompt_yes_no(f"(Optional) Update {spec['display']} from {current} to {latest}?")


def _too_new_downgrade(tool: str) -> tuple[str, str] | None:
"""Return (installed_version, downgrade_target) when the installed tool is
too new to work, or None. Agents opt in by defining `too_new_downgrade`."""
Expand Down Expand Up @@ -215,9 +197,6 @@ def install_tool_binary(
print_warning(required_update)
if not _update_installed_tool_binary(tool):
raise RuntimeError(_minimum_version_error(tool) or required_update)
elif prompt_optional_updates and _confirm_update_installed_tool_binary(tool):
_update_installed_tool_binary(tool)

version_error = _minimum_version_error(tool)
if version_error:
raise RuntimeError(version_error)
Expand Down Expand Up @@ -434,7 +413,8 @@ def configure_single_tool(tool: str, state: dict) -> dict:
raise RuntimeError(
f"{TOOL_SPECS[tool]['display']} is not available on this workspace.{detail}"
)
state = _configure_one(tool, state, provider)
with managed_write_batch(_managed_settings_displays([tool])):
state = _configure_one(tool, state, provider)
available_tools = list(set((state.get("available_tools") or []) + [tool]))
state["available_tools"] = available_tools
save_state(state)
Expand All @@ -456,24 +436,32 @@ def _configure_one(tool: str, state: dict, provider: str | None) -> dict:
return configure_tool(tool, state, model)


def configure_selected_tools(state: dict, tools: list[str]) -> dict:
def configure_selected_tools(
state: dict, tools: list[str], *, install_ai_tools: bool = True
) -> dict:
"""Configure the given tools. Caller is responsible for ensuring each tool
is available on the workspace.

Merges newly-configured tools into state['available_tools'] rather than
replacing it, so a previously-configured tool the user didn't pick this
run is preserved.
"""
for tool in tools:
state = _configure_one(tool, state, get_provider_service(state, tool))
with managed_write_batch(_managed_settings_displays(tools)):
for tool in tools:
state = _configure_one(tool, state, get_provider_service(state, tool))

existing = state.get("available_tools") or []
state["available_tools"] = sorted(set(existing) | set(tools))
save_state(state)
install_databricks_ai_tools_for_agents(tools, state)
if install_ai_tools:
install_databricks_ai_tools_for_agents(tools, state)
return state


def _managed_settings_displays(tools: list[str]) -> list[str]:
return [TOOL_SPECS[tool]["display"] for tool in tools if tool in _MANAGED_SETTINGS_TOOLS]


def configure_all_tools(state: dict) -> dict:
"""Discover available tools on the workspace and configure all of them.

Expand Down
Loading