diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index bd7e5d55..358e99b1 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -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. diff --git a/scripts/mdm-bootstrap.sh b/scripts/mdm-bootstrap.sh new file mode 100755 index 00000000..5735fc02 --- /dev/null +++ b/scripts/mdm-bootstrap.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# +# Unity Gateway (ug) MDM / JAMF bootstrap. +# +# Provisions a fresh macOS (or Linux) machine end to end so that, when this +# script finishes, `ug` and every workspace-enabled coding agent work headlessly +# with no browser login. Intended to be uploaded into JAMF and run as root on a +# bare machine, and to be testable inside a fresh container. +# +# The script: +# 1. Ensures ug's external prerequisites exist (curl/git, uv, node/npm). +# 2. Installs ug via uv. +# 3. Writes a PAT-based Databricks CLI profile. +# 4. Runs `ug configure --use-pat` headlessly (this also installs the +# Databricks CLI and the enabled agent CLIs via npm). +# 5. Probes every agent in the workspace's managed `enabled_agents` with a +# real one-shot inference call through the AI Gateway. +# +# All inputs are environment variables. JAMF reserves the positional parameters +# $1-$4 (mount point, computer name, user name, and its first script parameter), +# so this script never reads positional parameters. +# +# Required: +# UG_WORKSPACE_HOST Databricks workspace URL, e.g. https://myws.cloud.databricks.com +# UG_PAT Databricks personal access token for that workspace +# +# Optional: +# UG_PROFILE_NAME Databricks CLI profile name to write (default: ug-mdm) +# UG_AGENTS Comma-separated agents to force (e.g. "claude,codex"). +# Default: let the workspace's managed enabled_agents decide. +# UG_INSTALL_SPEC uv install spec for ug +# (default: git+https://github.com/databricks/unity-gateway) +# UG_NODE_VERSION Node.js version to install if node is absent (default below) +# UG_SKIP_PROBE If set to a non-empty value, skip the inference probe. +# +# ───────────────────────────────────────────────────────────────────────────── +# Container / CI usage (the secret is injected at run time, never stored here): +# +# docker run --rm \ +# -e UG_WORKSPACE_HOST="https://myws.cloud.databricks.com" \ +# -e UG_PAT="dapi..." \ +# my-image /path/to/mdm-bootstrap.sh +# +# JAMF usage: JAMF passes positional parameters ($4-$11) rather than env vars, +# and reserves $1-$3 (mount, computer, user). Deploy this script unchanged and +# upload a tiny wrapper as the JAMF policy script, mapping two JAMF parameters +# to the env vars this script reads (using $5/$6 to stay clear of $1-$4): +# +# #!/bin/bash +# # JAMF policy parameters: 5 = workspace URL, 6 = PAT +# export UG_WORKSPACE_HOST="$5" +# export UG_PAT="$6" +# exec /usr/local/bin/mdm-bootstrap.sh +# +# Note: a PAT passed as a JAMF parameter is visible in the JAMF policy config and +# logs. For a real fleet, prefer a Databricks service principal (OAuth M2M) as the +# machine identity rather than a shared user PAT (see the team writeup). +# +# OS-managed enforcement layer: +# This script provisions ug + LOCAL settings and runs `ug configure` NON- +# interactively, so it never writes the OS-managed files +# (/Library/Application Support/ClaudeCode/managed-settings.json, +# /etc/codex/managed_config.toml) and never prompts for a sudo password. +# `ug claude` / `ug codex` work off the local settings regardless. Deploy the +# OS-managed enforcement separately as JAMF configuration profiles +# (com.anthropic.claudecode, com.openai.codex) — see scripts/mdm/README.md — so +# gateway routing is enforced even for bare `claude` / `codex` launches. +# ───────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +# ── configuration ──────────────────────────────────────────────────────────── + +UG_PROFILE_NAME="${UG_PROFILE_NAME:-ug-mdm}" +UG_INSTALL_SPEC="${UG_INSTALL_SPEC:-git+https://github.com/databricks/unity-gateway}" +UG_NODE_VERSION="${UG_NODE_VERSION:-22.14.0}" # current LTS; overridable +UG_AGENTS="${UG_AGENTS:-}" +UG_SKIP_PROBE="${UG_SKIP_PROBE:-}" + +PROBE_PROMPT="say hi in 5 words or less" +NODE_PREFIX="${UG_NODE_PREFIX:-/opt/ug-node}" + +# ── UI helpers ─────────────────────────────────────────────────────────────── + +if [ -t 1 ]; then + _c_red=$'\033[31m'; _c_grn=$'\033[32m'; _c_ylw=$'\033[33m' + _c_blu=$'\033[34m'; _c_bld=$'\033[1m'; _c_rst=$'\033[0m' +else + _c_red=; _c_grn=; _c_ylw=; _c_blu=; _c_bld=; _c_rst= +fi + +section() { printf '\n%s==> %s%s\n' "$_c_blu$_c_bld" "$*" "$_c_rst"; } +info() { printf ' %s\n' "$*"; } +ok() { printf ' %s✓%s %s\n' "$_c_grn" "$_c_rst" "$*"; } +warn() { printf ' %s!%s %s\n' "$_c_ylw" "$_c_rst" "$*" >&2; } +die() { printf ' %s✗ %s%s\n' "$_c_red$_c_bld" "$*" "$_c_rst" >&2; exit 1; } + +# ── platform detection ─────────────────────────────────────────────────────── + +OS="$(uname -s)" +ARCH="$(uname -m)" + +is_macos() { [ "$OS" = "Darwin" ]; } +is_linux() { [ "$OS" = "Linux" ]; } + +# node's release naming for the current platform/arch. +node_platform() { + case "$OS" in + Darwin) printf 'darwin' ;; + Linux) printf 'linux' ;; + *) die "Unsupported OS for automatic node install: $OS" ;; + esac +} +node_arch() { + case "$ARCH" in + x86_64|amd64) printf 'x64' ;; + arm64|aarch64) printf 'arm64' ;; + *) die "Unsupported CPU architecture for automatic node install: $ARCH" ;; + esac +} + +# Root check: JAMF runs as root. Some installs (apt, /opt, /etc) need it. We do +# not hard-require root so the script is also runnable in a rootless container, +# but we warn when a step that wants root is reached without it. +IS_ROOT=0 +[ "$(id -u)" = "0" ] && IS_ROOT=1 + +as_root() { + if [ "$IS_ROOT" = "1" ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + die "This step needs root but neither root nor sudo is available: $*" + fi +} + +# Prepend a directory to PATH once, for this process and for child ug/agent runs. +add_to_path() { + case ":$PATH:" in + *":$1:"*) : ;; + *) PATH="$1:$PATH"; export PATH ;; + esac +} + +# ── input validation ───────────────────────────────────────────────────────── + +require_inputs() { + section "Validating inputs" + [ -n "${UG_WORKSPACE_HOST:-}" ] || die "UG_WORKSPACE_HOST is required (e.g. https://myws.cloud.databricks.com)." + [ -n "${UG_PAT:-}" ] || die "UG_PAT is required (a Databricks personal access token)." + case "$UG_WORKSPACE_HOST" in + https://*) : ;; + *) die "UG_WORKSPACE_HOST must start with https:// (got: $UG_WORKSPACE_HOST)." ;; + esac + ok "workspace: $UG_WORKSPACE_HOST" + ok "profile: $UG_PROFILE_NAME" + if [ -n "$UG_AGENTS" ]; then + ok "agents override: $UG_AGENTS" + else + info "agents: from workspace enabled_agents" + fi +} + +# ── phase 1: dependencies ──────────────────────────────────────────────────── + +ensure_apt_packages() { + # Only meaningful on Debian/Ubuntu-family Linux (the fresh-container case). + command -v apt-get >/dev/null 2>&1 || return 1 + as_root apt-get update -qq + as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" +} + +ensure_curl_and_git() { + section "Dependency: curl + git" + local missing=() + command -v curl >/dev/null 2>&1 || missing+=("curl") + command -v git >/dev/null 2>&1 || missing+=("git") + if [ "${#missing[@]}" -eq 0 ]; then + ok "curl and git present" + return + fi + info "installing: ${missing[*]}" + if is_linux && command -v apt-get >/dev/null 2>&1; then + ensure_apt_packages ca-certificates "${missing[@]}" + elif is_macos; then + die "Missing ${missing[*]} on macOS. Install the Xcode Command Line Tools (xcode-select --install)." + else + die "Cannot install ${missing[*]} automatically on this platform. Install them and re-run." + fi + command -v curl >/dev/null 2>&1 || die "curl still not on PATH after install." + command -v git >/dev/null 2>&1 || die "git still not on PATH after install." + ok "curl and git installed" +} + +ensure_uv() { + section "Dependency: uv" + add_to_path "$HOME/.local/bin" + add_to_path "$HOME/.cargo/bin" + if command -v uv >/dev/null 2>&1; then + ok "uv present ($(uv --version 2>/dev/null))" + return + fi + info "installing uv via astral.sh install script" + curl -LsSf https://astral.sh/uv/install.sh | sh + add_to_path "$HOME/.local/bin" + add_to_path "$HOME/.cargo/bin" + command -v uv >/dev/null 2>&1 || die "uv still not on PATH after install. Check ~/.local/bin." + ok "uv installed ($(uv --version 2>/dev/null))" +} + +ensure_node() { + section "Dependency: node + npm" + add_to_path "$NODE_PREFIX/bin" + if command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then + ok "node present ($(node --version 2>/dev/null)), npm present ($(npm --version 2>/dev/null))" + return + fi + local plat arch tarball url dest + plat="$(node_platform)" + arch="$(node_arch)" + tarball="node-v${UG_NODE_VERSION}-${plat}-${arch}.tar.gz" + url="https://nodejs.org/dist/v${UG_NODE_VERSION}/${tarball}" + info "installing Node.js v${UG_NODE_VERSION} for ${plat}-${arch} into ${NODE_PREFIX}" + as_root mkdir -p "$NODE_PREFIX" + dest="$(mktemp -d)" + curl -fsSL "$url" -o "$dest/$tarball" || die "Failed to download Node.js from $url" + # Strip the top-level node-vX-plat-arch/ directory so binaries land in $NODE_PREFIX/bin. + as_root tar -xzf "$dest/$tarball" -C "$NODE_PREFIX" --strip-components=1 + rm -rf "$dest" + add_to_path "$NODE_PREFIX/bin" + command -v node >/dev/null 2>&1 || die "node still not on PATH after install ($NODE_PREFIX/bin)." + command -v npm >/dev/null 2>&1 || die "npm still not on PATH after install ($NODE_PREFIX/bin)." + ok "node installed ($(node --version)), npm ($(npm --version))" +} + +# ── phase 2: install ug ────────────────────────────────────────────────────── + +install_ug() { + section "Installing Unity Gateway (ug)" + info "uv tool install --force $UG_INSTALL_SPEC" + uv tool install --force "$UG_INSTALL_SPEC" + # uv tool binaries live in the uv tool bin dir; make sure it's reachable. + add_to_path "$(uv tool dir --bin 2>/dev/null || printf '%s' "$HOME/.local/bin")" + add_to_path "$HOME/.local/bin" + command -v ug >/dev/null 2>&1 || die "ug is not on PATH after install. Check the uv tool bin directory." + ok "ug installed ($(ug --version 2>/dev/null))" +} + +# ── phase 3: configure headlessly ──────────────────────────────────────────── + +write_databricks_profile() { + section "Writing Databricks CLI profile [$UG_PROFILE_NAME]" + local cfg="${DATABRICKS_CONFIG_FILE:-$HOME/.databrickscfg}" + local tmp + tmp="$(mktemp)" + # Drop any pre-existing block for this profile, keeping every other profile + # intact, then append a fresh PAT block. + if [ -f "$cfg" ]; then + awk -v prof="[$UG_PROFILE_NAME]" ' + $0 == prof { skip = 1; next } + /^\[/ { skip = 0 } + !skip { print } + ' "$cfg" > "$tmp" + fi + { + printf '[%s]\n' "$UG_PROFILE_NAME" + printf 'host = %s\n' "$UG_WORKSPACE_HOST" + printf 'token = %s\n' "$UG_PAT" + printf 'auth_type = pat\n' + } >> "$tmp" + mkdir -p "$(dirname "$cfg")" + mv "$tmp" "$cfg" + chmod 600 "$cfg" + ok "wrote profile to $cfg (mode 600)" +} + +configure_ug() { + section "Configuring ug (headless, PAT)" + local args=(configure --profile "$UG_PROFILE_NAME" --use-pat) + [ -n "$UG_AGENTS" ] && args+=(--agents "$UG_AGENTS") + info "ug ${args[*]}" + # stdin from /dev/null keeps ug non-interactive: it writes only local settings + # and skips the sudo OS-managed reconciliation (which would prompt). The + # OS-managed enforcement is deployed via MDM profiles — see scripts/mdm/. + ug "${args[@]}" .py). Returns 0 on non-empty output. +probe_agent() { + local tool="$1" out rc + local run=(ug "$tool") + case "$tool" in + claude) run+=(-p "$PROBE_PROMPT" --max-turns 1) ;; + codex) run+=(exec --skip-git-repo-check "$PROBE_PROMPT") ;; + gemini) run+=(-p "$PROBE_PROMPT") ;; + opencode) run+=(run "$PROBE_PROMPT") ;; + copilot) run+=(--prompt "$PROBE_PROMPT" --allow-all-tools) ;; + pi) run+=(--print "$PROBE_PROMPT") ;; + *) warn "no probe recipe for '$tool'; skipping"; return 2 ;; + esac + # stdin from /dev/null so the launch stays non-interactive too (no per-launch + # sudo managed-settings prompt); -p/exec read the prompt from argv, not stdin. + if command -v timeout >/dev/null 2>&1; then + out="$(timeout 180 "${run[@]}" /dev/null)" && rc=0 || rc=$? + else + out="$("${run[@]}" /dev/null)" && rc=0 || rc=$? + fi + if [ "$rc" -eq 0 ] && [ -n "${out//[[:space:]]/}" ]; then + ok "$tool responded: $(printf '%s' "$out" | tr '\n' ' ' | cut -c1-60)" + return 0 + fi + warn "$tool probe failed (exit $rc)" + return 1 +} + +probe_enabled_agents() { + section "Probing enabled agents (real inference)" + if [ -n "$UG_SKIP_PROBE" ]; then + info "UG_SKIP_PROBE set — skipping inference probe" + return 0 + fi + local export_json enums + export_json="$(ug export 2>/dev/null)" || die "ug export failed; cannot determine enabled_agents." + # Parse enabled_agents[].agent with node (guaranteed present after phase 1). + enums="$(printf '%s' "$export_json" | node -e ' + let d = ""; + process.stdin.on("data", c => d += c).on("end", () => { + try { + const j = JSON.parse(d); + const a = (j.enabled_agents || []).map(x => x && x.agent).filter(Boolean); + process.stdout.write(a.join("\n")); + } catch (e) { process.exit(3); } + }); + ')" || die "Could not parse enabled_agents from ug export output." + + if [ -z "$enums" ]; then + warn "no enabled_agents in the managed config; nothing to probe" + return 0 + fi + + local failed=0 probed=0 enum tool + while IFS= read -r enum; do + [ -n "$enum" ] || continue + tool="$(enum_to_tool "$enum")" + if [ -z "$tool" ]; then + warn "unknown agent enum '$enum'; skipping" + continue + fi + probed=$((probed + 1)) + probe_agent "$tool" || failed=$((failed + 1)) + done < 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. diff --git a/scripts/mdm/claude-code.mobileconfig b/scripts/mdm/claude-code.mobileconfig new file mode 100644 index 00000000..fbd726f9 --- /dev/null +++ b/scripts/mdm/claude-code.mobileconfig @@ -0,0 +1,125 @@ + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + com.example.mdm.claudecode + PayloadUUID + REPLACE-WITH-UUIDGEN-1 + PayloadDisplayName + Claude Code Managed Settings (Databricks AI Gateway) + PayloadOrganization + Example Organization + PayloadScope + System + PayloadContent + + + PayloadType + com.anthropic.claudecode + PayloadVersion + 1 + PayloadIdentifier + com.example.mdm.claudecode.preferences + PayloadUUID + REPLACE-WITH-UUIDGEN-2 + PayloadDisplayName + Claude Code Preferences + + + apiKeyHelper + ug auth-token --host https://WORKSPACE_HOST --profile ug-mdm --use-pat + + + env + + ANTHROPIC_BASE_URL + https://WORKSPACE_HOST/ai-gateway/anthropic + ANTHROPIC_CUSTOM_HEADERS + x-databricks-use-coding-agent-mode: true +User-Agent: ucode/managed claude/managed + CLAUDE_CODE_USE_GATEWAY + 1 + CLAUDE_CODE_API_KEY_HELPER_TTL_MS + 900000 + ENABLE_PROMPT_CACHING_1H + 1 + ENABLE_TOOL_SEARCH + true + + ANTHROPIC_DEFAULT_OPUS_MODEL + system.ai.claude-opus-4-8[1m] + ANTHROPIC_DEFAULT_SONNET_MODEL + system.ai.claude-sonnet-4-6[1m] + ANTHROPIC_DEFAULT_HAIKU_MODEL + system.ai.claude-haiku-4-5 + + + + availableModels + + system.ai.claude-opus-4-8[1m] + system.ai.claude-sonnet-4-6[1m] + system.ai.claude-haiku-4-5 + + enforceAvailableModels + + modelPicker + + replaceBuiltInOptions + + options + + + model + system.ai.claude-opus-4-8[1m] + label + Claude Opus 4.8 (1M) + + + model + system.ai.claude-sonnet-4-6[1m] + label + Claude Sonnet 4.6 (1M) + + + model + system.ai.claude-haiku-4-5 + label + Claude Haiku 4.5 + + + + + + + diff --git a/scripts/mdm/codex-managed_config.toml.template b/scripts/mdm/codex-managed_config.toml.template new file mode 100644 index 00000000..28b1b691 --- /dev/null +++ b/scripts/mdm/codex-managed_config.toml.template @@ -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" diff --git a/scripts/mdm/codex.mobileconfig b/scripts/mdm/codex.mobileconfig new file mode 100644 index 00000000..b51fad4b --- /dev/null +++ b/scripts/mdm/codex.mobileconfig @@ -0,0 +1,62 @@ + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + com.example.mdm.codex + PayloadUUID + REPLACE-WITH-UUIDGEN-1 + PayloadDisplayName + Codex Managed Configuration (Databricks AI Gateway) + PayloadOrganization + Example Organization + PayloadScope + System + PayloadContent + + + PayloadType + com.openai.codex + PayloadVersion + 1 + PayloadIdentifier + com.example.mdm.codex.preferences + PayloadUUID + REPLACE-WITH-UUIDGEN-2 + PayloadDisplayName + Codex Preferences + + config_toml_base64 + REPLACE_WITH_BASE64_OF_managed_config.toml + + + + diff --git a/scripts/run_integration.py b/scripts/run_integration.py index 9fa73e2b..cbe91a21 100644 --- a/scripts/run_integration.py +++ b/scripts/run_integration.py @@ -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: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7c8c7a52..1857c609 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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 diff --git a/tests/integration/test_ug_configure_managed.py b/tests/integration/test_ug_configure_managed.py index 3faea234..3c442a64 100644 --- a/tests/integration/test_ug_configure_managed.py +++ b/tests/integration/test_ug_configure_managed.py @@ -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()