open-swe/agent/dashboard/agent_overrides.py
Johannes du Plessis 88856a04fa
feat: open-swe dashboard for per-user profile config (#1302)
* feat: dashboard backend — GitHub OAuth, profile CRUD, admin endpoints

Adds agent/dashboard/ FastAPI router mounted at /dashboard/api covering:
- GitHub App OAuth login → JWT cookie session (cross-domain ready)
- profile CRUD against LangGraph Store with model+effort validation
- admin gate via CONFIGURED_ADMINS
- /repos via /user/installations using the user's encrypted OAuth token

CORS allowlist on webapp.py is opt-in via DASHBOARD_ALLOWED_ORIGINS so the
Vercel-hosted frontend can call the LangSmith deployment with credentials.

* feat: apply dashboard profile model/effort overrides in get_agent

Look up the triggering user's GitHub login from config (direct field or
GITHUB_USER_EMAIL_MAP reverse lookup), read their profile from the Store,
and apply default_model + reasoning_effort to make_model when both are
valid. Effort 'max' is captured on the profile but not yet wired through —
the OpenAI Reasoning Literal doesn't accept it.

* feat: ui/ TanStack Start dashboard for profile config

Scaffolded with the shadcn b7CScJIjA preset (TanStack Start template,
base-ui primitives, Tailwind v4). Three routes:

- /login   — Sign in with GitHub (links to /dashboard/api/auth/login)
- /profile — Edit default model, reasoning effort, default repo
- /admin   — Admin-only: list users and edit other profiles

API client (src/lib/api.ts) uses credentials: include so the osw_session
cookie set by the OAuth callback rides cross-origin. VITE_DASHBOARD_API_BASE_URL
points at the LangSmith deployment.

Effort options re-render when the model changes; 'max' on Opus 4.7 is
captured on the profile but ignored downstream until anthropic reasoning
is wired through make_model.

* feat: searchable Combobox for default repo picker

Replaces the Select with a base-ui Combobox so users can filter by typing,
the popup is wider than the trigger so full owner/repo names are readable,
and the list caps at max-h-80 to stay on screen.

* fix: address review comments + wire default_repo and Anthropic thinking

Security/correctness fixes from PR review:

* Open redirect: validate `redirect_to` in `/auth/login` against
  `DASHBOARD_BASE_URL` + `DASHBOARD_ALLOWED_ORIGINS` before signing it
  into the state JWT. Anything off-allowlist falls back to the dashboard
  base URL. (PR #1302 r3250054386)

* Login CSRF: bind the OAuth `state` to the requesting browser. At
  `/auth/login` we generate a fresh nonce, set it as a short-lived
  HttpOnly SameSite=Lax cookie scoped to `/dashboard/api/auth`, and
  embed `hash_state_nonce(nonce)` in the state JWT. At `/auth/callback`
  we require the cookie nonce to hash-match the state JWT's nonce_hash
  (constant-time compare). (PR #1302 r3250054395)

* RMW race in profile vs token writes: split storage into two
  namespaces — `["profiles"]` for user-editable settings and
  `["oauth_tokens"]` for the encrypted GitHub token. Each upsert now
  only writes its own namespace so an in-flight profile save can no
  longer clobber a fresh token from a concurrent re-login (and vice
  versa). (PR #1302 r3250054393)

* /repos pagination: follow `Link: rel="next"` for both
  `/user/installations` and per-installation `/repositories` with
  per_page=100, capped at 1000 items. (PR #1302 r3250054401)

Feature wires:

* default_repo: applied as a fallback in `get_slack_repo_config` (after
  explicit-repo / thread metadata, before the env defaults) and in the
  Linear webhook (after comment-body extraction, before team mapping).
  Both paths resolve the triggering user's GitHub login via
  GITHUB_USER_EMAIL_MAP and read the profile's default_repo.

* Anthropic "thinking" effort: `make_model` now accepts a `thinking`
  kwarg; `get_agent` maps profile effort {low,medium,high,xhigh,max}
  to budget_tokens {1k,4k,12k,32k,60k} when the chosen model is
  anthropic. OpenAI path still ignores "max" since the Literal doesn't
  accept it.
2026-05-15 11:23:53 -07:00

86 lines
3 KiB
Python

"""Profile lookup + override helpers consumed by ``agent.server.get_agent``."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from langgraph_sdk import get_client
from ..utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import PROFILES_NAMESPACE
logger = logging.getLogger(__name__)
def resolve_login_from_email(email: str | None) -> str | None:
"""Reverse-lookup ``GITHUB_USER_EMAIL_MAP`` for the GitHub login of an email."""
if not isinstance(email, str) or not email.strip():
return None
normalized = email.strip().lower()
for gh_login, mapped in GITHUB_USER_EMAIL_MAP.items():
if mapped.lower() == normalized:
return gh_login
return None
def resolve_github_login(config: dict[str, Any]) -> str | None:
"""Best-effort resolution of the triggering user's GitHub login from config."""
configurable = (config or {}).get("configurable") or {}
login = configurable.get("github_login")
if isinstance(login, str) and login.strip():
return login.strip()
slack_thread = configurable.get("slack_thread") or {}
email = configurable.get("user_email") or slack_thread.get("triggering_user_email")
return resolve_login_from_email(email if isinstance(email, str) else None)
async def get_profile_default_repo(login: str | None) -> dict[str, str] | None:
"""Return ``{"owner", "name"}`` for the user's profile default_repo, if set."""
if not login:
return None
profile = await load_profile(login)
if not profile:
return None
default_repo = profile.get("default_repo")
if not isinstance(default_repo, str):
return None
parts = default_repo.strip().split("/", 1)
if len(parts) != 2:
return None
owner, name = parts[0].strip(), parts[1].strip()
if not owner or not name:
return None
return {"owner": owner, "name": name}
async def load_profile(login: str) -> dict[str, Any] | None:
try:
item = await get_client().store.get_item(PROFILES_NAMESPACE, login)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
logger.warning("profile lookup failed for %s: %s", login, e)
return None
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]:
"""Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``."""
model_id = profile.get("default_model")
effort = profile.get("reasoning_effort")
if (
isinstance(model_id, str)
and model_id in SUPPORTED_MODEL_IDS
and isinstance(effort, str)
and model_supports_effort(model_id, effort)
):
return model_id, effort
return None, None