mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 23:13:15 +00:00
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled
* feat: default Slack/dashboard/schedule PRs + commits to the app identity (#57) Slack/dashboard/schedule runs now author PRs and run git/gh operations as the GitHub App seahaven-openswe[bot] by default (matching GitHub-issue runs), so the self-review 422 is impossible by construction rather than guarded in the prompt. A profile flag author_prs_as_user restores per-user attribution. - open_pull_request._resolve_pr_author_token + auth.resolve_github_token: default to the installation token for these sources; per-user only when opted in. - authorship: commit identity -> seahaven-openswe[bot] (numeric noreply; accepted Vercel-resolution risk, documented inline). - self-trigger safety: INTERNAL_BOT_LOGINS + webapp/reviewer_reconcile/reply markers recognize seahaven-openswe[bot] (bot-authored events are now ours). Supersedes the prompt-only guard in #58. * fix: author commits as the app bot in the default path (SH-IDSPLIT-01) Security review found the commit identity was NOT actually unified to the bot: resolve_triggering_user_identity got a 403 from the installation token and fell back to configurable['github_login'], so commits were still authored as the triggering user (commit=user, push+PR=bot — a three-way split that missed the stated goal). Now gate the triggering-user identity resolution on the same default-bot decision as the token: slack/dashboard/schedule default to the app bot identity unless author_prs_as_user is set. * docs(security): record AUTHZ-SLACK-BOT-DEFAULT-001 as an accepted residual (#59) Single-user deployment; bounded by App-on-pilot + ALLOWED_GITHUB_REPOS lock. Revisit (add a per-user gate) before expanding users or the App installation.
175 lines
6.3 KiB
Python
175 lines
6.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 .options import SUPPORTED_MODEL_IDS, model_supports_effort, provider_fallback_pair
|
|
from .profiles import PROFILES_NAMESPACE
|
|
from .team_settings import get_team_default_model
|
|
from .user_mappings import cached_login_for_email, login_for_email
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def resolve_login_from_email(email: str | None) -> str | None:
|
|
"""Reverse-lookup the user-mapping store for the GitHub login of an email.
|
|
|
|
Reads the in-process mapping cache (sync). When the cache is cold the
|
|
lookup misses; the webhook path that triggers a run primes the cache via
|
|
:func:`agent.dashboard.user_mappings.refresh_cache` beforehand.
|
|
"""
|
|
return cached_login_for_email(email)
|
|
|
|
|
|
async def resolve_login_from_email_async(email: str | None) -> str | None:
|
|
"""Async reverse-lookup that falls through to the Store on a cold cache.
|
|
|
|
Use this from webhook/repo-resolution paths that may run on a freshly
|
|
started worker before the user-mapping cache has been primed, so a mapped
|
|
user still resolves to their GitHub login (and dashboard ``default_repo``).
|
|
"""
|
|
return await login_for_email(email if isinstance(email, str) else 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 profile_create_prs(profile: dict[str, Any] | None) -> bool:
|
|
"""Return whether the agent should always open a PR. Defaults to False."""
|
|
if not isinstance(profile, dict):
|
|
return False
|
|
value = profile.get("create_prs")
|
|
if isinstance(value, bool):
|
|
return value
|
|
return False
|
|
|
|
|
|
def profile_author_prs_as_user(profile: dict[str, Any] | None) -> bool:
|
|
"""Return whether Slack/dashboard/schedule PRs should be authored as the
|
|
triggering user (their per-user OAuth token) instead of the App bot.
|
|
|
|
Defaults to False: by default these PRs (and the run's git/gh operations) are
|
|
authored as the GitHub App `seahaven-openswe[bot]`, matching GitHub-issue
|
|
runs and making the self-review 422 impossible. Set the profile flag
|
|
``author_prs_as_user: true`` to opt back into per-user attribution.
|
|
"""
|
|
if not isinstance(profile, dict):
|
|
return False
|
|
value = profile.get("author_prs_as_user")
|
|
if isinstance(value, bool):
|
|
return value
|
|
return False
|
|
|
|
|
|
def _normalize_profile_model_pair(
|
|
profile: dict[str, Any],
|
|
*,
|
|
model_key: str,
|
|
effort_key: str,
|
|
) -> tuple[str | None, str | None]:
|
|
model_id = profile.get(model_key)
|
|
effort = profile.get(effort_key)
|
|
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
|
|
# A stored selection whose exact id dropped out of the supported set (e.g. an
|
|
# Opus minor-version bump) stays on its provider rather than being discarded
|
|
# and silently deferring to the team default. An absent/unknown-provider
|
|
# selection still returns (None, None) so the team default applies.
|
|
if isinstance(model_id, str):
|
|
provider_pair = provider_fallback_pair(model_id, effort)
|
|
if provider_pair is not None:
|
|
return provider_pair
|
|
return None, 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)``."""
|
|
return _normalize_profile_model_pair(
|
|
profile,
|
|
model_key="default_model",
|
|
effort_key="reasoning_effort",
|
|
)
|
|
|
|
|
|
def normalize_profile_subagent_overrides(
|
|
profile: dict[str, Any],
|
|
) -> tuple[str | None, str | None]:
|
|
"""Return the profile's subagent model pair if valid, else ``(None, None)``."""
|
|
return _normalize_profile_model_pair(
|
|
profile,
|
|
model_key="default_subagent_model",
|
|
effort_key="subagent_reasoning_effort",
|
|
)
|
|
|
|
|
|
async def resolve_agent_model_id(
|
|
github_login: str | None,
|
|
per_thread_model_id: str | None = None,
|
|
) -> str:
|
|
"""Resolve the agent model ID using the same precedence as ``get_agent``.
|
|
|
|
Order: per-thread override → profile override → team default.
|
|
"""
|
|
model_id, _effort = await get_team_default_model("agent")
|
|
if github_login:
|
|
profile = await load_profile(github_login)
|
|
if profile:
|
|
overridden_model, _ = normalize_profile_overrides(profile)
|
|
if overridden_model:
|
|
model_id = overridden_model
|
|
if isinstance(per_thread_model_id, str) and per_thread_model_id in SUPPORTED_MODEL_IDS:
|
|
model_id = per_thread_model_id
|
|
return model_id
|