mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
* 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.
128 lines
4.2 KiB
Python
128 lines
4.2 KiB
Python
"""User profile schema and LangGraph Store CRUD.
|
|
|
|
Storage is split into two namespaces to avoid the read-modify-write race
|
|
between profile-edit writes and OAuth-callback token refreshes:
|
|
|
|
* ``["profiles"]`` — user-editable settings (model, effort, default_repo).
|
|
* ``["oauth_tokens"]`` — encrypted GitHub OAuth access token + email.
|
|
|
|
Each upsert only touches its own namespace, so the two flows can't clobber
|
|
each other's fields even when they interleave.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from langgraph_sdk import get_client
|
|
from pydantic import BaseModel, field_validator
|
|
|
|
from ..encryption import decrypt_token, encrypt_token
|
|
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROFILES_NAMESPACE: list[str] = ["profiles"]
|
|
OAUTH_TOKENS_NAMESPACE: list[str] = ["oauth_tokens"]
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
default_model: str
|
|
reasoning_effort: str
|
|
default_repo: str | None = None
|
|
|
|
@field_validator("default_model")
|
|
@classmethod
|
|
def _model_supported(cls, v: str) -> str:
|
|
if v not in SUPPORTED_MODEL_IDS:
|
|
raise ValueError(f"unsupported model: {v}")
|
|
return v
|
|
|
|
def validate_pairing(self) -> None:
|
|
if not model_supports_effort(self.default_model, self.reasoning_effort):
|
|
raise ValueError(
|
|
f"effort {self.reasoning_effort!r} not supported by {self.default_model!r}"
|
|
)
|
|
|
|
|
|
def _client():
|
|
return get_client()
|
|
|
|
|
|
async def _get_value(namespace: list[str], key: str) -> dict[str, Any] | None:
|
|
try:
|
|
item = await _client().store.get_item(namespace, key)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
return None
|
|
raise
|
|
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
|
|
|
|
|
|
async def get_profile(login: str) -> dict[str, Any] | None:
|
|
return await _get_value(PROFILES_NAMESPACE, login)
|
|
|
|
|
|
async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[str, Any]:
|
|
"""Write the user's editable settings.
|
|
|
|
Only touches ``["profiles"]`` — the OAuth token in ``["oauth_tokens"]``
|
|
is untouched, so a concurrent re-login can't be clobbered by this write
|
|
and vice versa.
|
|
"""
|
|
existing = await get_profile(login) or {}
|
|
value: dict[str, Any] = {
|
|
**existing,
|
|
"login": login,
|
|
"email": email or existing.get("email", ""),
|
|
"default_model": update.default_model,
|
|
"reasoning_effort": update.reasoning_effort,
|
|
"default_repo": update.default_repo,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
await _client().store.put_item(PROFILES_NAMESPACE, login, value)
|
|
return value
|
|
|
|
|
|
async def upsert_access_token(login: str, email: str, access_token: str) -> None:
|
|
"""Persist (or refresh) the user's encrypted GitHub OAuth token.
|
|
|
|
Only touches ``["oauth_tokens"]`` — the user-editable profile is left
|
|
intact even if a save is in flight in another request.
|
|
"""
|
|
if not access_token:
|
|
return
|
|
value: dict[str, Any] = {
|
|
"login": login,
|
|
"email": email,
|
|
"encrypted_gh_token": encrypt_token(access_token),
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
await _client().store.put_item(OAUTH_TOKENS_NAMESPACE, login, value)
|
|
|
|
|
|
async def get_access_token(login: str) -> str | None:
|
|
record = await _get_value(OAUTH_TOKENS_NAMESPACE, login)
|
|
if not record:
|
|
return None
|
|
encrypted = record.get("encrypted_gh_token")
|
|
if not encrypted:
|
|
return None
|
|
return decrypt_token(encrypted) or None
|
|
|
|
|
|
async def list_profiles() -> list[dict[str, Any]]:
|
|
result = await _client().store.search_items(PROFILES_NAMESPACE, limit=1000)
|
|
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
|
|
out: list[dict[str, Any]] = []
|
|
for item in items or []:
|
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
|
if isinstance(value, dict):
|
|
out.append(value)
|
|
return out
|