From e0a0844d28b495235de146bf42a5bf7be09a9f1e Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 00:35:24 +1000 Subject: [PATCH 1/7] fix(gateway): provision app model access and remove static pins --- Makefile | 18 ++++- app.py | 23 +++--- app.yaml | 9 +-- configure_gateway_resources.py | 89 +++++++++++++++++++++++ docs/deployment.md | 6 +- setup_claude.py | 27 +++++-- setup_pi.py | 8 +- tests/test_configure_gateway_resources.py | 51 +++++++++++++ tests/test_gateway_discovery.py | 11 ++- tests/test_gateway_models.py | 13 ++-- 10 files changed, 216 insertions(+), 39 deletions(-) create mode 100644 configure_gateway_resources.py create mode 100644 tests/test_configure_gateway_resources.py diff --git a/Makefile b/Makefile index 735e956b..23fa11cb 100644 --- a/Makefile +++ b/Makefile @@ -79,12 +79,12 @@ help: ## Show this help # ── Workflows ──────────────────────────────────────── -deploy: create-app grant-omnigent-host sync deploy-app ## Full deploy (create app, grant Omnigent host IAM, sync, deploy) +deploy: create-app configure-gateway-resources grant-omnigent-host sync deploy-app ## Full deploy (create app, attach Gateway models, grant Omnigent IAM, sync, deploy) @echo "" @echo "Deployment complete! App URL:" @databricks apps get $(APP_NAME) --profile $(PROFILE) --output json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('url','(pending)'))" -redeploy: grant-omnigent-host sync deploy-app ## Redeploy: (re)grant Omnigent host IAM + sync + deploy +redeploy: configure-gateway-resources grant-omnigent-host sync deploy-app ## Redeploy: refresh Gateway models + Omnigent IAM, sync, deploy @echo "" @echo "Redeployment complete!" @@ -206,7 +206,7 @@ configure-git-credential: ## Add a Git credential to the app SP for private repo && echo " Git credential added to SP $$sp_id for '$(APP_NAME)'." \ || echo " git-credentials create failed (credential may already exist for $(GIT_PROVIDER))." -deploy-git: ## Deploy the app from the configured Git ref ($(GIT_REF_TYPE)=$(GIT_REF)) +deploy-git: configure-gateway-resources ## Attach Gateway models, then deploy from configured Git ref ($(GIT_REF_TYPE)=$(GIT_REF)) @echo "==> Deploying '$(APP_NAME)' from Git $(GIT_REF_TYPE)='$(GIT_REF)'..." @databricks apps deploy $(APP_NAME) --profile $(PROFILE) --no-wait \ --json '{"git_source":{"$(GIT_REF_TYPE)":"$(GIT_REF)"}}' @@ -217,6 +217,18 @@ redeploy-git: grant-omnigent-host deploy-git ## (Re)grant Omnigent host IAM, the @echo "" @echo "Git redeployment complete!" +# ── AI Gateway model resources ───────────────────── + +AUTO_CONFIGURE_GATEWAY ?= true + +configure-gateway-resources: ## Grant the app SP CAN_QUERY on READY Foundation Model chat endpoints + @if [ "$(AUTO_CONFIGURE_GATEWAY)" = "true" ]; then \ + echo "==> Configuring AI Gateway model resources for '$(APP_NAME)'..."; \ + python3 configure_gateway_resources.py --profile $(PROFILE) --app $(APP_NAME); \ + else \ + echo "==> AI Gateway resource configuration disabled (AUTO_CONFIGURE_GATEWAY=$(AUTO_CONFIGURE_GATEWAY))."; \ + fi + # ── Omnigent host resources ───────────────────────── # The generic app.yaml resolves workspace-specific Omnigent values at runtime # via valueFrom resource references. This target attaches those resources to diff --git a/app.py b/app.py index 2cbc250f..f4a5984f 100644 --- a/app.py +++ b/app.py @@ -33,7 +33,7 @@ import app_state import enterprise_config from claude_otel import apply_claude_otel_env -from utils import add_1m_context_suffix, ensure_https, get_gateway_host +from utils import ensure_https from token_helper import write_databricks_token_wrapper from pat_rotator import PATRotator from sp_token_broker import ( @@ -784,13 +784,9 @@ def _configure_all_cli_auth(token): claude_dir = os.path.join(home, ".claude") os.makedirs(claude_dir, exist_ok=True) - gateway_host = get_gateway_host() databricks_host = ensure_https(os.environ.get("DATABRICKS_HOST", "").rstrip("/")) - - if gateway_host: - anthropic_base_url = f"{gateway_host}/anthropic" - else: - anthropic_base_url = f"{databricks_host}/serving-endpoints/anthropic" + from gateway_models import pi_base_urls + anthropic_base_url = pi_base_urls(databricks_host)["claude"] # Read-merge-write to preserve env vars from other setup scripts (e.g. setup_mlflow.py) settings_path = os.path.join(claude_dir, "settings.json") @@ -801,8 +797,10 @@ def _configure_all_cli_auth(token): settings = {} settings.setdefault("env", {}) - settings["env"]["ANTHROPIC_MODEL"] = os.environ.get("ANTHROPIC_MODEL", "databricks-claude-opus-4-8") + settings["env"].pop("ANTHROPIC_MODEL", None) settings["env"]["ANTHROPIC_BASE_URL"] = anthropic_base_url + settings["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + settings["env"]["CLAUDE_CODE_USE_GATEWAY"] = "1" # Respect the spec-C apiKeyHelper: when it owns model auth (setup_claude.py # installed the "apiKeyHelper" key), don't re-pin a static token here — the # helper fetches its own per-TTL. Otherwise write the PAT as before. @@ -810,11 +808,8 @@ def _configure_all_cli_auth(token): settings["env"].pop("ANTHROPIC_AUTH_TOKEN", None) else: settings["env"]["ANTHROPIC_AUTH_TOKEN"] = token - # [1m] suffix requests the 1M context window via the gateway (opus/sonnet - # only; see utils.add_1m_context_suffix). Keep in sync with setup_claude.py. - settings["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] = add_1m_context_suffix("databricks-claude-opus-4-8") - settings["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] = add_1m_context_suffix("databricks-claude-sonnet-4-6") - settings["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "databricks-claude-haiku-4-5" + # Model inventory and family defaults are owned by setup_claude.py. Token + # rotation must not replace them with stale static model ids. settings["env"]["ANTHROPIC_CUSTOM_HEADERS"] = "x-databricks-use-coding-agent-mode: true" settings["env"]["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] = "1" if apply_claude_otel_env(settings, token, databricks_host): @@ -834,7 +829,7 @@ def _configure_all_cli_auth(token): # They are idempotent: detect CLI already installed, just write config files env = {**os.environ, "DATABRICKS_TOKEN": token, "CODA_VENV_PYTHON": _venv_python()} - for script in ["setup_pi.py", "setup_codex.py", "setup_opencode.py", "setup_gemini.py", "setup_hermes.py"]: + for script in ["setup_claude.py", "setup_pi.py", "setup_codex.py", "setup_opencode.py", "setup_gemini.py", "setup_hermes.py"]: try: result = subprocess.run( [_venv_python(), script], diff --git a/app.yaml b/app.yaml index d4eb755a..ddc2904b 100644 --- a/app.yaml +++ b/app.yaml @@ -4,12 +4,9 @@ command: env: - name: HOME value: /app/python/source_code - - name: ANTHROPIC_MODEL - value: system.ai.claude-sonnet-5 - # Pi routes to the same /anthropic gateway route as Claude Code; PI_MODEL is - # the serving-endpoint name it addresses (validated against in-geo models). - - name: PI_MODEL - value: system.ai.claude-sonnet-5 + # Claude and Pi discover their model inventory from this workspace at setup + # time. Do not pin ANTHROPIC_MODEL or PI_MODEL here: a static value collapses + # Claude's picker and can select a model the app SP cannot query. - name: GEMINI_MODEL value: system.ai.gemini-3-flash # The setup script discovers system.ai models served via the Responses API diff --git a/configure_gateway_resources.py b/configure_gateway_resources.py new file mode 100644 index 00000000..c3951154 --- /dev/null +++ b/configure_gateway_resources.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Attach available Foundation Model endpoints to a CoDA Databricks App. + +This is the deployment-time half of CoDA's ucode-compatible Gateway setup. +The deploying identity discovers READY chat endpoints; the Apps resource API +then grants the app service principal only CAN_QUERY. Existing resources are +preserved, embeddings are excluded, and repeated runs are idempotent. +""" +from __future__ import annotations + +import argparse +from collections.abc import Iterable + +from databricks.sdk import WorkspaceClient +from databricks.sdk.service.apps import App, AppResource + +_RESOURCE_PREFIX = "gateway-model-" + + +def discover_gateway_endpoint_names(endpoints: Iterable[object]) -> list[str]: + """Return READY Foundation Model chat endpoint names.""" + names: set[str] = set() + for endpoint in endpoints: + data = endpoint.as_dict() if hasattr(endpoint, "as_dict") else endpoint + if not isinstance(data, dict) or data.get("task") != "llm/v1/chat": + continue + state = data.get("state") or {} + if state.get("ready") != "READY": + continue + entities = ((data.get("config") or {}).get("served_entities") or []) + if not any( + isinstance(entity, dict) + and str(entity.get("entity_name") or "").startswith("system.ai.") + for entity in entities + ): + continue + name = data.get("name") + if isinstance(name, str) and name.startswith("databricks-"): + names.add(name) + return sorted(names) + + +def merge_resources(current: list[dict], endpoint_names: list[str]) -> list[AppResource]: + """Preserve non-Gateway resources and replace our managed endpoint set.""" + by_name = { + resource["name"]: resource + for resource in current + if isinstance(resource, dict) + and isinstance(resource.get("name"), str) + and not resource["name"].startswith(_RESOURCE_PREFIX) + } + for endpoint_name in endpoint_names: + resource_name = _RESOURCE_PREFIX + endpoint_name.removeprefix("databricks-") + by_name[resource_name] = { + "name": resource_name, + "description": "ucode-compatible AI Gateway model access", + "serving_endpoint": { + "name": endpoint_name, + "permission": "CAN_QUERY", + }, + } + return [AppResource.from_dict(resource) for resource in by_name.values()] + + +def configure(profile: str, app_name: str) -> list[str]: + w = WorkspaceClient(profile=profile) + app = w.apps.get(app_name) + endpoint_names = discover_gateway_endpoint_names(w.serving_endpoints.list()) + if not endpoint_names: + raise RuntimeError("no READY Foundation Model chat endpoints were discovered") + current = [resource.as_dict() for resource in (app.resources or [])] + resources = merge_resources(current, endpoint_names) + w.apps.create_update(app_name, "resources", app=App(name=app_name, resources=resources)) + return endpoint_names + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--profile", required=True) + parser.add_argument("--app", required=True) + args = parser.parse_args() + endpoints = configure(args.profile, args.app) + print(f"Attached {len(endpoints)} READY chat endpoints to {args.app} with CAN_QUERY:") + for endpoint in endpoints: + print(f" {endpoint}") + + +if __name__ == "__main__": + main() diff --git a/docs/deployment.md b/docs/deployment.md index 40849f0b..95d23d01 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,7 +20,7 @@ The app pulls the code directly from Git. To update later, just re-deploy — it > **Note:** On first startup, the app automatically removes the template's `.git` history and reinitializes a clean, remote-free git repo. This prevents accidental pushes back to the template repo from the in-browser terminal. -> **Optional (Highly Recommended):** If you use [Databricks AI Gateway](https://docs.databricks.com/aws/en/ai-gateway/), also add `DATABRICKS_GATEWAY_HOST` as a secret or environment variable. Otherwise the app falls back to direct model serving endpoints. +> **AI Gateway setup:** CLI deployment targets automatically discover READY Foundation Model chat endpoints and attach them to the app with `CAN_QUERY`. This is required because Gateway visibility and invocation are identity-scoped; a model visible to the deployer may otherwise return a masked 404 to the app service principal. Set `AUTO_CONFIGURE_GATEWAY=false` only when permissions are managed externally. ## Alternative: Deploy with CLI @@ -75,8 +75,8 @@ make configure-git APP_NAME=coda-04 PROFILE= gh auth token | make configure-git-credential APP_NAME=coda-04 PROFILE= # 3. Deploy from a ref (branch | tag | commit) -make deploy-git APP_NAME=coda-04 PROFILE= GIT_REF=main -make redeploy-git APP_NAME=coda-04 PROFILE= GIT_REF=main # + (re)grant Omnigent IAM +make deploy-git APP_NAME=coda-04 PROFILE= GIT_REF=main # refresh Gateway CAN_QUERY resources +make redeploy-git APP_NAME=coda-04 PROFILE= GIT_REF=main # + (re)grant Omnigent IAM ``` Overridable vars: `GIT_URL`, `GIT_PROVIDER` (`gitHub`, `gitLab`, …), `GIT_REF`, diff --git a/setup_claude.py b/setup_claude.py index 5b1fc663..65618087 100644 --- a/setup_claude.py +++ b/setup_claude.py @@ -83,17 +83,32 @@ def _write_apikey_helper(claude_dir: Path) -> Path: served = catalog["anthropic"] print(f"Discovered {len(served)} anthropic-dialect model services") - requested_model = os.environ.get("ANTHROPIC_MODEL", "system.ai.claude-sonnet-5") - sonnet_model = family_model("sonnet", served, fallback=requested_model) + if not served: + print( + "ERROR: the CoDA service principal discovered no Anthropic-dialect " + "Gateway models; attach serving-endpoint resources with CAN_QUERY" + ) + raise SystemExit(1) + requested_model = os.environ.get("ANTHROPIC_MODEL", "").strip() + sonnet_model = family_model("sonnet", served, fallback=served[0]) opus_model = family_model("opus", served, fallback=sonnet_model) haiku_model = family_model("haiku", served, fallback=sonnet_model) - active_model = requested_model if requested_model in served else sonnet_model - if served and active_model != requested_model: - print(f"ANTHROPIC_MODEL={requested_model} not served here, using {active_model}") + if requested_model and requested_model not in served: + print(f"Ignoring unavailable ANTHROPIC_MODEL={requested_model}") settings.setdefault("env", {}) - settings["env"]["ANTHROPIC_MODEL"] = active_model + # Match ucode's contract: native Gateway discovery plus modelOverrides owns + # the picker. ANTHROPIC_MODEL must remain absent or Claude collapses the + # picker to that one static row. + settings["env"].pop("ANTHROPIC_MODEL", None) settings["env"]["ANTHROPIC_BASE_URL"] = anthropic_base_url + settings["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + settings["env"]["CLAUDE_CODE_USE_GATEWAY"] = "1" + settings["modelOverrides"] = { + model.removeprefix("system.ai."): model + for model in served + if model.startswith("system.ai.claude-") + } # Token source (spec C): by default install an apiKeyHelper that fetches a # fresh token per-TTL -- Claude Code re-runs it on the interval below, so diff --git a/setup_pi.py b/setup_pi.py index c1fa3928..29e1b775 100644 --- a/setup_pi.py +++ b/setup_pi.py @@ -105,7 +105,13 @@ base_urls = pi_base_urls(host) catalog = discover_model_catalog(host, token) -claude_models = catalog["anthropic"] or [pi_model] +claude_models = catalog["anthropic"] +if not claude_models: + print( + "ERROR: the CoDA service principal discovered no Anthropic-dialect " + "Gateway models; attach serving-endpoint resources with CAN_QUERY" + ) + raise SystemExit(1) active_model = preferred_model(pi_model, claude_models) print(f"Using workspace AI Gateway: {base_urls['claude']}") print( diff --git a/tests/test_configure_gateway_resources.py b/tests/test_configure_gateway_resources.py new file mode 100644 index 00000000..ced456a0 --- /dev/null +++ b/tests/test_configure_gateway_resources.py @@ -0,0 +1,51 @@ +from configure_gateway_resources import discover_gateway_endpoint_names, merge_resources + + +def _endpoint(name, *, task="llm/v1/chat", ready="READY", entity=None): + return { + "name": name, + "task": task, + "state": {"ready": ready}, + "config": { + "served_entities": [ + {"entity_name": entity or f"system.ai.{name}"} + ] + }, + } + + +def test_discovers_only_ready_foundation_model_chat_endpoints(): + endpoints = [ + _endpoint("databricks-claude-sonnet-5"), + _endpoint("databricks-gpt-oss-120b"), + _endpoint("databricks-qwen3-embedding", task="llm/v1/embeddings"), + _endpoint("databricks-not-ready", ready="NOT_READY"), + _endpoint("custom-chat", entity="catalog.schema.model"), + ] + + assert discover_gateway_endpoint_names(endpoints) == [ + "databricks-claude-sonnet-5", + "databricks-gpt-oss-120b", + ] + + +def test_merge_preserves_unrelated_resources_and_replaces_managed_set(): + current = [ + {"name": "challenge", "secret": {"scope": "s", "key": "k", "permission": "READ"}}, + { + "name": "gateway-model-stale", + "serving_endpoint": {"name": "databricks-stale", "permission": "CAN_QUERY"}, + }, + ] + + merged = [resource.as_dict() for resource in merge_resources( + current, ["databricks-claude-sonnet-5"] + )] + by_name = {resource["name"]: resource for resource in merged} + + assert "challenge" in by_name + assert "gateway-model-stale" not in by_name + assert by_name["gateway-model-claude-sonnet-5"]["serving_endpoint"] == { + "name": "databricks-claude-sonnet-5", + "permission": "CAN_QUERY", + } diff --git a/tests/test_gateway_discovery.py b/tests/test_gateway_discovery.py index 3d24a44d..40f76092 100644 --- a/tests/test_gateway_discovery.py +++ b/tests/test_gateway_discovery.py @@ -157,7 +157,7 @@ def _run_setup(self, script_name, tmp_path, env_overrides=None): "DATABRICKS_TOKEN": "dapi_test_token", "DATABRICKS_WORKSPACE_ID": "1234567890123456", "PATH": os.environ.get("PATH", ""), - "PYTHONPATH": str(SETUP_DIR), + "PYTHONPATH": os.pathsep.join((str(tmp_path), str(SETUP_DIR))), # Pre-resolve gateway so subprocess skips the network probe "_GATEWAY_RESOLVED": "", "CODA_SKIP_CLAUDE_INSTALL": "true", @@ -167,6 +167,15 @@ def _run_setup(self, script_name, tmp_path, env_overrides=None): if env_overrides: env.update(env_overrides) + # Keep this subprocess test hermetic: production now fails closed when + # discovery returns no models, so provide a deterministic catalog rather + # than relying on the old static Sonnet fallback. + (tmp_path / "sitecustomize.py").write_text( + "import gateway_models\n" + "gateway_models.discover_model_catalog = lambda *_a, **_k: {" + "'anthropic':['system.ai.claude-sonnet-5','system.ai.claude-opus-5']," + "'anthropic_specs':[], 'openai':[], 'gemini':[], 'oss':[], 'oss_specs':[]}\n" + ) # Create required dirs (tmp_path / ".claude").mkdir(exist_ok=True) diff --git a/tests/test_gateway_models.py b/tests/test_gateway_models.py index e26c834f..60a9863b 100644 --- a/tests/test_gateway_models.py +++ b/tests/test_gateway_models.py @@ -348,7 +348,8 @@ def test_app_yaml_enables_the_default_harnesses(): default now that compatible endpoints are available; Hermes remains opt-in. """ source = (Path(__file__).parents[1] / "app.yaml").read_text() - assert source.count("value: system.ai.claude-sonnet-5") == 2 + assert "name: ANTHROPIC_MODEL" not in source + assert "name: PI_MODEL" not in source assert "value: system.ai.gpt-5" in source assert "value: system.ai.gemini-3-flash" in source assert '- name: ENABLE_CLAUDE\n value: "true"' in source @@ -393,10 +394,10 @@ def test_default_model_is_sonnet_not_opus(monkeypatch): assert gm.preferred_model("system.ai.claude-opus-5", models) == "system.ai.claude-opus-5" -def test_app_yaml_defaults_the_pickers_to_sonnet(monkeypatch): +def test_app_yaml_does_not_pin_claude_or_pi_picker_models(monkeypatch): source = (Path(__file__).parents[1] / "app.yaml").read_text() - assert "value: system.ai.claude-sonnet-5" in source - assert "value: system.ai.claude-opus-5" not in source + assert "name: ANTHROPIC_MODEL" not in source + assert "name: PI_MODEL" not in source assert 'name: ENABLE_FABLE_MODELS' in source @@ -495,7 +496,9 @@ def test_setup_claude_uses_the_workspace_gateway_and_discovered_models(): assert "pick_in_geo_model" not in source assert 'pi_base_urls(databricks_host)["claude"]' in source assert "discover_model_catalog" in source - assert '"ANTHROPIC_MODEL", "system.ai.claude-sonnet-5"' in source + assert 'settings["env"].pop("ANTHROPIC_MODEL", None)' in source + assert 'settings["env"]["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"' in source + assert 'settings["env"]["CLAUDE_CODE_USE_GATEWAY"] = "1"' in source def test_family_model_picks_newest_and_falls_back(): From 93fcb036fde6592a1046430b9edd4c9c299de107 Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 00:36:52 +1000 Subject: [PATCH 2/7] fix(deploy): bound Gateway resource names --- configure_gateway_resources.py | 12 ++++++++++-- tests/test_configure_gateway_resources.py | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/configure_gateway_resources.py b/configure_gateway_resources.py index c3951154..c6b49bb3 100644 --- a/configure_gateway_resources.py +++ b/configure_gateway_resources.py @@ -9,12 +9,20 @@ from __future__ import annotations import argparse +import hashlib from collections.abc import Iterable from databricks.sdk import WorkspaceClient from databricks.sdk.service.apps import App, AppResource -_RESOURCE_PREFIX = "gateway-model-" +_RESOURCE_PREFIX = "coda-gw-" + + +def gateway_resource_name(endpoint_name: str) -> str: + """Return a deterministic Apps resource name within the 30-char limit.""" + slug = endpoint_name.removeprefix("databricks-").replace("_", "-") + digest = hashlib.sha256(endpoint_name.encode()).hexdigest()[:8] + return f"{_RESOURCE_PREFIX}{slug[:13]}-{digest}" def discover_gateway_endpoint_names(endpoints: Iterable[object]) -> list[str]: @@ -50,7 +58,7 @@ def merge_resources(current: list[dict], endpoint_names: list[str]) -> list[AppR and not resource["name"].startswith(_RESOURCE_PREFIX) } for endpoint_name in endpoint_names: - resource_name = _RESOURCE_PREFIX + endpoint_name.removeprefix("databricks-") + resource_name = gateway_resource_name(endpoint_name) by_name[resource_name] = { "name": resource_name, "description": "ucode-compatible AI Gateway model access", diff --git a/tests/test_configure_gateway_resources.py b/tests/test_configure_gateway_resources.py index ced456a0..4ad65c40 100644 --- a/tests/test_configure_gateway_resources.py +++ b/tests/test_configure_gateway_resources.py @@ -1,4 +1,8 @@ -from configure_gateway_resources import discover_gateway_endpoint_names, merge_resources +from configure_gateway_resources import ( + discover_gateway_endpoint_names, + gateway_resource_name, + merge_resources, +) def _endpoint(name, *, task="llm/v1/chat", ready="READY", entity=None): @@ -29,11 +33,18 @@ def test_discovers_only_ready_foundation_model_chat_endpoints(): ] +def test_gateway_resource_names_are_stable_and_fit_apps_limit(): + name = gateway_resource_name("databricks-claude-sonnet-5") + assert name == gateway_resource_name("databricks-claude-sonnet-5") + assert len(name) <= 30 + assert name != gateway_resource_name("databricks-claude-sonnet-4-5") + + def test_merge_preserves_unrelated_resources_and_replaces_managed_set(): current = [ {"name": "challenge", "secret": {"scope": "s", "key": "k", "permission": "READ"}}, { - "name": "gateway-model-stale", + "name": "coda-gw-stale", "serving_endpoint": {"name": "databricks-stale", "permission": "CAN_QUERY"}, }, ] @@ -44,8 +55,9 @@ def test_merge_preserves_unrelated_resources_and_replaces_managed_set(): by_name = {resource["name"]: resource for resource in merged} assert "challenge" in by_name - assert "gateway-model-stale" not in by_name - assert by_name["gateway-model-claude-sonnet-5"]["serving_endpoint"] == { + assert "coda-gw-stale" not in by_name + resource_name = gateway_resource_name("databricks-claude-sonnet-5") + assert by_name[resource_name]["serving_endpoint"] == { "name": "databricks-claude-sonnet-5", "permission": "CAN_QUERY", } From b9cb38b036fc5b3ff45ae275bb70b13ea12f98d6 Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 00:43:02 +1000 Subject: [PATCH 3/7] fix(setup): expose bounded agent configuration diagnostics --- app.py | 14 ++++++++++++++ tests/test_setup_pi.py | 29 +++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index f4a5984f..e0906131 100644 --- a/app.py +++ b/app.py @@ -301,9 +301,23 @@ def _run_step(step_id, command): if result.returncode == 0: if step_id == "dbcli" and os.environ.get(BROKER_URL_ENV): _ensure_broker_cli_wrapper() + if step_id in {"claude", "pi"}: + safe_markers = ( + "Using workspace AI Gateway:", + "Discovered ", + "Pi configured:", + "Claude configured:", + ) + summary = [ + line.strip() + for line in result.stdout.splitlines() + if line.strip().startswith(safe_markers) + ] + logger.info("%s setup: %s", step_id, " | ".join(summary) or "complete") _update_step(step_id, status="complete", completed_at=time.time()) else: err = result.stderr.strip() or result.stdout.strip() or "Unknown error" + logger.error("%s setup failed (rc=%s): %s", step_id, result.returncode, err[-500:]) _update_step(step_id, status="error", completed_at=time.time(), error=err[:500]) except subprocess.TimeoutExpired: _update_step(step_id, status="error", completed_at=time.time(), error="Timed out after 300s") diff --git a/tests/test_setup_pi.py b/tests/test_setup_pi.py index 96dcd34e..a2322ef0 100644 --- a/tests/test_setup_pi.py +++ b/tests/test_setup_pi.py @@ -1,9 +1,9 @@ """Tests for setup_pi.py — verify the Pi models.json config is written correctly. Runs the real setup_pi.py as a subprocess against a fake HOME. A fake `pi` -binary is pre-seeded so the npm install is skipped. Model-services discovery -fails closed against the fake workspace, so only the requested system.ai model -is configured and the write path remains deterministic without inference. +binary is pre-seeded so the npm install is skipped. A sitecustomize fixture +provides a deterministic catalogue; production fails closed when discovery is +empty rather than writing an invalid fallback model. """ import json @@ -30,11 +30,18 @@ def _seed_fake_pi_binary(home: Path): def run_setup_pi(tmp_path, env_overrides=None): + (tmp_path / "sitecustomize.py").write_text( + "import gateway_models\n" + "gateway_models.discover_model_catalog = lambda *_a, **_k: {" + "'anthropic':['system.ai.claude-sonnet-5','system.ai.claude-opus-5']," + "'anthropic_specs':[], 'openai':[], 'gemini':[], 'oss':[], 'oss_specs':[]}\n" + ) env = { "HOME": str(tmp_path), "DATABRICKS_HOST": "https://workspace.example.test", "DATABRICKS_TOKEN": "dapi_test_token", "PATH": os.environ.get("PATH", ""), + "PYTHONPATH": os.pathsep.join((str(tmp_path), str(SETUP_PI.parent))), "_GATEWAY_RESOLVED": "", } if env_overrides: @@ -69,15 +76,21 @@ def test_writes_databricks_claude_provider_schema(self, tmp_path): assert provider["baseUrl"] == "https://workspace.example.test/ai-gateway/anthropic" assert ".ai-gateway." not in provider["baseUrl"] assert provider["compat"] == {"supportsEagerToolInputStreaming": False} - assert [m["id"] for m in provider["models"]] == ["system.ai.claude-opus-5"] + assert [m["id"] for m in provider["models"]] == [ + "system.ai.claude-sonnet-5", + "system.ai.claude-opus-5", + ] # Limits and thinking come from the shared Claude version policy: opus 5 # is a >= 4.6 tier, so 1M/128k with adaptive thinking. Without # forceAdaptiveThinking Pi sends `thinking: {type: "enabled"}` and the # endpoint answers 400 "thinking.type.enabled is not supported". - assert provider["models"][0]["reasoning"] is True - assert provider["models"][0]["compat"] == {"forceAdaptiveThinking": True} - assert provider["models"][0]["contextWindow"] == 1_000_000 - assert provider["models"][0]["maxTokens"] == 128_000 + opus = {model["id"]: model for model in provider["models"]}[ + "system.ai.claude-opus-5" + ] + assert opus["reasoning"] is True + assert opus["compat"] == {"forceAdaptiveThinking": True} + assert opus["contextWindow"] == 1_000_000 + assert opus["maxTokens"] == 128_000 def test_models_json_is_chmod_600(self, tmp_path): _seed_fake_pi_binary(tmp_path) From 44f4754e18215b49e081566647b307db9bef1194 Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 00:46:25 +1000 Subject: [PATCH 4/7] fix(gateway): union app-visible endpoints with UC models --- gateway_models.py | 14 ++++++++++++++ tests/test_gateway_models.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/gateway_models.py b/gateway_models.py index 34d13498..2a3d827f 100644 --- a/gateway_models.py +++ b/gateway_models.py @@ -374,6 +374,20 @@ def discover_model_catalog(workspace: str, token: str) -> dict[str, Any]: """ ids = list_model_services(workspace, token) metadata = fetch_foundation_models(workspace, token) + # Match ucode's Gateway-only union: an app SP may have CAN_QUERY through + # attached serving-endpoint resources while lacking UC browse permission. + # In that case model-services is empty, but the app-SP-visible Foundation + # Models catalogue is authoritative. Project endpoint IDs back to the + # routable system.ai aliases used in request bodies. + gateway_ids = { + "system.ai." + canonical + for canonical, entry in metadata.items() + if entry.get("gateway_v2") is True + and isinstance(entry.get("id"), str) + and entry["id"].startswith("databricks-") + and not any(marker in canonical for marker in NON_CHAT_MARKERS) + } + ids = sorted(set(ids) | gateway_ids) # Every served version of each offered family, newest first — not just the # newest per family. A picker that lists one model per family cannot switch # to an older opus the workspace still serves, which is the whole point of diff --git a/tests/test_gateway_models.py b/tests/test_gateway_models.py index 60a9863b..75fe89cf 100644 --- a/tests/test_gateway_models.py +++ b/tests/test_gateway_models.py @@ -461,6 +461,31 @@ def test_picker_lists_only_models_the_gateway_serves_for_that_dialect(monkeypatc assert catalog["gemini"] == ["system.ai.gemini-3-pro"] +def test_gateway_catalog_supplies_models_when_app_sp_cannot_browse_uc(monkeypatch): + """CAN_QUERY resources work without broad model-services browse grants.""" + monkeypatch.setattr(gm, "list_model_services", lambda *_a, **_kw: []) + monkeypatch.setattr( + gm, + "_get_json", + lambda *_a, **_kw: _fm_payload( + [ + ("databricks-claude-sonnet-5", ["anthropic/v1/messages"]), + ("databricks-claude-opus-5", ["anthropic/v1/messages"]), + ("databricks-gpt-oss-120b", ["mlflow/v1/chat/completions"]), + ("databricks-qwen3-embedding", ["mlflow/v1/embeddings"]), + ] + ), + ) + + catalog = gm.discover_model_catalog(WORKSPACE, "tok") + + assert catalog["anthropic"] == [ + "system.ai.claude-sonnet-5", + "system.ai.claude-opus-5", + ] + assert catalog["oss"] == ["system.ai.gpt-oss-120b"] + + def test_unknown_models_are_kept_so_a_picker_never_collapses(monkeypatch): """Discovery failure must not silently reduce the picker to nothing.""" ids = ["system.ai.claude-sonnet-5", "system.ai.claude-opus-5"] From 8d6e6f4d685adf1d9fbdbaadac93bbe85c3b5625 Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 00:51:01 +1000 Subject: [PATCH 5/7] fix(gateway): publish app-visible model inventory --- app.yaml | 8 +++-- configure_gateway_resources.py | 37 +++++++++++++++++++++-- gateway_models.py | 32 +++++++++++++++++--- tests/test_configure_gateway_resources.py | 14 ++++++++- tests/test_gateway_models.py | 14 +++++++++ 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/app.yaml b/app.yaml index ddc2904b..d9fbcf4e 100644 --- a/app.yaml +++ b/app.yaml @@ -4,8 +4,12 @@ command: env: - name: HOME value: /app/python/source_code - # Claude and Pi discover their model inventory from this workspace at setup - # time. Do not pin ANTHROPIC_MODEL or PI_MODEL here: a static value collapses + # The deploy-time Gateway configurator writes the exact CAN_QUERY inventory + # into this secret resource. Runtime unions it with live discovery because an + # app SP can invoke attached endpoints without UC model-services browse access. + - name: CODA_GATEWAY_MODEL_CATALOG + valueFrom: gateway-model-catalog + # Do not pin ANTHROPIC_MODEL or PI_MODEL here: a static value collapses # Claude's picker and can select a model the app SP cannot query. - name: GEMINI_MODEL value: system.ai.gemini-3-flash diff --git a/configure_gateway_resources.py b/configure_gateway_resources.py index c6b49bb3..f1b90db6 100644 --- a/configure_gateway_resources.py +++ b/configure_gateway_resources.py @@ -10,12 +10,16 @@ import argparse import hashlib +import json from collections.abc import Iterable from databricks.sdk import WorkspaceClient +from databricks.sdk.errors import ResourceAlreadyExists from databricks.sdk.service.apps import App, AppResource _RESOURCE_PREFIX = "coda-gw-" +_CATALOG_RESOURCE = "gateway-model-catalog" +_CATALOG_SCOPE = "coda-gateway" def gateway_resource_name(endpoint_name: str) -> str: @@ -48,7 +52,14 @@ def discover_gateway_endpoint_names(endpoints: Iterable[object]) -> list[str]: return sorted(names) -def merge_resources(current: list[dict], endpoint_names: list[str]) -> list[AppResource]: +def endpoint_model_id(endpoint_name: str) -> str: + """Map a Foundation Model endpoint name to its routable request model ID.""" + return "system.ai." + endpoint_name.removeprefix("databricks-") + + +def merge_resources( + current: list[dict], endpoint_names: list[str], *, catalog_secret_key: str +) -> list[AppResource]: """Preserve non-Gateway resources and replace our managed endpoint set.""" by_name = { resource["name"]: resource @@ -67,6 +78,15 @@ def merge_resources(current: list[dict], endpoint_names: list[str]) -> list[AppR "permission": "CAN_QUERY", }, } + by_name[_CATALOG_RESOURCE] = { + "name": _CATALOG_RESOURCE, + "description": "Gateway models granted to this CoDA app", + "secret": { + "scope": _CATALOG_SCOPE, + "key": catalog_secret_key, + "permission": "READ", + }, + } return [AppResource.from_dict(resource) for resource in by_name.values()] @@ -76,8 +96,21 @@ def configure(profile: str, app_name: str) -> list[str]: endpoint_names = discover_gateway_endpoint_names(w.serving_endpoints.list()) if not endpoint_names: raise RuntimeError("no READY Foundation Model chat endpoints were discovered") + catalog_secret_key = f"{app_name}-model-catalog" + try: + w.secrets.create_scope(_CATALOG_SCOPE) + except ResourceAlreadyExists: + pass + model_ids = [endpoint_model_id(name) for name in endpoint_names] + w.secrets.put_secret( + _CATALOG_SCOPE, + catalog_secret_key, + string_value=json.dumps(model_ids, separators=(",", ":")), + ) current = [resource.as_dict() for resource in (app.resources or [])] - resources = merge_resources(current, endpoint_names) + resources = merge_resources( + current, endpoint_names, catalog_secret_key=catalog_secret_key + ) w.apps.create_update(app_name, "resources", app=App(name=app_name, resources=resources)) return endpoint_names diff --git a/gateway_models.py b/gateway_models.py index 2a3d827f..94e16e61 100644 --- a/gateway_models.py +++ b/gateway_models.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json import os import re from typing import Any @@ -365,6 +366,26 @@ def discover_oss_specs( return [specs[key] for key in sorted(specs)] +def configured_model_ids() -> list[str]: + """Return the deployment-time inventory attached with CAN_QUERY.""" + raw = os.environ.get("CODA_GATEWAY_MODEL_CATALOG", "").strip() + if not raw: + return [] + try: + values = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(values, list): + return [] + return sorted( + { + value.strip() + for value in values + if isinstance(value, str) and value.strip().startswith("system.ai.") + } + ) + + def discover_model_catalog(workspace: str, token: str) -> dict[str, Any]: """Bucket current model services using ucode's provider precedence. @@ -387,7 +408,7 @@ def discover_model_catalog(workspace: str, token: str) -> dict[str, Any]: and entry["id"].startswith("databricks-") and not any(marker in canonical for marker in NON_CHAT_MARKERS) } - ids = sorted(set(ids) | gateway_ids) + ids = sorted(set(ids) | gateway_ids | set(configured_model_ids())) # Every served version of each offered family, newest first — not just the # newest per family. A picker that lists one model per family cannot switch # to an older opus the workspace still serves, which is the whole point of @@ -422,12 +443,15 @@ def discover_model_catalog(workspace: str, token: str) -> dict[str, Any]: for model in ids: canonical = _canonical(model) spec = specs_by_id.get(canonical) - if spec is None and any(family in canonical for family in OSS_STATIC_FAMILIES): + if spec is None and ( + any(family in canonical for family in OSS_STATIC_FAMILIES) + or canonical in OSS_OUTPUT_LIMITS + ): spec = { "id": model, - "reasoning": True, + "reasoning": canonical.startswith(("gpt-oss-", "kimi-", "glm-")), "context_window": 1_000_000 if "glm-5-2" in canonical else 128_000, - "max_tokens": 65_536 if "kimi" in canonical or "glm-5-2" in canonical else 8_192, + "max_tokens": OSS_OUTPUT_LIMITS.get(canonical, 8_192), } if spec is not None: oss.append(model) diff --git a/tests/test_configure_gateway_resources.py b/tests/test_configure_gateway_resources.py index 4ad65c40..5026952b 100644 --- a/tests/test_configure_gateway_resources.py +++ b/tests/test_configure_gateway_resources.py @@ -1,5 +1,6 @@ from configure_gateway_resources import ( discover_gateway_endpoint_names, + endpoint_model_id, gateway_resource_name, merge_resources, ) @@ -33,6 +34,10 @@ def test_discovers_only_ready_foundation_model_chat_endpoints(): ] +def test_endpoint_names_map_to_routable_model_ids(): + assert endpoint_model_id("databricks-claude-sonnet-5") == "system.ai.claude-sonnet-5" + + def test_gateway_resource_names_are_stable_and_fit_apps_limit(): name = gateway_resource_name("databricks-claude-sonnet-5") assert name == gateway_resource_name("databricks-claude-sonnet-5") @@ -50,7 +55,9 @@ def test_merge_preserves_unrelated_resources_and_replaces_managed_set(): ] merged = [resource.as_dict() for resource in merge_resources( - current, ["databricks-claude-sonnet-5"] + current, + ["databricks-claude-sonnet-5"], + catalog_secret_key="coda-model-catalog", )] by_name = {resource["name"]: resource for resource in merged} @@ -61,3 +68,8 @@ def test_merge_preserves_unrelated_resources_and_replaces_managed_set(): "name": "databricks-claude-sonnet-5", "permission": "CAN_QUERY", } + assert by_name["gateway-model-catalog"]["secret"] == { + "scope": "coda-gateway", + "key": "coda-model-catalog", + "permission": "READ", + } diff --git a/tests/test_gateway_models.py b/tests/test_gateway_models.py index 75fe89cf..64addaf3 100644 --- a/tests/test_gateway_models.py +++ b/tests/test_gateway_models.py @@ -461,6 +461,20 @@ def test_picker_lists_only_models_the_gateway_serves_for_that_dialect(monkeypatc assert catalog["gemini"] == ["system.ai.gemini-3-pro"] +def test_deployment_catalog_supplies_models_when_app_sp_cannot_list_them(monkeypatch): + monkeypatch.setenv( + "CODA_GATEWAY_MODEL_CATALOG", + '["system.ai.claude-sonnet-5","system.ai.gpt-oss-120b"]', + ) + monkeypatch.setattr(gm, "list_model_services", lambda *_a, **_kw: []) + monkeypatch.setattr(gm, "fetch_foundation_models", lambda *_a, **_kw: {}) + + catalog = gm.discover_model_catalog(WORKSPACE, "tok") + + assert catalog["anthropic"] == ["system.ai.claude-sonnet-5"] + assert catalog["oss"] == ["system.ai.gpt-oss-120b"] + + def test_gateway_catalog_supplies_models_when_app_sp_cannot_browse_uc(monkeypatch): """CAN_QUERY resources work without broad model-services browse grants.""" monkeypatch.setattr(gm, "list_model_services", lambda *_a, **_kw: []) From b29fd80f2295e99f74bcd4d685dc0e98c03709cb Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 01:04:27 +1000 Subject: [PATCH 6/7] fix(deploy): grant Unity model-service execution --- Makefile | 2 +- configure_gateway_resources.py | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 23fa11cb..5cfc95bd 100644 --- a/Makefile +++ b/Makefile @@ -221,7 +221,7 @@ redeploy-git: grant-omnigent-host deploy-git ## (Re)grant Omnigent host IAM, the AUTO_CONFIGURE_GATEWAY ?= true -configure-gateway-resources: ## Grant the app SP CAN_QUERY on READY Foundation Model chat endpoints +configure-gateway-resources: ## Grant app SP UC EXECUTE (v3) + CAN_QUERY (legacy) on READY chat models @if [ "$(AUTO_CONFIGURE_GATEWAY)" = "true" ]; then \ echo "==> Configuring AI Gateway model resources for '$(APP_NAME)'..."; \ python3 configure_gateway_resources.py --profile $(PROFILE) --app $(APP_NAME); \ diff --git a/configure_gateway_resources.py b/configure_gateway_resources.py index f1b90db6..41c850dd 100644 --- a/configure_gateway_resources.py +++ b/configure_gateway_resources.py @@ -16,6 +16,7 @@ from databricks.sdk import WorkspaceClient from databricks.sdk.errors import ResourceAlreadyExists from databricks.sdk.service.apps import App, AppResource +from databricks.sdk.service.catalog import PermissionsChange, Privilege _RESOURCE_PREFIX = "coda-gw-" _CATALOG_RESOURCE = "gateway-model-catalog" @@ -102,6 +103,25 @@ def configure(profile: str, app_name: str) -> list[str]: except ResourceAlreadyExists: pass model_ids = [endpoint_model_id(name) for name in endpoint_names] + principal = app.service_principal_client_id + if not principal: + raise RuntimeError(f"app {app_name} has no service principal client ID") + w.grants.update( + "catalog", + "system", + changes=[PermissionsChange(principal=principal, add=[Privilege.USE_CATALOG])], + ) + w.grants.update( + "schema", + "system.ai", + changes=[PermissionsChange(principal=principal, add=[Privilege.USE_SCHEMA])], + ) + for model_id in model_ids: + w.grants.update( + "model_service", + model_id, + changes=[PermissionsChange(principal=principal, add=[Privilege.EXECUTE])], + ) w.secrets.put_secret( _CATALOG_SCOPE, catalog_secret_key, @@ -121,7 +141,10 @@ def main() -> None: parser.add_argument("--app", required=True) args = parser.parse_args() endpoints = configure(args.profile, args.app) - print(f"Attached {len(endpoints)} READY chat endpoints to {args.app} with CAN_QUERY:") + print( + f"Configured {len(endpoints)} READY chat models for {args.app} " + "(UC EXECUTE + legacy CAN_QUERY):" + ) for endpoint in endpoints: print(f" {endpoint}") From 8530f8259303e31721638cfc547b906c34244f84 Mon Sep 17 00:00:00 2001 From: CoDA PR triage Date: Wed, 2 Sep 2026 01:18:56 +1000 Subject: [PATCH 7/7] docs(setup): surface required Gateway model grants --- README.md | 6 +++--- app.py | 23 ++++++++++++++++++++++- docs/deployment.md | 2 +- static/index.html | 19 ++++++++++++++++++- tests/test_auth_enforcement.py | 19 +++++++++++++++++++ tests/test_cli_token_rotation.py | 3 --- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 08d57649..d1e7dbb9 100644 --- a/README.md +++ b/README.md @@ -261,10 +261,10 @@ Beyond boot registration, the host can be driven at runtime: 1. Click [**Use this template**](https://github.com/datasciencemonkey/coding-agents-databricks-apps/generate) to create your own repo 2. Go to **Databricks → Apps → Create App** 3. Choose **Custom App** and connect your new repo -4. Deploy -5. Open the app — paste a short-lived PAT when prompted on first terminal session +4. From your checkout, run `make configure-gateway-resources PROFILE= APP_NAME=` +5. Deploy, then open the app and paste a short-lived PAT if prompted -That's it. No secrets to configure, no pre-deployment setup. +> **Required:** a plain Apps UI deployment cannot grant Unity AI Gateway v3 model-service privileges. The configuration target grants the app SP `USE CATALOG`, `USE SCHEMA`, and least-privilege `EXECUTE`; without it Pi and Claude report `404 '' does not exist`. The repository's normal `make deploy`, `make redeploy`, and `make deploy-git` workflows run it automatically. [→ Full deployment guide](docs/deployment.md) — environment variables, gateway config, and advanced options. diff --git a/app.py b/app.py index e0906131..65753fb2 100644 --- a/app.py +++ b/app.py @@ -229,7 +229,21 @@ def _update_step(step_id, **kwargs): def _get_setup_state_snapshot(): with setup_lock: - return copy.deepcopy(setup_state) + snapshot = copy.deepcopy(setup_state) + warnings = [] + agents_need_gateway = any( + os.environ.get(name, "true").strip().lower() not in ("false", "0", "no") + for name in ("ENABLE_CLAUDE", "ENABLE_PI") + ) + if agents_need_gateway and not os.environ.get("CODA_GATEWAY_MODEL_CATALOG", "").strip(): + warnings.append( + "AI Gateway model access is not provisioned for this app. Deploy with " + "the repository Make targets, or run `make configure-gateway-resources " + "PROFILE= APP_NAME=` before redeploying. App resources " + "alone do not grant Unity model-service EXECUTE." + ) + snapshot["warnings"] = warnings + return snapshot # Single-user security: only the token owner can access the terminal @@ -2447,6 +2461,13 @@ def initialize_app(local_dev=False): """One-time init: detect owner, start cleanup thread.""" global app_owner, _omnigent_sp_creds, _sp_token_broker_server + if not os.environ.get("CODA_GATEWAY_MODEL_CATALOG", "").strip(): + logger.warning( + "AI Gateway model access is not provisioned: deploy with the repository " + "Make targets or run `make configure-gateway-resources PROFILE= " + "APP_NAME=`; Apps YAML alone cannot grant model-service EXECUTE" + ) + global _sp_token_broker_atexit_registered if not _sp_token_broker_atexit_registered: atexit.register(_shutdown_sp_token_broker) diff --git a/docs/deployment.md b/docs/deployment.md index 95d23d01..d86f9c5c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,7 +20,7 @@ The app pulls the code directly from Git. To update later, just re-deploy — it > **Note:** On first startup, the app automatically removes the template's `.git` history and reinitializes a clean, remote-free git repo. This prevents accidental pushes back to the template repo from the in-browser terminal. -> **AI Gateway setup:** CLI deployment targets automatically discover READY Foundation Model chat endpoints and attach them to the app with `CAN_QUERY`. This is required because Gateway visibility and invocation are identity-scoped; a model visible to the deployer may otherwise return a masked 404 to the app service principal. Set `AUTO_CONFIGURE_GATEWAY=false` only when permissions are managed externally. +> **Required AI Gateway setup:** deploy through this repository's `make deploy`, `make redeploy`, or `make deploy-git` targets. They grant the app SP `USE CATALOG` on `system`, `USE SCHEMA` on `system.ai`, and `EXECUTE` on each selected Unity AI Gateway v3 model service; they also attach legacy `CAN_QUERY` resources and publish the app-visible model inventory. A plain Apps UI deploy cannot express the v3 UC grants and leaves Pi/Claude returning a masked `404 '' does not exist`. Set `AUTO_CONFIGURE_GATEWAY=false` only when equivalent UC grants are managed externally. ## Alternative: Deploy with CLI diff --git a/static/index.html b/static/index.html index 59d4a84c..bfe47b3d 100644 --- a/static/index.html +++ b/static/index.html @@ -1643,6 +1643,21 @@

