Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions HANDOFF-omnigents-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,8 @@ CoDA's container.
---

## Cleanup / hygiene owed
- **Live PAT to revoke:** `bea9756ceb8e1656e8a00e454985784e6d239e7cdffc93cac85f22cf6ea9e9f4`
(comment `coda-auto-rotated`) — this is the CoDA-rotated token derived from the PAT pasted into
the terminal for log-debugging. It's CoDA's *active* terminal credential; revoke when done with
the terminal: `databricks tokens delete <id> --profile daveok`. (Earlier prior-session ~90-day
PAT was already revoked.)
- **Exposed PAT:** the plaintext value has been removed from this public handoff. Treat every
token ever committed here as compromised and verify it is revoked before reusing the environment.
- **`omnigents-daveok` app:** currently **RUNNING** (restarted this session for testing). To halt
metering: `databricks apps stop omnigents-daveok --profile daveok`. App + its Lakebase branch
(`projects/omnigents-daveok/branches/production` on the SHARED `ot-demo-lakebase` CU_1) stay
Expand All @@ -152,12 +149,12 @@ CoDA's container.
---

## Key environment facts (so you don't re-derive them)
- coda app SP: `793257c7-63d3-464f-b6fb-3bc11880bf2d` (`app-2hnbfl coda`).
- Stable host_id (deterministic): `host_30023c7760d5a8726abf9820d912b4e0`
- coda app SP: `<coda-app-service-principal-client-id>`.
- Stable host_id (deterministic): `host_<sha256-prefix>`
= `_stable_host_identity()` = sha256(`coda-omnigents-host:<SP client_id>`).
- Omnigent server runs **header auth mode** (default; accepts SP via proxy `X-Forwarded-Email`).
- OSS source is the authoritative ref: `github.com/omnigent-ai/omnigent` (Databricks open-sourced
it 2026-06-13; same codebase as internal agent-framework). Use `gh api repos/omnigent-ai/omnigent/contents/<path> --jq .content | base64 -d` to read files (account: `david-okeeffe_data`).
it 2026-06-13; same codebase as internal agent-framework). Use `gh api repos/omnigent-ai/omnigent/contents/<path> --jq .content | base64 -d` to read files.
- Host visibility = the identity on the tunnel at `omnigent host` launch; owner-scoped in
`server/host_registry.py` + `auth.py`.
- CoDA terminal driving (e2e): mint short-lived PAT → paste into terminal `Token:` gate (user
Expand Down
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ e2e-auth: ## Record SSO session for e2e tests (one-time per cookie expiry)
@url=$$(databricks apps get coding-agents --profile $(PROFILE) --output json 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin)['url'])") && \
echo "Recording SSO session against $$url ..." && \
uv run playwright codegen --save-storage tests/e2e/auth.json "$$url"
uv run python tests/e2e/record_auth.py "$$url" --output tests/e2e/auth.json
@echo ""
@echo "Auth state saved to tests/e2e/auth.json (gitignored)."
@echo "Run `make e2e-test PROFILE=$(PROFILE)` to execute the suite."
Expand Down Expand Up @@ -302,4 +302,3 @@ clean: ## Remove the app (destructive)
@databricks apps delete $(APP_NAME) --profile $(PROFILE) 2>/dev/null && \
echo " App '$(APP_NAME)' deleted." || \
echo " App '$(APP_NAME)' not found or already deleted."

27 changes: 18 additions & 9 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from claude_otel import apply_claude_otel_env
from utils import add_1m_context_suffix, ensure_https, get_gateway_host
from pat_rotator import PATRotator
from sp_token_broker import BROKER_URL_ENV, broker_url, mint_sp_token, start_sp_token_broker
from telemetry import log_telemetry, set_product_info

# Sanitize DATABRICKS_TOKEN early — the platform sometimes injects trailing
Expand Down Expand Up @@ -185,6 +186,7 @@ def _get_setup_state_snapshot():
# Single-user security: only the token owner can access the terminal
app_owner = None
_omnigent_sp_creds = None
_sp_token_broker_server = None


def _owner_check_disabled() -> bool:
Expand Down Expand Up @@ -1879,7 +1881,7 @@ def close_session():

def initialize_app(local_dev=False):
"""One-time init: detect owner, start cleanup thread."""
global app_owner, _omnigent_sp_creds
global app_owner, _omnigent_sp_creds, _sp_token_broker_server

