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
3 changes: 3 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ jobs:
UCODE_TEST_WORKSPACE: ${{ secrets.E2E_ADMIN_WORKSPACE }}
DATABRICKS_CLIENT_ID: ${{ secrets.E2E_ADMIN_SP_CLIENT_ID }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.E2E_ADMIN_SP_CLIENT_SECRET }}
# Durable SP-minted PAT for the MDM `--use-pat` journeys (the runner's own token
# is hourly M2M; the `_via_pat` tests need a real auth_type=pat profile).
E2E_ADMIN_SP_PAT: ${{ secrets.E2E_ADMIN_SP_PAT }}
run: |
# A managed config enables both agents and `ug configure` applies it to every enabled
# agent, so both CLIs must be installed even though this lane asserts only one agent.
Expand Down
396 changes: 396 additions & 0 deletions scripts/mdm-bootstrap.sh

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions scripts/mdm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# MDM / JAMF deployment for Unity Gateway

Deploying coding agents through the Databricks AI Gateway to a fleet of macs has
two independent layers. Keep them separate.

## Layer 1 — provisioning (`../mdm-bootstrap.sh`)

A JAMF policy script, run as root on each machine. It ensures prerequisites
(`uv`, `node`), installs `ug`, writes a PAT-based Databricks profile, runs
`ug configure` **non-interactively**, and probes each enabled agent. Because it
runs non-interactively, `ug` writes only its **local** settings and never touches
the OS-managed files, so there is no `sudo` password prompt. `ug claude` /
`ug codex` work off those local settings.

See `../mdm-bootstrap.sh` for inputs (env vars) and the JAMF wrapper snippet.

## Layer 2 — enforcement (config profiles in this directory)

The OS-managed settings are what enforce gateway routing even for **bare**
`claude` / `codex` launches (not just `ug claude`). Both agents read a macOS
managed-preferences domain, so deploy them as JAMF **Configuration Profiles** —
no root file-writes, no `sudo`, and the profile outranks any on-disk file. This
matches how OpenRouter Ori is deployed (`com.openrouter.ori`).

| Agent | Domain | Template | Reads it |
| --- | --- | --- | --- |
| Claude Code | `com.anthropic.claudecode` | `claude-code.mobileconfig` | startup + every 30 min, read-only |
| Codex | `com.openai.codex` | `codex.mobileconfig` (+ `codex-managed_config.toml.template`) | startup, read-only |

Deploy each via JAMF -> Configuration Profiles -> Application & Custom Settings
(or upload the `.mobileconfig`). Each template has a header comment listing the
placeholders to fill (workspace host, model list, UUIDs) before deployment.

