From d84d04f5b6d6bc7bed82022aa8125bcd1252a2c3 Mon Sep 17 00:00:00 2001 From: David Liu Date: Wed, 16 Sep 2026 23:03:45 +0000 Subject: [PATCH 1/4] Add MDM/JAMF bootstrap script for headless ug provisioning scripts/mdm-bootstrap.sh provisions a bare machine end to end: ensures uv + node, installs ug, writes a PAT Databricks profile, runs `ug configure --use-pat` headlessly (which also installs the enabled agent CLIs), then probes every managed enabled_agents entry with a one-shot inference call. Inputs are env vars only (JAMF-safe). For JAMF mass deployment (AIGTWY-4678). Co-authored-by: Isaac --- scripts/mdm-bootstrap.sh | 381 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100755 scripts/mdm-bootstrap.sh diff --git a/scripts/mdm-bootstrap.sh b/scripts/mdm-bootstrap.sh new file mode 100755 index 000000000..a0a25f479 --- /dev/null +++ b/scripts/mdm-bootstrap.sh @@ -0,0 +1,381 @@ +#!/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). +# ───────────────────────────────────────────────────────────────────────────── + +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[*]}" + ug "${args[@]}" + ok "ug configured" +} + +# ── phase 4: verify with a real inference probe ────────────────────────────── + +# Map a CODING_AGENT_* proto enum to the ug agent name (see +# src/ucode/managed_config.py:AGENT_ENUM_TO_TOOL). +enum_to_tool() { + case "$1" in + CODING_AGENT_CLAUDE_CODE) printf 'claude' ;; + CODING_AGENT_CODEX) printf 'codex' ;; + CODING_AGENT_GEMINI) printf 'gemini' ;; + CODING_AGENT_COPILOT) printf 'copilot' ;; + CODING_AGENT_PI) printf 'pi' ;; + CODING_AGENT_OPENCODE) printf 'opencode' ;; + *) printf '' ;; + esac +} + +# Run one agent's non-interactive one-shot through ug. Mirrors each agent's +# validate_cmd recipe (src/ucode/agents/.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 + if command -v timeout >/dev/null 2>&1; then + out="$(timeout 180 "${run[@]}" 2>/dev/null)" && rc=0 || rc=$? + else + out="$("${run[@]}" 2>/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 < Date: Wed, 16 Sep 2026 23:27:31 +0000 Subject: [PATCH 2/4] mdm-bootstrap: non-interactive configure + MDM enforcement profiles Run `ug configure` and the agent probes with stdin from /dev/null so ug stays non-interactive: it writes only local settings and never attempts the sudo OS-managed reconciliation, removing the password prompt (matches JAMF's non-TTY behavior). Add scripts/mdm/ with JAMF configuration-profile templates for the OS-managed enforcement layer, deployed separately from provisioning: Claude Code via com.anthropic.claudecode and Codex via com.openai.codex (base64 TOML), plus a README explaining the two-layer split. For AIGTWY-4678. Co-authored-by: Isaac --- scripts/mdm-bootstrap.sh | 21 ++- scripts/mdm/README.md | 54 ++++++++ scripts/mdm/claude-code.mobileconfig | 125 ++++++++++++++++++ .../mdm/codex-managed_config.toml.template | 20 +++ scripts/mdm/codex.mobileconfig | 62 +++++++++ 5 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 scripts/mdm/README.md create mode 100644 scripts/mdm/claude-code.mobileconfig create mode 100644 scripts/mdm/codex-managed_config.toml.template create mode 100644 scripts/mdm/codex.mobileconfig diff --git a/scripts/mdm-bootstrap.sh b/scripts/mdm-bootstrap.sh index a0a25f479..5735fc022 100755 --- a/scripts/mdm-bootstrap.sh +++ b/scripts/mdm-bootstrap.sh @@ -55,6 +55,16 @@ # 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 @@ -270,7 +280,10 @@ configure_ug() { local args=(configure --profile "$UG_PROFILE_NAME" --use-pat) [ -n "$UG_AGENTS" ] && args+=(--agents "$UG_AGENTS") info "ug ${args[*]}" - 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[@]}" /dev/null 2>&1; then - out="$(timeout 180 "${run[@]}" 2>/dev/null)" && rc=0 || rc=$? + out="$(timeout 180 "${run[@]}" /dev/null)" && rc=0 || rc=$? else - out="$("${run[@]}" 2>/dev/null)" && rc=0 || rc=$? + 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)" diff --git a/scripts/mdm/README.md b/scripts/mdm/README.md new file mode 100644 index 000000000..19f42a8e6 --- /dev/null +++ b/scripts/mdm/README.md @@ -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. diff --git a/scripts/mdm/claude-code.mobileconfig b/scripts/mdm/claude-code.mobileconfig new file mode 100644 index 000000000..fbd726f96 --- /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 000000000..28b1b6916 --- /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 000000000..b51fad4bc --- /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 + + + + From 81b3374eefda33f6b69dee15995b16ad55677070 Mon Sep 17 00:00:00 2001 From: David Liu Date: Thu, 17 Sep 2026 17:16:13 +0000 Subject: [PATCH 3/4] Add a managed `--use-pat` (MDM) integration CUJ Add `test_ug_configure_managed_via_pat`: configure the managed workspace through a `[ug-mdm]` PAT profile + `ug configure --profile ug-mdm --use-pat`, exactly as scripts/mdm-bootstrap.sh does, and assert the same managed-config outcome (no selector, admin's static models in Claude's picker and Codex's catalog) plus `use_pat` in state and a real launch. This is the only coverage of the MDM headless auth path; existing managed CUJs use `--workspace` plus the runner's bearer. The token comes from the durable `E2E_ADMIN_SP_PAT` CI secret (a stored SP-minted PAT; the runner's own bearer is hourly M2M). Thread that secret through the runner's pytest env allowlist and the managed workflow job; the test skips when it's unset. For AIGTWY-4678. Co-authored-by: Isaac --- .github/workflows/integration.yml | 3 ++ scripts/run_integration.py | 3 ++ tests/integration/conftest.py | 12 ++++++ .../integration/test_ug_configure_managed.py | 42 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index bd7e5d552..358e99b1f 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/run_integration.py b/scripts/run_integration.py index 9fa73e2b5..cbe91a219 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 7c8c7a528..1857c6093 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 3faea2349..3c442a649 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() From 363ec74522aee0a52e1d9e973c7e3e410e990bc5 Mon Sep 17 00:00:00 2001 From: David Liu Date: Wed, 23 Sep 2026 17:52:57 +0000 Subject: [PATCH 4/4] Add heal-claude-gateway.sh: interactive repair for half-configured Macs Idempotent support script for a Mac where ug/Claude gateway routing is broken: removes the legacy ucode-claude-ide wrapper that hardcodes `--provider` (tripping the managed-config guard), clears the VS Code claudeProcessWrapper setting, runs `ug configure` interactively so it can write both ~/.claude/ucode-settings.json and the OS-managed managed-settings.json, verifies where each landed, and writes a redacted diagnostics report. Refuses a non-TTY run since the OS-managed write is gated on stdin.isatty(). All changes are backed up. Co-authored-by: Isaac --- scripts/mdm/README.md | 19 +++ scripts/mdm/heal-claude-gateway.sh | 186 +++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100755 scripts/mdm/heal-claude-gateway.sh diff --git a/scripts/mdm/README.md b/scripts/mdm/README.md index 19f42a8e6..3339bf058 100644 --- a/scripts/mdm/README.md +++ b/scripts/mdm/README.md @@ -44,6 +44,25 @@ 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://.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 diff --git a/scripts/mdm/heal-claude-gateway.sh b/scripts/mdm/heal-claude-gateway.sh new file mode 100755 index 000000000..eb12df3f0 --- /dev/null +++ b/scripts/mdm/heal-claude-gateway.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# heal-ug-claude.sh +# Unblocks `ug claude` and bare-Claude gateway routing on a Mac left half-configured: +# - removes the legacy ucode-claude-ide wrapper that hardcodes --provider +# - clears the VS Code setting that points at that wrapper +# - runs `ug configure` so it can write both its own settings and the OS-managed file +# - verifies where each piece landed and reports what still needs attention +# +# RUN IT BY TYPING IT IN Terminal. Do NOT pipe it (no `curl ... | bash`) and do not +# redirect stdin: `ug configure` only writes the root-owned OS-managed settings when +# stdin is an interactive TTY, which is the step that makes bare `claude` / VS Code route. +# +# Usage: +# bash heal-ug-claude.sh https://dbc-XXXXXXXX.cloud.databricks.com +# # or: UG_WORKSPACE_HOST=https://... bash heal-ug-claude.sh +# +# Re-running is safe. Everything it changes is backed up first. + +set -uo pipefail # deliberately not -e: run every check and report, don't abort on first failure + +WORKSPACE_HOST="${1:-${UG_WORKSPACE_HOST:-}}" +TS="$(date +%Y%m%d-%H%M%S)" +WRAPPER="$HOME/.local/bin/ucode-claude-ide" +UCODE_SETTINGS="$HOME/.claude/ucode-settings.json" +MANAGED_DIR="/Library/Application Support/ClaudeCode" +MANAGED_FILE="$MANAGED_DIR/managed-settings.json" + +say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +ok() { printf ' \033[32m[ok]\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m[!]\033[0m %s\n' "$*" >&2; } +err() { printf ' \033[31m[x] %s\033[0m\n' "$*" >&2; } + +REPORT="$HOME/ug-heal-report-$TS.txt" + +# Mask token-like strings before anything is written to the shareable report. +redact() { + sed -E \ + -e 's/dapi[0-9a-f]+//g' \ + -e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+//g' \ + -e 's/([Bb]earer )[A-Za-z0-9._-]+/\1/g' \ + -e 's/("(token|secret|client_secret|refresh_token|access_token|password)"[[:space:]]*:[[:space:]]*")[^"]*/\1/g' +} + +# ---- preconditions ---------------------------------------------------------- +[ "$(uname -s)" = "Darwin" ] || { err "This script targets macOS."; exit 1; } +if [ -z "$WORKSPACE_HOST" ]; then + err "Pass your workspace URL, e.g.: bash $0 https://dbc-XXXXXXXX.cloud.databricks.com" + exit 1 +fi +case "$WORKSPACE_HOST" in https://*) ;; *) err "Workspace URL must start with https://"; exit 1 ;; esac +if [ ! -t 0 ]; then + err "stdin is not an interactive terminal. ug would SKIP the OS-managed settings write" + err "(so bare 'claude' / VS Code would not route), and its agent picker can hang." + err "Re-run by TYPING this in Terminal, not piping it." + exit 1 +fi +command -v ug >/dev/null 2>&1 || { err "ug is not on PATH. Run 'ug upgrade' (or install ug) first."; exit 1; } +ok "ug $(ug --version 2>/dev/null || echo '(version unknown)')" +ok "workspace $WORKSPACE_HOST" + +# ---- 1. remove the legacy wrapper that hardcodes --provider ----------------- +# It is not shipped by ug; it forces `ug claude --provider ...`, which a managed +# config now rejects. Backed up, not deleted. +say "Legacy ucode-claude-ide wrapper" +if [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then + mv "$WRAPPER" "$WRAPPER.bak.$TS" && ok "moved aside -> $WRAPPER.bak.$TS" +else + ok "not present" +fi + +# ---- 2. clear the VS Code setting that points Claude at that wrapper -------- +# With the key gone, the extension launches Claude the default way, which routes +# through the OS-managed layer instead of the removed wrapper. +say "VS Code claudeCode.claudeProcessWrapper setting" +for base in "$HOME/Library/Application Support/Code/User" \ + "$HOME/Library/Application Support/Code - Insiders/User"; do + s="$base/settings.json" + [ -f "$s" ] || continue + python3 - "$s" "$TS" <<'PY' +import json, shutil, sys +path, ts = sys.argv[1], sys.argv[2] +key = "claudeCode.claudeProcessWrapper" +try: + with open(path) as fh: + data = json.load(fh) +except Exception: + # JSONC / comments / trailing commas: don't risk corrupting it. + print(f" [!] {path}: not strict JSON; if it sets {key}, remove that line by hand.") + sys.exit(0) +if isinstance(data, dict) and key in data: + shutil.copy2(path, f"{path}.bak.{ts}") + data.pop(key, None) + import os, tempfile + d = os.path.dirname(path) or "." + fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp") + with os.fdopen(fd, "w") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + os.replace(tmp, path) # atomic swap so a crash never leaves settings.json truncated + print(f" [ok] removed {key} from {path} (backup {path}.bak.{ts})") +else: + print(f" [ok] {path}: key not set") +PY +done + +# ---- 3. configure ug (interactive; approve the sudo prompt if asked) -------- +# Writes ug's own gateway config to ~/.claude/ucode-settings.json (no sudo), and, +# on a TTY, the root-owned OS-managed managed-settings.json via sudo. Some orgs gate +# sudo behind an admin "reason" prompt; approve it when it appears. No --agent: the +# workspace's managed config drives which agents get configured (here, Claude only). +say "Running ug configure (approve the sudo/admin prompt if it appears)" +CFG_LOG="$(mktemp -t ug-configure.XXXXXX)" +ug configure --workspace "$WORKSPACE_HOST" 2>&1 | tee "$CFG_LOG" +configure_rc="${PIPESTATUS[0]}" +[ "$configure_rc" -eq 0 ] && ok "ug configure exited cleanly" || warn "ug configure exited with code $configure_rc (read its output above)" + +# ---- 4. verify where each piece landed -------------------------------------- +say "Verification" + +# ug's own config -> makes `ug claude` route (injected via --settings at launch) +if [ -f "$UCODE_SETTINGS" ] && grep -q "ANTHROPIC_BASE_URL" "$UCODE_SETTINGS" 2>/dev/null; then + ok "ug config OK: $UCODE_SETTINGS has the gateway env -> 'ug claude' will route" +else + warn "ug config missing gateway env at $UCODE_SETTINGS -> 'ug claude' may not route. Re-run ug configure." +fi + +# OS-managed file -> makes bare `claude` and VS Code route +if [ -f "$MANAGED_FILE" ]; then + ok "OS-managed settings present: $MANAGED_FILE -> bare 'claude' and VS Code will route" +else + warn "No $MANAGED_FILE was written." + warn "That means the privileged write was declined at the sudo/admin prompt, or the" + warn "directory is locked by MDM. Bare 'claude' / VS Code then route ONLY if your MDM's" + warn "managed-settings.d fragment carries the gateway env. Present drop-ins:" + ls -1 "$MANAGED_DIR"/managed-settings.d/*.json 2>/dev/null | sed 's/^/ /' || echo " (none found)" + warn "Fix: re-run this script by typing it in Terminal and approve the prompt." +fi + +# ---- 4b. write a shareable diagnostics report ------------------------------- +# So a repeat failure can be triaged without another live session. Secrets are +# redacted; the file holds versions, ug configure output, and `ug doctor`. +say "Writing diagnostics report" +{ + echo "=== ug heal report $TS ===" + echo "workspace: $WORKSPACE_HOST" + echo + echo "=== system ===" + uname -a + sw_vers 2>/dev/null + printf 'stdin_is_tty: %s\n' "$([ -t 0 ] && echo yes || echo no)" + echo + echo "=== ug ===" + command -v ug 2>&1 + ug --version 2>&1 + echo + echo "=== legacy wrapper ===" + ls -l "$WRAPPER" "$WRAPPER.bak.$TS" 2>&1 + echo + echo "=== ug configure output (exit $configure_rc) ===" + [ -f "$CFG_LOG" ] && cat "$CFG_LOG" || echo "(no configure log)" + echo + echo "=== ug doctor ===" + if command -v ug >/dev/null 2>&1 && ug doctor --help >/dev/null 2>&1; then ug doctor 2>&1; else echo "(ug doctor unavailable)"; fi + echo + echo "=== ~/.claude/ucode-settings.json ===" + [ -f "$UCODE_SETTINGS" ] && cat "$UCODE_SETTINGS" || echo "(missing)" + echo + echo "=== OS-managed dir: $MANAGED_DIR ===" + ls -la "$MANAGED_DIR" 2>&1 + printf 'managed-settings.json: %s\n' "$([ -f "$MANAGED_FILE" ] && echo present || echo MISSING)" + echo "drop-ins:"; ls -1 "$MANAGED_DIR"/managed-settings.d/*.json 2>&1 +} | redact > "$REPORT" 2>&1 +[ -n "${CFG_LOG:-}" ] && [ -f "$CFG_LOG" ] && rm -f "$CFG_LOG" +ok "diagnostics written to $REPORT (secrets redacted)" + +# ---- 5. next steps ---------------------------------------------------------- +say "Next" +echo " 1. Run: ug claude (first launch opens a browser for Databricks OAuth; that is expected)" +echo " 2. Then test bare 'claude' and Claude in VS Code." +echo " 3. If bare 'claude' still bypasses the gateway, the OS-managed file above is the gap:" +echo " re-run in Terminal and approve the admin prompt, or have your MDM own that file." +echo +echo +echo " If anything above failed, send this file to the Databricks team: $REPORT" +echo " Backups written this run carry the suffix .bak.$TS"