# Install SIGTERM handler only for gunicorn (production).
# For local dev, SIG_DFL is fine — the process just exits cleanly.
Expand All @@ -1903,16 +1905,23 @@ def initialize_app(local_dev=False):
else:
logger.warning("Could not determine app owner - authorization disabled")

# SP-auth workshop path: write the omnigents-host OAuth (M2M) profile at
# boot from the app's OWN SP creds, so token_helper._sp_oauth_token()
# resolves without a pasted PAT. Normally _write_oauth_profile runs only
# when OMNIGENTS_SERVER_URL is set (host path); the workshop app omits that
# var, so arm it here when ENABLE_SP_APIKEYHELPER=true. Idempotent; no-op
# without SP creds (local dev / per-user deploy keeps the PAT path).
if _omnigent_sp_creds and os.environ.get("ENABLE_SP_APIKEYHELPER", "").strip().lower() in ("true", "1", "yes"):
sp_helper_enabled = os.environ.get("ENABLE_SP_APIKEYHELPER", "").strip().lower() in (
"true", "1", "yes",
)
host_enabled = bool(os.environ.get("OMNIGENTS_SERVER_URL", "").strip())
if _omnigent_sp_creds and (sp_helper_enabled or host_enabled):
_sp_token_broker_server = start_sp_token_broker(
lambda: mint_sp_token(_omnigent_sp_creds)
)
os.environ[BROKER_URL_ENV] = broker_url(_sp_token_broker_server)
logger.info("SP token broker listening on loopback")

# The profile retains only the workspace host for Omnigent routing. The
# long-lived client secret stays in this process; helpers mint via broker.
if _omnigent_sp_creds and sp_helper_enabled:
from omnigents_host import _write_oauth_profile
_write_oauth_profile(_omnigent_sp_creds)
logger.info("SP apikeyhelper: wrote omnigents-host OAuth profile at boot (no PAT paste needed)")
logger.info("SP apikeyhelper: wrote secret-free host profile at boot")

# Strip SP credentials — only needed for owner resolution above.
# Keeping them causes SDK to silently fall back to SP auth when PAT is dead.
Expand Down
10 changes: 6 additions & 4 deletions docs/agent-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,14 @@ Key runtime facts an agent should know:
`setup_*.py`); agent CLIs install in parallel. `RUNNING` app status only
means gunicorn bound the port — check `/api/setup-status` or boot logs before
claiming setup-dependent features work.
- **Auth (layered):** with `ENABLE_SP_APIKEYHELPER=true`, the app writes its own
SP OAuth profile at boot and all agents install without a paste. A short-lived
- **Auth (layered):** with `ENABLE_SP_APIKEYHELPER=true`, the app keeps its SP
client secret in-process and brokers short-lived OAuth tokens over loopback;
the on-disk `omnigents-host` profile contains only the workspace host. Agents
install without a paste. A short-lived
PAT pasted on first terminal session is the **fallback** and **auto-rotates
every 10 min** (`pat_rotator.py`). The rotator rewrites `~/.databrickscfg` on
every rotation — a known failure mode was clobbering co-owned profiles (e.g.
`omnigents-host` OAuth); fixed in `b5b11a6`, but any CLI call that must use
`omnigents-host`); fixed in `b5b11a6`, but any CLI call that must use
the file profile should go through `databrickscfg_only_env()` (see `utils.py`).
- Databricks CLI: in the **container**, test with `databricks current-user me`.
On a **local Mac**, use `databricks auth describe --profile <profile>`.
Expand Down Expand Up @@ -153,7 +155,7 @@ of the live `coda` app can wedge platform boot.

## 3. Databricks auth notes (gotchas)

- **Layered auth in-container:** SP OAuth at boot (`ENABLE_SP_APIKEYHELPER`) is
- **Layered auth in-container:** brokered SP OAuth at boot (`ENABLE_SP_APIKEYHELPER`) is
tried first; pasted PAT is the fallback. These coexist by design — not
"PAT or CLIENT_ID/SECRET, pick one."
- Ambient app-SP env vars can shadow a `~/.databrickscfg` profile. The sync uses
Expand Down
40 changes: 20 additions & 20 deletions docs/auth-and-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,27 @@ Reference for who does what on a running CoDA host (e.g. `coda-01`..`coda-08`),
how each agent authenticates its model calls, and the known zero-PAT gap for
OpenCode / Hermes. Written 2026-07-11.