General

stopSpinner(); term.write('\r\x1b[K' + color + ' ' + emoji + '\x1b[0m ' + msg + '\r\n'); }; + const showSetupWarnings = (data) => { + const clean = (value) => String(value || '').replace(/[\x00-\x1f\x7f-\x9f]/g, ' '); + const messages = [...(data.warnings || [])]; + for (const step of (data.steps || [])) { + if (step.status === 'error' && step.error) { + messages.push(`${step.label}: ${step.error}`); + } + } + if (!messages.length) return; + term.write('\r\n\x1b[1;33m AI agent setup requires attention:\x1b[0m\r\n'); + for (const message of messages) { + term.write(`\x1b[33m • ${clean(message)}\x1b[0m\r\n`); + } + term.write('\r\n'); + }; // Polls /api/setup-status and rewrites the spinner line with the // currently-running step + completion count. Returns when setup is // complete or errored. Used by both bootstrap paths. @@ -1659,7 +1674,7 @@

General

return; } else if (pollData.status === 'error') { finishLine('!', '\x1b[1;33m', 'Setup completed with warnings'); - term.write('\r\n'); + showSetupWarnings(pollData); return; } const steps = pollData.steps || []; @@ -1773,6 +1788,8 @@

General

const setupData2 = await setupResp2.json(); if (setupData2.status !== 'complete' && setupData2.status !== 'error') { await waitForSetup(); + } else if (setupData2.status === 'error' || (setupData2.warnings || []).length) { + showSetupWarnings(setupData2); } var { sid, reattached } = await getOrPromptSession(term, tab.label, opts.skipPrompt); } else { diff --git a/tests/test_auth_enforcement.py b/tests/test_auth_enforcement.py index dd031359..382e19ac 100644 --- a/tests/test_auth_enforcement.py +++ b/tests/test_auth_enforcement.py @@ -28,6 +28,25 @@ def _make_client(app_module): return app_module.app.test_client() +def test_setup_snapshot_warns_when_gateway_model_grants_are_missing(monkeypatch): + app_module = _get_app_module() + monkeypatch.delenv("CODA_GATEWAY_MODEL_CATALOG", raising=False) + monkeypatch.setenv("ENABLE_CLAUDE", "true") + + snapshot = app_module._get_setup_state_snapshot() + + assert len(snapshot["warnings"]) == 1 + assert "model-service EXECUTE" in snapshot["warnings"][0] + assert "configure-gateway-resources" in snapshot["warnings"][0] + + +def test_setup_snapshot_has_no_gateway_warning_when_catalog_is_attached(monkeypatch): + app_module = _get_app_module() + monkeypatch.setenv("CODA_GATEWAY_MODEL_CATALOG", '["system.ai.claude-sonnet-5"]') + + assert app_module._get_setup_state_snapshot()["warnings"] == [] + + # --------------------------------------------------------------------------- # 1. Session endpoints MUST enforce owner check # --------------------------------------------------------------------------- diff --git a/tests/test_cli_token_rotation.py b/tests/test_cli_token_rotation.py index b24b0eda..49f51618 100644 --- a/tests/test_cli_token_rotation.py +++ b/tests/test_cli_token_rotation.py @@ -833,12 +833,9 @@ def test_bootstrap_writes_claude_settings_mode_600( ): import stat import app - import utils monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("DATABRICKS_HOST", "https://workspace.example") - monkeypatch.setattr(utils, "resolve_and_cache_gateway", lambda: None) - monkeypatch.setattr(app, "get_gateway_host", lambda: "https://gateway.example") monkeypatch.setattr(app, "apply_claude_otel_env", lambda *_args: False) monkeypatch.setattr(app.pat_rotator, "_write_databrickscfg", lambda _token: True) monkeypatch.setattr(app, "_venv_python", lambda: "/usr/bin/python3")