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.

54 changes: 54 additions & 0 deletions scripts/mdm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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.

## 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>
3 changes: 3 additions & 0 deletions scripts/run_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,9 @@ def run(command, *, cwd=output, env=base_env, timeout=600) -> str:
"UG_INTEGRATION_CODEX_PROVIDER_MODEL": args.codex_provider_model,
"UCODE_TEST_WORKSPACE": args.workspace or "",
"DATABRICKS_BEARER": bearer,
# Durable SP-minted PAT for the managed `--use-pat` (MDM) journeys; the
# runner's own bearer is hourly M2M, so these tests need a real PAT.
"E2E_ADMIN_SP_PAT": os.environ.get("E2E_ADMIN_SP_PAT", ""),
}
)
for agent in agents:
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ def workspace():
return value


@pytest.fixture(scope="session")
def admin_sp_pat():
"""The SP-minted PAT for the managed workspace, from the ``E2E_ADMIN_SP_PAT`` CI secret.
Unlike the runner's hourly M2M ``DATABRICKS_BEARER``, it is a durable PAT, so the MDM
``--use-pat`` journey exercises a real ``auth_type = pat`` profile. Required, like the
workspace and bearer."""
value = os.environ.get("E2E_ADMIN_SP_PAT", "").strip()
if not value:
pytest.fail("Live MDM --use-pat requires E2E_ADMIN_SP_PAT (the SP-minted PAT).")
return value


@pytest.fixture
def session(request, installed_binary):
# Codex rejects helper installation beneath /tmp. Keep the disposable home
Expand Down
42 changes: 42 additions & 0 deletions tests/integration/test_ug_configure_managed.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,45 @@ def test_ug_configure_managed_is_idempotent(live_session, workspace):

expected = (MANAGED_CLAUDE_MODELS, MANAGED_CLAUDE_MODELS, [MANAGED_CODEX_MODEL])
assert runs == [expected, expected], runs


@pytest.mark.managed
@pytest.mark.claude
def test_ug_configure_managed_via_pat(live_session, workspace, admin_sp_pat):
"""Scenario: MDM/JAMF headless provisioning — configure a managed workspace through a
``[ug-mdm]`` PAT profile plus ``ug configure --profile ug-mdm --use-pat``, exactly as
scripts/mdm-bootstrap.sh does, rather than the ``--workspace`` journeys above.

Expected: the managed config applies to every enabled agent with no selector — Claude's
static model_services become its picker allow-list and Codex's catalog lists exactly the
admin's models — reached through the PAT-profile auth path, with ``use_pat`` in state, and
a real launch reaches the gateway prompt rather than an account-login flow.
"""
session = live_session
# Headless MDM auth: a ``[ug-mdm]`` PAT profile (token = the SP-minted PAT) plus
# ``ug configure --profile ug-mdm --use-pat``, exactly as scripts/mdm-bootstrap.sh does.
config = session.home / ".databrickscfg"
config.write_text(f"[ug-mdm]\nhost = {workspace}\ntoken = {admin_sp_pat}\nauth_type = pat\n")
config.chmod(0o600)
result = session.run(
"configure", "--profile", "ug-mdm", "--use-pat", "--skip-upgrade", timeout=240
)
assert "Select coding agents to configure:" not in result.stdout, result.stdout
assert session.workspace_state().get("use_pat") is True, session.state()

settings = json.loads((session.home / ".claude" / "ucode-settings.json").read_text())
assert settings.get("availableModels") == MANAGED_CLAUDE_MODELS, settings
options = (settings.get("modelPicker") or {}).get("options", [])
assert [option.get("model") for option in options] == MANAGED_CLAUDE_MODELS, settings

catalog = json.loads((session.home / ".ucode" / "codex-model-catalog.json").read_text())
listed = [
model.get("slug")
for model in catalog.get("models", [])
if model.get("visibility") == "list"
]
assert listed == [MANAGED_CODEX_MODEL], catalog

with AgentTerminal(session, "claude", [str(session.binary), "claude"], "managed-pat") as tui:
tui.boot()
tui.check_input_and_exit()
Loading