## TL;DR — two identities, don't confuse them
## TL;DR two identities, don't confuse them

A CoDA container carries **two separate Databricks identities**:

| Identity | What it is | What it's used for |
|---|---|---|
| **The user** (e.g. `david.okeeffe1@coles.com.au`) via the PAT in `~/.databrickscfg [DEFAULT]` | A **user** personal access token (`dapi…`), kept fresh by the PAT rotator | **Everything the terminal / CLI does**: `databricks` commands, `git`, `gh`, workspace/UC ops, file writes. Also the token the content-filter proxy injects for OpenCode / Hermes / Codex model calls. |
| **The user** (e.g. `user@example.com`) via the PAT in `~/.databrickscfg [DEFAULT]` | A **user** personal access token (`dapi…`), kept fresh by the PAT rotator | **Everything the terminal / CLI does**: `databricks` commands, `git`, `gh`, workspace/UC ops, file writes. Also the token the content-filter proxy injects for OpenCode / Hermes / Codex model calls. |
| **The app service principal** (e.g. `app-4n8qml coda-02`, an OAuth client_id) | The Databricks App's own SP, no PAT | **Claude & Pi model inference** (via the `apiKeyHelper` / `!command` that mints an **SP-OAuth** token from the `omnigents-host` profile), plus the Omnigent host registration / tunnel. |

### So: when Claude runs a command on this box, who is it?

**You.** Any shell command Claude Code runs — `git commit`, `databricks …`, file
edits — authenticates with the **injected user PAT** in `~/.databrickscfg`, so it
**You.** Any shell command Claude Code runs `git commit`, `databricks `, file
edits authenticates with the **injected user PAT** in `~/.databrickscfg`, so it
acts as **your user identity**, not the app SP. That's why commits/pushes show as
you and CLI calls have your grants.

Only the **model inference** for Claude/Pi uses the app SP (zero-PAT, via the
token helper). The shell/tools do not.

> Consequence: on a **shared team CoDA**, everyone on that host shares the one
> injected PAT — so terminal actions all run as whoever injected it (the host
> injected PAT so terminal actions all run as whoever injected it (the host
> owner), regardless of which teammate is driving. Segregation is at the Omnigent
> host-share layer, not per-user identity inside the box.

Expand All @@ -34,9 +34,9 @@ token helper). The shell/tools do not.
|---|---|---|
| **Claude** | `apiKeyHelper` in `~/.claude/settings.json` (shared `token_helper.py`) | yes |
| **Pi** | `!command` apiKey in `~/.pi/agent/models.json` (same `token_helper.py`) | yes |
| **OpenCode** | `baseURL` → local **content-filter proxy** (`127.0.0.1:4000`), which injects a fresh token per request | ⚠️ only after a PAT is injected once |
| **Hermes** | routes via the **content-filter proxy** too (same fresh-token injection) | ⚠️ only after a PAT is injected once |
| **Codex** | content-filter proxy | ⚠️ same as above |
| **OpenCode** | `baseURL` local **content-filter proxy** (`127.0.0.1:4000`), which injects a fresh token per request | ⚠️ only after a PAT is injected once |
| **Hermes** | routes via the **content-filter proxy** too (same fresh-token injection) | ⚠️ only after a PAT is injected once |
| **Codex** | content-filter proxy | ⚠️ same as above |

### Why Claude/Pi are zero-PAT but OpenCode/Hermes aren't

Expand All @@ -46,32 +46,32 @@ token helper). The shell/tools do not.
- **OpenCode / Hermes / Codex** route through `content_filter_proxy.py`, whose
`_get_fresh_token()` reads the current token from **`~/.databrickscfg`**
(`content_filter_proxy.py:54`, injected at `:569-573`). The PAT rotator keeps
that file fresh — **but only after a PAT has been bootstrapped**. On the pure
that file fresh **but only after a PAT has been bootstrapped**. On the pure
SP-OAuth host path there is no PAT, so `~/.databrickscfg` has no token to read
until you inject one in the UI. That's the one-time PAT injection.
- Once injected, the proxy keeps OpenCode/Hermes fresh across PAT rotation with
no further injection (that's the dynamic-refresh mechanism — it's not static).
no further injection (that's the dynamic-refresh mechanism it's not static).