References:
- Claude Code managed settings: https://code.claude.com/docs/en/managed-settings
(and Anthropic's Jamf template: https://github.com/anthropics/claude-code/tree/main/examples/mdm)
- Codex managed configuration: https://developers.openai.com/codex/enterprise/managed-configuration

## Why the split

Claude Code and Codex both treat OS-managed settings as **externally owned and
read-only** — an external tool writes them once and the agent only reads them.
Having the bootstrap (or `ug` per launch) rewrite them via `sudo` fights that
model and prompts non-admin users. Let MDM own the enforcement layer; let the
bootstrap own provisioning + local settings.

## Troubleshooting (`heal-claude-gateway.sh`)

`heal-claude-gateway.sh` is an interactive, idempotent repair tool for a Mac left
half-configured: a legacy wrapper (`~/.local/bin/ucode-claude-ide`) that hardcodes
`ug claude --provider ...` and trips the "`--provider` not allowed when a managed
config exists" error, or a machine where `ug configure` wrote only its local
settings and never the OS-managed file (so `ug claude` routes but bare `claude` /
VS Code do not).

A developer runs it by **typing** it in Terminal (it refuses a non-TTY run,
because `ug` only writes the root-owned managed settings when stdin is a TTY):

bash heal-claude-gateway.sh https://<workspace>.cloud.databricks.com

It backs up and removes the legacy wrapper, clears the VS Code
`claudeCode.claudeProcessWrapper` setting, runs `ug configure`, verifies where each
piece landed (`~/.claude/ucode-settings.json` and the OS-managed file), and writes
a redacted `~/ug-heal-report-*.txt` for support. Everything it changes is backed up.

## Known gap

`ug` cannot yet **emit** these profile payloads for MDM packaging — it only writes
the OS-managed files in place via an interactive `sudo` reconciliation. Until it
can, generate accurate content from a reference machine (run the bootstrap once as
admin, then read `/Library/Application Support/ClaudeCode/managed-settings.json`
and `/etc/codex/managed_config.toml`) and transcribe it into these templates. See
the "ug MDM gaps" note for the requested `ug` changes.
125 changes: 125 additions & 0 deletions scripts/mdm/claude-code.mobileconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Claude Code OS-managed settings for JAMF/MDM (macOS).

This is a TEMPLATE. Claude Code reads enterprise-managed settings from the
`com.anthropic.claudecode` managed-preferences domain (same top-level keys as
managed-settings.json), and an MDM configuration profile OUTRANKS the on-disk
managed-settings.json file. Deploy this via JAMF -> Configuration Profiles ->
Application & Custom Settings; Claude Code re-reads it at startup and every 30
minutes and never writes it. See:
https://code.claude.com/docs/en/managed-settings and Anthropic's Jamf template
at https://github.com/anthropics/claude-code/tree/main/examples/mdm

Before deploying:
1. Replace WORKSPACE_HOST with your workspace host (no scheme in the host slot,
full https:// URL in ANTHROPIC_BASE_URL / apiKeyHelper).
2. Replace the model slugs + picker labels with your workspace's enabled models.
3. Regenerate the two PayloadUUID values (uuidgen) and set PayloadOrganization.
4. apiKeyHelper calls `ug auth-token`, so `ug` must be installed and on PATH for
the launching user (the bootstrap installs it); use an absolute path to `ug`
if it isn't on a system-wide PATH.

NOTE: the exact values below mirror what `ug configure` writes for Claude Code
(src/ucode/agents/claude.py). Ideally `ug` would emit this profile directly
(see the "ug MDM gaps" note) rather than admins hand-maintaining it.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.mdm.claudecode</string>
<key>PayloadUUID</key>
<string>REPLACE-WITH-UUIDGEN-1</string>
<key>PayloadDisplayName</key>
<string>Claude Code Managed Settings (Databricks AI Gateway)</string>
<key>PayloadOrganization</key>
<string>Example Organization</string>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.anthropic.claudecode</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.mdm.claudecode.preferences</string>
<key>PayloadUUID</key>
<string>REPLACE-WITH-UUIDGEN-2</string>
<key>PayloadDisplayName</key>
<string>Claude Code Preferences</string>

<!-- Gateway credential helper: mints the AI Gateway token per request. -->
<key>apiKeyHelper</key>
<string>ug auth-token --host https://WORKSPACE_HOST --profile ug-mdm --use-pat</string>

<!-- Point Claude Code at the workspace's AI Gateway. -->
<key>env</key>
<dict>
<key>ANTHROPIC_BASE_URL</key>
<string>https://WORKSPACE_HOST/ai-gateway/anthropic</string>
<key>ANTHROPIC_CUSTOM_HEADERS</key>
<string>x-databricks-use-coding-agent-mode: true
User-Agent: ucode/managed claude/managed</string>
<key>CLAUDE_CODE_USE_GATEWAY</key>
<string>1</string>
<key>CLAUDE_CODE_API_KEY_HELPER_TTL_MS</key>
<string>900000</string>
<key>ENABLE_PROMPT_CACHING_1H</key>
<string>1</string>
<key>ENABLE_TOOL_SEARCH</key>
<string>true</string>
<!-- Per-family default model ids (replace with your enabled models). -->
<key>ANTHROPIC_DEFAULT_OPUS_MODEL</key>
<string>system.ai.claude-opus-4-8[1m]</string>
<key>ANTHROPIC_DEFAULT_SONNET_MODEL</key>
<string>system.ai.claude-sonnet-4-6[1m]</string>
<key>ANTHROPIC_DEFAULT_HAIKU_MODEL</key>
<string>system.ai.claude-haiku-4-5</string>
</dict>

<!-- Restrict + relabel the /model picker to the workspace's enabled models. -->
<key>availableModels</key>
<array>
<string>system.ai.claude-opus-4-8[1m]</string>
<string>system.ai.claude-sonnet-4-6[1m]</string>
<string>system.ai.claude-haiku-4-5</string>
</array>
<key>enforceAvailableModels</key>
<true/>
<key>modelPicker</key>
<dict>
<key>replaceBuiltInOptions</key>
<true/>
<key>options</key>
<array>
<dict>
<key>model</key>
<string>system.ai.claude-opus-4-8[1m]</string>
<key>label</key>
<string>Claude Opus 4.8 (1M)</string>
</dict>
<dict>
<key>model</key>
<string>system.ai.claude-sonnet-4-6[1m]</string>
<key>label</key>
<string>Claude Sonnet 4.6 (1M)</string>
</dict>
<dict>
<key>model</key>
<string>system.ai.claude-haiku-4-5</string>
<key>label</key>
<string>Claude Haiku 4.5</string>
</dict>
</array>
</dict>
</dict>
</array>
</dict>
</plist>
20 changes: 20 additions & 0 deletions scripts/mdm/codex-managed_config.toml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Starter managed_config.toml for Codex (Databricks AI Gateway).
#
# This is a STARTER. The authoritative content is whatever `ug configure` writes
# to /etc/codex/managed_config.toml on a reference machine — prefer copying that
# (it has the correct base URL and model-catalog pointer for your workspace).
#
# Deploy either as base64 in the com.openai.codex MDM profile
# (config_toml_base64 in codex.mobileconfig) or as the file
# /etc/codex/managed_config.toml. Replace WORKSPACE_HOST.

# Dynamic per-workspace model list; `ug` maintains this file in the user's home
# (no sudo), while this managed file only points at it.
model_catalog_json = "~/.codex/ucode-models.json"

model_provider = "Databricks"

[model_providers.Databricks]
name = "Databricks"
base_url = "https://WORKSPACE_HOST/ai-gateway/codex/v1"
wire_api = "responses"
62 changes: 62 additions & 0 deletions scripts/mdm/codex.mobileconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Codex CLI OS-managed settings for JAMF/MDM (macOS).

This is a TEMPLATE. Codex reads enterprise-managed configuration from the
`com.openai.codex` managed-preferences domain, as base64-encoded TOML:
- config_toml_base64 legacy managed defaults (== /etc/codex/managed_config.toml)
- requirements_toml_base64 enforced requirements (== /etc/codex/requirements.toml)
No physical TOML file is needed on the Mac when deploying via MDM. See:
https://developers.openai.com/codex/enterprise/managed-configuration

Before deploying:
1. Produce the managed_config.toml content. The most reliable way is to read
the file `ug` itself writes on a reference machine (run the bootstrap once
as admin, then `cat /etc/codex/managed_config.toml`), which already has the
correct AI Gateway base URL and model-catalog pointer. A starter is in
scripts/mdm/codex-managed_config.toml.template.
2. base64-encode it (no line wraps) and paste into config_toml_base64:
base64 -i managed_config.toml | tr -d '\r\n'
3. Optionally add requirements_toml_base64 for enforced restrictions.
4. Regenerate the two PayloadUUID values (uuidgen) and set PayloadOrganization.

NOTE: ideally `ug` would emit this base64 payload directly (see the "ug MDM
gaps" note) rather than admins hand-encoding it.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.mdm.codex</string>
<key>PayloadUUID</key>
<string>REPLACE-WITH-UUIDGEN-1</string>
<key>PayloadDisplayName</key>
<string>Codex Managed Configuration (Databricks AI Gateway)</string>
<key>PayloadOrganization</key>
<string>Example Organization</string>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.openai.codex</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.mdm.codex.preferences</string>
<key>PayloadUUID</key>
<string>REPLACE-WITH-UUIDGEN-2</string>
<key>PayloadDisplayName</key>
<string>Codex Preferences</string>
<!-- base64 of managed_config.toml (see header + template). -->
<key>config_toml_base64</key>
<string>REPLACE_WITH_BASE64_OF_managed_config.toml</string>
</dict>
</array>
</dict>
</plist>
Loading
Loading