## Known gap / future fix (not done — intentional)
## Known gap / future fix (not done intentional)

To make **OpenCode / Hermes** zero-PAT like Claude/Pi, teach
`content_filter_proxy._get_fresh_token()` to **fall back to minting an SP-OAuth
token** from the `omnigents-host` profile (the same source `token_helper.py`
uses) when no PAT is present in `~/.databrickscfg`. Small, well-scoped change:

- `content_filter_proxy.py` — add an SP-OAuth mint (via `databricks.sdk` `Config(profile="omnigents-host").authenticate()`) as the fallback in `_get_fresh_token()`, cached with a short TTL like the current path.
- No change needed to `setup_opencode.py` / `setup_hermes.py` — they already
- `content_filter_proxy.py` add an SP-OAuth mint (via `databricks.sdk` `Config(profile="omnigents-host").authenticate()`) as the fallback in `_get_fresh_token()`, cached with a short TTL like the current path.
- No change needed to `setup_opencode.py` / `setup_hermes.py` they already
route through the proxy; only the proxy's token source needs the fallback.

Decision (2026-07-11): **left as-is.** "Claude/Pi work with no PAT; Hermes/OpenCode
work after a one-time PAT injection" is acceptable for the workshop.

## Key files

- `token_helper.py` — shared SP-OAuth/PAT resolver for Claude (`apiKeyHelper`) and Pi (`!command`).
- `setup_claude.py` / `setup_pi.py` — wire the helper (default-on; opt out via `DISABLE_SP_APIKEYHELPER`).
- `content_filter_proxy.py` — local proxy for OpenCode/Hermes/Codex; `_get_fresh_token()` (`:54`) + header injection (`:569-573`).
- `setup_opencode.py` / `setup_hermes.py` — point the agent at the proxy (`127.0.0.1:4000`).
- `pat_rotator.py` — mints/rotates the user PAT, writes `~/.databrickscfg`, fans out via `cli_auth.py`.
- `cli_auth.py` — on rotation, refreshes static tokens in each agent's config (skips the `!command` / helper-owned ones).
- `omnigents_host.py` — host path: `_ensure_{claude,pi,opencode}_settings()` re-run setup with a minted SP bearer on host-connect.
- `token_helper.py` shared SP-OAuth/PAT resolver for Claude (`apiKeyHelper`) and Pi (`!command`).
- `setup_claude.py` / `setup_pi.py` wire the helper (default-on; opt out via `DISABLE_SP_APIKEYHELPER`).
- `content_filter_proxy.py` local proxy for OpenCode/Hermes/Codex; `_get_fresh_token()` (`:54`) + header injection (`:569-573`).
- `setup_opencode.py` / `setup_hermes.py` point the agent at the proxy (`127.0.0.1:4000`).
- `pat_rotator.py` mints/rotates the user PAT, writes `~/.databrickscfg`, fans out via `cli_auth.py`.
- `cli_auth.py` on rotation, refreshes static tokens in each agent's config (skips the `!command` / helper-owned ones).
- `omnigents_host.py` host path: `_ensure_{claude,pi,opencode}_settings()` re-run setup with a minted SP bearer on host-connect.
4 changes: 2 additions & 2 deletions grant_omnigent_host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# For a deployed CoDA app to appear as a selectable host in the Omnigent picker,
# its container must successfully run `omnigent host <server>`. That requires the
# app's service principal to have BOTH:
# 1. CAN_USE on the Omnigent server app — or the host tunnel (WebSocket
# 1. CAN_USE on the Omnigent server app or the host tunnel (WebSocket
# upgrade) is rejected and the host never registers.
# 2. UC access to the wheel volume = the FULL traversal chain:
# a. USE_CATALOG on the catalog
Expand Down Expand Up @@ -136,7 +136,7 @@ for a in d.get('privilege_assignments',[]):
missing=[p for p in ('READ_VOLUME','WRITE_VOLUME') if p not in have]
print(','.join(missing))")
if [[ -z "$MISSING" ]]; then
echo " already granted â skipping"
echo " already granted skipping"
else
ADD_JSON=$(CODA_SP="$CODA_SP" MISSING="$MISSING" python3 -c "
import os,json
Expand Down
Loading