feat: open Slack-triggered PRs as the triggering user (#1375)

* feat: open Slack-triggered PRs as the triggering user

Route the Slack per-user GitHub token through the dashboard OAuth store
(the backend the self-service link prompt populates) and block runs that
lack a valid user token, prompting the user to (re-)link. Per-user OAuth
now wins over bot-token-only mode for mapped Slack/dashboard users.

Flip commit/PR authorship across all sources: the triggering user is the
commit author (via repo-local git identity using their resolvable GitHub
noreply email) and open-swe[bot] is the Co-authored-by collaborator.

* fix: address PR review — shell-escape commit identity, fix token cache impersonation

- Shell-escape the triggering user's name/email with shlex.quote before
  embedding them in the repo-setup `git config` command, so a name like
  O'Connor (or a crafted one) can't break or inject into the command.
- Stop consulting the shared thread-metadata token cache in
  _resolve_dashboard_user_token. Slack thread ids are shared across the
  conversation, so a cached token from a prior triggering user could be
  returned for the current github_login. Always resolve by login from the
  dashboard OAuth store instead.

* feat: dashboard self-service user mapping + UI cleanup

- Add session-scoped GET/PUT /dashboard/api/my-mapping so users can set their
  own work email / Slack member ID (keyed by their GitHub login, source=self).
- Slack account-link prompt now redirects to Profile Settings after auth.
- Rename "My Settings" -> "Profile Settings" and "Cloud Agents" -> "Open SWE
  Agent"; remove the Integrations tab/section (folded out, low value for now)
  and redirect /integrations to Profile Settings.
- Add a "User mapping" section to Profile Settings (work email used by Slack
  and Linear, optional Slack member ID).
- Make dashboard auth cookies scheme-aware: Secure;SameSite=None over HTTPS,
  non-Secure;SameSite=Lax over http://localhost so local login works.

* feat: self-service Slack account linking via Sign in with Slack (OIDC)

Replace the spoofable manual work-email/Slack-ID form with a verified
"Sign in with Slack" flow so a logged-in GitHub user can only ever link
their own Slack identity.

- New agent/dashboard/slack_oauth.py: OIDC authorize URL, code exchange,
  userInfo identity parse, optional workspace gate, configured check.
- routes.py: session-gated GET /slack/login and /slack/callback that upsert
  the mapping from Slack-verified user_id + email (source=slack_oauth).
  Remove the spoofable PUT /my-mapping; expose slack_oauth_enabled on /me.
- UI: drop the editable inputs; add a Connect Slack button + status to the
  User mapping section.

Admin-managed mappings are unaffected and still resolve at trigger time.
This commit is contained in:
Johannes du Plessis 2026-06-02 15:04:20 -07:00 • committed by GitHub
parent 111f00ea9a
commit cb4c643e43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 912 additions and 231 deletions

View file

@ -170,6 +170,9 @@ def decode_state(state: str) -> dict[str, Any]:
LINK_TTL_SECONDS = 7 * 24 * 60 * 60
# Dashboard route where users manage their GitHub↔Slack link.
PROFILE_SETTINGS_PATH = "/my-settings"
def issue_account_link(*, slack_user_id: str | None, work_email: str | None) -> str:
"""Sign a short-lived token carrying the Slack identity to map after login.
@ -212,7 +215,14 @@ def build_account_link_url(*, slack_user_id: str | None, work_email: str | None)
if not api_base:
return None
token = issue_account_link(slack_user_id=slack_user_id, work_email=work_email)
return f"{api_base}/dashboard/api/auth/login?link={quote(token, safe='')}"
url = f"{api_base}/dashboard/api/auth/login?link={quote(token, safe='')}"
# Land the user on the Profile Settings page so they can review/complete
# their GitHub↔Slack link after re-authenticating.
frontend_base = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
if frontend_base:
redirect_to = f"{frontend_base}{PROFILE_SETTINGS_PATH}"
url += f"&redirect_to={quote(redirect_to, safe='')}"
return url
def require_session(request: Request) -> dict[str, Any]:

View file

@ -58,6 +58,14 @@ from .review_styles import (
normalize_repo_full_name,
set_custom_prompt,
)
from .slack_oauth import (
SLACK_STATE_COOKIE_NAME,
build_authorize_url,
exchange_slack_code,
fetch_slack_identity,
slack_oauth_configured,
verify_team,
)
from .team_settings import (
TeamSettingsUpdate,
get_team_settings,
@ -76,6 +84,7 @@ from .thread_api import (
)
from .user_mappings import (
delete_mapping,
get_mapping,
list_mappings,
upsert_mapping,
)
@ -115,14 +124,29 @@ def _frontend_base_url() -> str:
return v
def _cookie_security() -> tuple[bool, str]:
"""Cookie ``secure``/``samesite`` flags derived from the API scheme.
Production serves the API over HTTPS and the dashboard is a separate
(cross-site) origin, so the session cookie must be ``Secure; SameSite=None``.
Local dev runs over ``http://localhost`` where ``Secure`` cookies are
rejected and the frontend/API are same-site, so fall back to
``SameSite=Lax`` without ``Secure``.
"""
if os.environ.get("DASHBOARD_API_BASE_URL", "").startswith("https://"):
return True, "none"
return False, "lax"
def _set_session_cookie(response: Response, jwt_token: str) -> None:
secure, samesite = _cookie_security()
response.set_cookie(
key=COOKIE_NAME,
value=jwt_token,
max_age=SESSION_TTL_SECONDS,
httponly=True,
secure=True,
samesite="none",
secure=secure,
samesite=samesite,
path="/",
)
@ -131,20 +155,42 @@ def _set_state_cookie(response: Response, nonce: str) -> None:
# SameSite=Lax so GitHub's top-level redirect back to /auth/callback
# still presents this cookie; the cookie is single-purpose and lives
# only for the duration of one OAuth round-trip.
secure, _ = _cookie_security()
response.set_cookie(
key=STATE_COOKIE_NAME,
value=nonce,
max_age=STATE_TTL_SECONDS,
httponly=True,
secure=True,
secure=secure,
samesite="lax",
path="/dashboard/api/auth",
)
def _clear_state_cookie(response: Response) -> None:
secure, _ = _cookie_security()
response.delete_cookie(
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=True
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=secure
)
def _set_slack_state_cookie(response: Response, nonce: str) -> None:
secure, _ = _cookie_security()
response.set_cookie(
key=SLACK_STATE_COOKIE_NAME,
value=nonce,
max_age=STATE_TTL_SECONDS,
httponly=True,
secure=secure,
samesite="lax",
path="/dashboard/api/slack",
)
def _clear_slack_state_cookie(response: Response) -> None:
secure, _ = _cookie_security()
response.delete_cookie(
SLACK_STATE_COOKIE_NAME, path="/dashboard/api/slack", samesite="lax", secure=secure
)
@ -247,7 +293,8 @@ async def _complete_account_mapping(login: str, github_email: str | None, link_t
@router.post("/auth/logout")
async def auth_logout() -> Response:
response = Response(status_code=204)
response.delete_cookie(COOKIE_NAME, path="/", samesite="none", secure=True)
secure, samesite = _cookie_security()
response.delete_cookie(COOKIE_NAME, path="/", samesite=samesite, secure=secure)
return response
@ -258,6 +305,7 @@ async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
"email": session.get("email"),
"avatar_url": session.get("avatar_url"),
"is_admin": is_admin(session.get("email")),
"slack_oauth_enabled": slack_oauth_configured(),
}
@ -283,6 +331,79 @@ async def put_my_profile(
return await upsert_profile(session["sub"], session.get("email") or "", update)
@router.get("/my-mapping")
async def get_my_mapping(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
"""Return the logged-in user's own GitHub↔Slack mapping (or empty)."""
mapping = await get_mapping(session["sub"])
return mapping or {}
@router.get("/slack/login")
async def slack_login(
_session: dict[str, Any] = _SESSION_DEP,
) -> RedirectResponse:
"""Start the Sign in with Slack flow to link the current GitHub account."""
if not slack_oauth_configured():
raise HTTPException(500, "Slack OAuth is not configured")
redirect_uri = f"{_api_base_url()}/dashboard/api/slack/callback"
nonce = new_state_nonce()
state = issue_state(
redirect_to=f"{_frontend_base_url()}/my-settings",
nonce_hash=hash_state_nonce(nonce),
)
response = RedirectResponse(
build_authorize_url(redirect_uri=redirect_uri, state=state), status_code=302
)
_set_slack_state_cookie(response, nonce)
return response
@router.get("/slack/callback")
async def slack_callback(
request: Request,
code: str,
state: str,
session: dict[str, Any] = _SESSION_DEP,
) -> RedirectResponse:
"""Link the verified Slack identity to the logged-in GitHub user.
The Slack member id and email come from Slack's verified OIDC claims, so a
user can only ever link their own Slack account — no self-asserted values.
"""
state_payload = decode_state(state)
nonce_hash = state_payload.get("nonce_hash")
cookie_nonce = request.cookies.get(SLACK_STATE_COOKIE_NAME)
if (
not isinstance(nonce_hash, str)
or not cookie_nonce
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), nonce_hash)
):
raise HTTPException(400, "oauth state mismatch — please retry")
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
redirect_uri = f"{_api_base_url()}/dashboard/api/slack/callback"
access_token = await exchange_slack_code(code, redirect_uri)
identity = await fetch_slack_identity(access_token)
verify_team(identity)
if not identity.email or not identity.email_verified:
raise HTTPException(400, "your Slack account has no verified email to link")
await upsert_mapping(
github_login=session["sub"],
work_email=identity.email,
slack_user_id=identity.user_id,
source="slack_oauth",
status="active",
)
response = RedirectResponse(redirect_to, status_code=302)
_clear_slack_state_cookie(response)
return response
@router.get("/team-settings")
async def api_get_team_settings(
session: dict[str, Any] = _SESSION_DEP,

View file

@ -0,0 +1,117 @@
"""Sign in with Slack (OpenID Connect) for self-service GitHub ⇄ Slack linking.
Reuses the existing Slack app — Sign in with Slack is a capability on the same
app that owns the bot token, so it only needs the ``openid email profile`` user
scopes, a redirect URL, and the app's client id/secret. The id_token/userInfo
claims give us a Slack-*verified* member id and email, so a logged-in GitHub
user can only ever link their own Slack identity (no self-asserted spoofing).
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
from fastapi import HTTPException
logger = logging.getLogger(__name__)
SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", "")
SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", "")
# Optional: restrict linking to a single workspace (the Slack team id, T...).
SLACK_TEAM_ID = os.environ.get("SLACK_TEAM_ID", "")
SLACK_STATE_COOKIE_NAME = "osw_slack_oauth_state"
SLACK_OIDC_SCOPES = "openid email profile"
_AUTHORIZE_URL = "https://slack.com/openid/connect/authorize"
_TOKEN_URL = "https://slack.com/api/openid.connect.token"
_USERINFO_URL = "https://slack.com/api/openid.connect.userInfo"
_USER_ID_CLAIM = "https://slack.com/user_id"
_TEAM_ID_CLAIM = "https://slack.com/team_id"
def slack_oauth_configured() -> bool:
return bool(SLACK_CLIENT_ID and SLACK_CLIENT_SECRET)
@dataclass(frozen=True)
class SlackIdentity:
user_id: str
team_id: str
email: str | None
email_verified: bool
name: str | None
def build_authorize_url(*, redirect_uri: str, state: str) -> str:
params = {
"response_type": "code",
"scope": SLACK_OIDC_SCOPES,
"client_id": SLACK_CLIENT_ID,
"redirect_uri": redirect_uri,
"state": state,
}
if SLACK_TEAM_ID:
# Pre-selects the workspace so users can't accidentally sign in elsewhere.
params["team"] = SLACK_TEAM_ID
return f"{_AUTHORIZE_URL}?{urlencode(params)}"
def parse_slack_identity(data: dict[str, Any]) -> SlackIdentity:
"""Build a SlackIdentity from an openid.connect.userInfo response."""
if not data.get("ok", True):
raise HTTPException(400, f"slack userinfo failed: {data.get('error', 'unknown')}")
user_id = data.get(_USER_ID_CLAIM)
if not isinstance(user_id, str) or not user_id:
raise HTTPException(400, "slack userinfo missing user id")
team_id = data.get(_TEAM_ID_CLAIM)
email = data.get("email")
return SlackIdentity(
user_id=user_id,
team_id=team_id if isinstance(team_id, str) else "",
email=email if isinstance(email, str) and email else None,
email_verified=bool(data.get("email_verified")),
name=data.get("name") if isinstance(data.get("name"), str) else None,
)
def verify_team(identity: SlackIdentity) -> None:
"""Reject identities from a different workspace when one is configured."""
if SLACK_TEAM_ID and identity.team_id != SLACK_TEAM_ID:
raise HTTPException(403, "Slack account is not in the authorized workspace")
async def exchange_slack_code(code: str, redirect_uri: str) -> str:
"""Exchange an authorization code for a user access token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
_TOKEN_URL,
data={
"client_id": SLACK_CLIENT_ID,
"client_secret": SLACK_CLIENT_SECRET,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
},
)
resp.raise_for_status()
data = resp.json()
if not data.get("ok") or not data.get("access_token"):
raise HTTPException(400, f"slack oauth exchange failed: {data.get('error', 'unknown')}")
return data["access_token"]
async def fetch_slack_identity(access_token: str) -> SlackIdentity:
"""Resolve the signed-in Slack user's verified identity."""
async with httpx.AsyncClient() as client:
resp = await client.get(
_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return parse_slack_identity(resp.json())

View file

@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
USER_MAPPINGS_NAMESPACE: list[str] = ["user_mappings"]
MappingSource = Literal["hardcoded", "self", "admin"]
MappingSource = Literal["hardcoded", "self", "admin", "slack_oauth"]
MappingStatus = Literal["active", "pending"]

View file

@ -1,8 +1,13 @@
import logging
import os
import shlex
from pathlib import Path
from .utils.authorship import CollaboratorIdentity
from .utils.authorship import (
OPEN_SWE_BOT_EMAIL,
OPEN_SWE_BOT_NAME,
CollaboratorIdentity,
)
from .utils.github_comments import UNTRUSTED_GITHUB_COMMENT_OPEN_TAG
logger = logging.getLogger(__name__)
@ -90,10 +95,10 @@ Before starting any task that requires code changes, set up the repository in yo
3. **Set the commit identity** — IMMEDIATELY after cloning, `cd` into the repo and run:
```bash
git config user.name 'open-swe[bot]' && git config user.email 'open-swe@users.noreply.github.com'
git config user.name {commit_identity_name} && git config user.email {commit_identity_email}
```
This is required: third-party CI integrations (e.g. Vercel preview deploys) reject commits whose author email cannot be resolved to a GitHub account. Do NOT set any other identity, do NOT pass `--author` to `git commit`, and do NOT export `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env vars.
This sets the author of every commit you make. This is required for CI: third-party integrations (e.g. Vercel preview deploys) reject commits whose author email cannot be resolved to a GitHub account, and this email resolves. Do NOT set any other identity, do NOT pass `--author` to `git commit`, and do NOT export `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env vars.
4. **Choose your branch** — Use a thread-stable branch name such as `open-swe/<short-task-slug>`. If a branch already exists for this thread/task, fetch and check it out instead of creating a new one.
@ -360,12 +365,12 @@ COLLABORATION_TEMPLATE = """---
### Collaborative Attribution
This run was triggered by **{display_name}**. Credit them on every commit and PR you create:
This run was triggered by **{display_name}**. You author the work **as them** — their git identity is already configured in the Repository Setup step, so every commit and the PR are attributed to them. Credit open-swe as the collaborator:
- **Commits**: append this trailer (verbatim, on its own line, separated from the message body by a blank line) to every commit message you author. Add it to both the first commit and any follow-up commits in this run:
```
Co-authored-by: {commit_name} <{commit_email}>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
```
- **PR body**: append this line to the bottom of the PR description (separated from the body by a blank line) when you open or update the draft PR. Do not duplicate it if it is already present. If the PR body already contains the legacy footer `_Opened collaboratively by {display_name} and open-swe._`, replace that legacy footer with this line instead of appending a second footer:
@ -382,8 +387,6 @@ def _render_collaboration_section(identity: CollaboratorIdentity | None) -> str:
return ""
return COLLABORATION_TEMPLATE.format(
display_name=identity.display_name,
commit_name=identity.commit_name,
commit_email=identity.commit_email,
pr_attribution_name=identity.pr_attribution_name,
)
@ -425,6 +428,14 @@ def construct_system_prompt(
create_prs: bool = False,
) -> str:
default_prompt_section = _load_default_prompt()
# Shell-escape: display names/emails are user-controlled (e.g. O'Connor) and
# are embedded in a `git config` command the agent copies verbatim.
if triggering_user_identity is not None:
commit_identity_name = shlex.quote(triggering_user_identity.commit_name)
commit_identity_email = shlex.quote(triggering_user_identity.commit_email)
else:
commit_identity_name = shlex.quote(OPEN_SWE_BOT_NAME)
commit_identity_email = shlex.quote(OPEN_SWE_BOT_EMAIL)
return SYSTEM_PROMPT_TEMPLATE.format(
working_dir=working_dir,
linear_project_id=linear_project_id or "<PROJECT_ID>",
@ -432,4 +443,6 @@ def construct_system_prompt(
default_prompt_section=default_prompt_section,
pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "",
collaboration_section=_render_collaboration_section(triggering_user_identity),
commit_identity_name=commit_identity_name,
commit_identity_email=commit_identity_email,
)

View file

@ -23,6 +23,21 @@ logger = logging.getLogger(__name__)
client = get_client()
class GitHubUserAuthRequired(RuntimeError):
"""Raised when a mapped user has no valid GitHub OAuth token.
Signals that the run cannot proceed on the user's behalf and that the user
must (re-)authenticate. The Slack webhook blocks before creating a run, so
this is a defense-in-depth signal at execution time.
"""
def __init__(self, source: str, github_login: str | None) -> None:
self.source = source
self.github_login = github_login
super().__init__(f"GitHub authentication required for {source} user '{github_login}'")
LANGSMITH_API_KEY = os.environ.get("LANGSMITH_API_KEY_PROD", "")
LANGSMITH_API_URL = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
LANGSMITH_HOST_API_URL = os.environ.get("LANGSMITH_HOST_API_URL", "https://api.host.langchain.com")
@ -361,6 +376,36 @@ async def save_encrypted_token_from_email(
return token, encrypted, expires_at
async def _resolve_dashboard_user_token(
thread_id: str, github_login: str
) -> tuple[str, str, str | None] | None:
"""Resolve a per-user GitHub token from the dashboard OAuth store.
Returns the ``(token, encrypted, expires_at)`` tuple, or ``None`` when the
user has no valid token (never linked, or expired/revoked beyond refresh).
The thread-metadata token cache is intentionally NOT consulted here: Slack
thread ids are shared across everyone in a conversation, so a cached token
from a prior triggering user would impersonate the current ``github_login``.
We always resolve by login from the dashboard store instead.
"""
login = github_login.strip()
if not login:
raise ValueError("missing github_login")
from ..dashboard.profiles import OAUTH_TOKENS_NAMESPACE, get_valid_access_token
from ..dashboard.profiles import _get_value as get_oauth_record
token = await get_valid_access_token(login)
if not token:
return None
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, login)
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
expires_at = expires_at if isinstance(expires_at, str) else None
encrypted = await persist_encrypted_github_token(thread_id, token, expires_at=expires_at)
return token, encrypted, expires_at
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str, str | None]:
"""Get a GitHub App installation token and persist it for the thread."""
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
@ -396,24 +441,32 @@ async def resolve_github_token(
Raises:
RuntimeError: If source is missing or token resolution fails.
"""
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
configurable = config["configurable"]
source = configurable.get("source")
if not source:
logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id)
raise RuntimeError(f"GitHub auth failed for thread {thread_id}: missing source")
# Unmapped Slack/Linear users still get a run on the GitHub App installation
# token (the webhook posts a "link your account" prompt separately). This
# keeps the agent responsive while self-service onboarding completes.
if configurable.get("use_installation_token_fallback"):
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
thread_id
)
if cached_token and cached_encrypted:
return cached_token, cached_encrypted, cached_expires_at
github_login = configurable.get("github_login")
# Per-user OAuth from the dashboard store wins even in bot-token-only mode,
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
# what lets the agent open PRs as the triggering user.
if source in ("slack", "dashboard") and isinstance(github_login, str) and github_login.strip():
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
raise RuntimeError(str(exc)) from exc
if user_token is not None:
return user_token
# No valid user token. In bot-token-only mode fall back to the bot so the
# deployment stays functional; otherwise block and require auth.
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
raise GitHubUserAuthRequired(source, github_login)
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
try:
@ -423,36 +476,12 @@ async def resolve_github_token(
)
if cached_token and cached_encrypted:
return cached_token, cached_encrypted, cached_expires_at
github_login = configurable.get("github_login")
from ..dashboard.user_mappings import email_for_login
email = await email_for_login(github_login)
if not email:
raise ValueError(f"No email mapping found for GitHub user '{github_login}'")
return await save_encrypted_token_from_email(email, source)
if source == "dashboard":
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
thread_id
)
if cached_token and cached_encrypted:
return cached_token, cached_encrypted, cached_expires_at
github_login = configurable.get("github_login")
if not isinstance(github_login, str) or not github_login.strip():
raise ValueError("missing github_login for dashboard run")
from ..dashboard.profiles import OAUTH_TOKENS_NAMESPACE, get_valid_access_token
from ..dashboard.profiles import _get_value as get_oauth_record
token = await get_valid_access_token(github_login.strip())
if not token:
raise ValueError("github token unavailable, re-login required")
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, github_login.strip())
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
encrypted = await persist_encrypted_github_token(
thread_id,
token,
expires_at=expires_at if isinstance(expires_at, str) else None,
)
return token, encrypted, expires_at if isinstance(expires_at, str) else None
return await save_encrypted_token_from_email(configurable.get("user_email"), source)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))

View file

@ -138,16 +138,14 @@ def resolve_triggering_user_identity(
return _identity_from_github_token(github_token) or _identity_from_config(config)
def add_user_coauthor_trailer(
commit_message: str,
identity: CollaboratorIdentity | None,
) -> str:
"""Append a Co-authored-by trailer when a user identity is available."""
normalized_message = commit_message.rstrip()
if not identity:
return normalized_message
def add_bot_coauthor_trailer(commit_message: str) -> str:
"""Append the open-swe[bot] Co-authored-by trailer.
trailer = f"Co-authored-by: {identity.commit_name} <{identity.commit_email}>"
Commits are authored by the triggering user (via the repo-local git
identity); open-swe[bot] is credited as the collaborator.
"""
normalized_message = commit_message.rstrip()
trailer = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
if trailer in normalized_message:
return normalized_message
return f"{normalized_message}\n\n{trailer}"

View file

@ -26,7 +26,7 @@ from .dashboard.agent_overrides import (
)
from .dashboard.enabled_repos import is_review_repo_enabled
from .dashboard.oauth import build_account_link_url
from .dashboard.profiles import get_profile
from .dashboard.profiles import get_profile, get_valid_access_token
from .dashboard.team_settings import get_team_settings
from .dashboard.user_mappings import (
email_for_login,
@ -869,18 +869,34 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
async def _post_account_link_prompt(
channel_id: str, thread_ts: str, user_id: str, user_email: str | None
channel_id: str,
thread_ts: str,
user_id: str,
user_email: str | None,
reason: str = "unlinked",
) -> None:
"""Prompt an unmapped Slack user to link their GitHub account (ephemeral)."""
"""Prompt a Slack user to (re-)link their GitHub account (ephemeral).
``reason`` is ``"unlinked"`` (no mapping yet) or ``"expired"`` (mapped but
the stored GitHub authorization is missing/expired/revoked). Open SWE opens
PRs as the triggering user, so it cannot start until the account is linked.
"""
link_url = build_account_link_url(slack_user_id=user_id, work_email=user_email)
if not link_url:
logger.debug("Account-link URL unavailable (DASHBOARD_API_BASE_URL unset); skipping prompt")
return
text = (
"👋 I don't have your GitHub account linked yet, so I'm running with limited "
"(bot) permissions. Link your account so I can act on your behalf:\n"
f"<{link_url}|Link your GitHub account>"
)
if reason == "expired":
text = (
"🔐 Your GitHub authorization has expired or was revoked, so I can't act on "
"your behalf. Re-link your account to continue:\n"
f"<{link_url}|Re-link your GitHub account>"
)
else:
text = (
"👋 I don't have your GitHub account linked yet, so I can't open PRs on your "
"behalf. I won't start until you link your account:\n"
f"<{link_url}|Link your GitHub account>"
)
try:
await post_slack_ephemeral_message(channel_id, user_id, text, thread_ts=thread_ts)
except Exception: # noqa: BLE001
@ -1010,6 +1026,37 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
mapped_login = await login_for_email(user_email)
is_user_mapped = bool(mapped_login)
# Open SWE opens PRs as the triggering user, so a run only proceeds when we
# have a valid user GitHub token. Unmapped users, and mapped users whose
# token is missing/expired/revoked, are blocked and prompted to (re-)link.
# Bot-token-only deployments are exempt — they run on the installation token.
user_token: str | None = None
if mapped_login:
try:
user_token = await get_valid_access_token(mapped_login)
except Exception: # noqa: BLE001
logger.debug(
"Failed to resolve GitHub token for %s; treating as unauthenticated",
mapped_login,
exc_info=True,
)
user_token = None
has_valid_user_token = bool(user_token)
if not has_valid_user_token and not is_bot_token_only_mode():
reason = "expired" if is_user_mapped else "unlinked"
logger.info(
"Blocking Slack run for thread %s: no valid user GitHub token (%s)",
thread_id,
reason,
)
if user_id:
await _post_account_link_prompt(
channel_id, thread_ts, user_id, user_email, reason=reason
)
await set_slack_assistant_status(channel_id, thread_ts, status="")
return
configurable: dict[str, Any] = {
"repo": repo_config,
"slack_thread": {
@ -1025,9 +1072,6 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
}
if mapped_login:
configurable["github_login"] = mapped_login
else:
# Unmapped: run on the installation token and prompt the user to link.
configurable["use_installation_token_fallback"] = True
langgraph_client = get_client(url=LANGGRAPH_URL)
is_first_mention = not await _thread_exists(thread_id)
@ -1072,8 +1116,6 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
thread_id,
)
run_id = run.get("run_id")
if is_first_mention and not is_user_mapped and user_id:
await _post_account_link_prompt(channel_id, thread_ts, user_id, user_email)
if is_first_mention:
trace_message_ts = await post_slack_trace_reply(channel_id, thread_ts, thread_id)
await set_slack_assistant_status(channel_id, thread_ts)

View file

@ -34,17 +34,31 @@ def test_decode_account_link_rejects_wrong_kind() -> None:
def test_build_account_link_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com/")
monkeypatch.delenv("DASHBOARD_BASE_URL", raising=False)
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
assert url is not None
assert url.startswith("https://api.example.com/dashboard/api/auth/login?link=")
# The embedded token must decode back to the same identity.
token = url.split("link=", 1)[1]
token = url.split("link=", 1)[1].split("&", 1)[0]
from urllib.parse import unquote
payload = oauth.decode_account_link(unquote(token))
assert payload["slack_user_id"] == "U1"
def test_build_account_link_url_redirects_to_profile_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DASHBOARD_API_BASE_URL", "https://api.example.com")
monkeypatch.setenv("DASHBOARD_BASE_URL", "https://app.example.com")
url = oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com")
assert url is not None
from urllib.parse import parse_qs, urlparse
query = parse_qs(urlparse(url).query)
assert query["redirect_to"] == ["https://app.example.com/my-settings"]
def test_build_account_link_url_none_without_base(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DASHBOARD_API_BASE_URL", raising=False)
assert oauth.build_account_link_url(slack_user_id="U1", work_email="d@x.com") is None

View file

@ -85,3 +85,127 @@ def test_leave_failure_comment_falls_back_to_slack_thread_when_ephemeral_fails(
asyncio.run(auth.leave_failure_comment("slack", "auth failed"))
assert thread_called == {"channel_id": "C123", "thread_ts": "1.2", "message": "auth failed"}
def _slack_config(github_login: str | None = "mason-gh") -> dict:
configurable: dict = {
"source": "slack",
"user_email": "mason@example.com",
"thread_id": "t1",
}
if github_login is not None:
configurable["github_login"] = github_login
return {"configurable": configurable}
def _stub_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
*,
token: str | None,
expires_at: str | None = "2099-01-01T00:00:00Z",
cached: tuple[str | None, str | None, str | None] = (None, None, None),
) -> None:
from agent.dashboard import profiles
async def fake_get_from_thread(thread_id: str):
return cached
async def fake_get_valid(login: str):
return token
async def fake_get_value(namespace, key):
return {"token_expires_at": expires_at}
async def fake_persist(thread_id: str, tok: str, expires_at: str | None = None):
return "enc"
monkeypatch.setattr(auth, "get_github_token_from_thread", fake_get_from_thread)
monkeypatch.setattr(auth, "persist_encrypted_github_token", fake_persist)
monkeypatch.setattr(profiles, "get_valid_access_token", fake_get_valid)
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
def test_resolve_github_token_slack_uses_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "user-tok"
assert encrypted == "enc"
assert expires_at == "2099-01-01T00:00:00Z"
def test_resolve_github_token_slack_ignores_stale_thread_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Slack thread ids are shared, so a prior user's cached token must NOT be
# returned. Resolution always goes by github_login via the dashboard store.
_stub_dashboard_store(
monkeypatch,
token="bob-token",
cached=("alice-token", "alice-enc", "2099-01-01T00:00:00Z"),
)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "bob-token"
def test_resolve_github_token_slack_no_token_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
def test_resolve_github_token_per_user_wins_over_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fail_bot(thread_id: str):
raise AssertionError("bot token must not be used when a user token exists")
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
token, _, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "user-tok"
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):
return ("bot-tok", "bot-enc", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, encrypted, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert (token, encrypted, expires_at) == ("bot-tok", "bot-enc", None)
@pytest.mark.parametrize("source", ["github", "linear"])
def test_resolve_github_token_bot_only_mode_non_slack_uses_bot(
monkeypatch: pytest.MonkeyPatch, source: str
) -> None:
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):
return ("bot-tok", "bot-enc", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
config = {"configurable": {"source": source, "github_login": "octo", "thread_id": "t1"}}
token, _, _ = asyncio.run(auth.resolve_github_token(config, "t1"))
assert token == "bot-tok"

36
tests/test_authorship.py Normal file
View file

@ -0,0 +1,36 @@
from __future__ import annotations
from agent.utils.authorship import (
OPEN_SWE_BOT_EMAIL,
OPEN_SWE_BOT_NAME,
add_bot_coauthor_trailer,
resolve_triggering_user_identity,
)
_BOT_TRAILER = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
def test_add_bot_coauthor_trailer_appends_bot() -> None:
result = add_bot_coauthor_trailer("fix: thing")
assert result == f"fix: thing\n\n{_BOT_TRAILER}"
def test_add_bot_coauthor_trailer_is_idempotent() -> None:
once = add_bot_coauthor_trailer("fix: thing")
assert add_bot_coauthor_trailer(once) == once
def test_resolve_identity_from_config_uses_user_noreply_email() -> None:
config = {
"configurable": {
"source": "slack",
"github_login": "mason-gh",
"github_user_id": 4321,
"slack_thread": {"triggering_user_name": "Mason"},
}
}
identity = resolve_triggering_user_identity(config)
assert identity is not None
assert identity.commit_name == "Mason"
assert identity.commit_email == "4321+mason-gh@users.noreply.github.com"
assert identity.github_login == "mason-gh"

View file

@ -96,7 +96,11 @@ def test_construct_system_prompt_includes_coauthor_trailer_when_identity_present
)
assert "Collaborative Attribution" in prompt
assert "Co-authored-by: octocat <1234+octocat@users.noreply.github.com>" in prompt
# The user authors the commits; open-swe[bot] is the co-author/collaborator.
# Values are shell-escaped via shlex.quote; safe tokens need no quoting.
assert "git config user.name octocat" in prompt
assert "git config user.email 1234+octocat@users.noreply.github.com" in prompt
assert "Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>" in prompt
assert "_Opened collaboratively by octocat and open-swe._" in prompt
@ -113,7 +117,10 @@ def test_construct_system_prompt_includes_github_login_in_pr_footer() -> None:
triggering_user_identity=identity,
)
assert "Co-authored-by: Mona Lisa <1234+octocat@users.noreply.github.com>" in prompt
# A name with a space is shlex-quoted; the safe email is left bare.
assert "git config user.name 'Mona Lisa'" in prompt
assert "git config user.email 1234+octocat@users.noreply.github.com" in prompt
assert "Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>" in prompt
assert "_Opened collaboratively by Mona Lisa (@octocat) and open-swe._" in prompt
assert (
"replace that legacy footer with this line instead of appending a second footer" in prompt
@ -121,6 +128,27 @@ def test_construct_system_prompt_includes_github_login_in_pr_footer() -> None:
assert "`_Opened collaboratively by Mona Lisa and open-swe._`" in prompt
def test_construct_system_prompt_shell_escapes_user_name() -> None:
import shlex
hostile = "O'Connor'; rm -rf / #"
identity = CollaboratorIdentity(
display_name=hostile,
commit_name=hostile,
commit_email="1234+oconnor@users.noreply.github.com",
github_login="oconnor",
)
prompt = construct_system_prompt(
working_dir="/workspace",
triggering_user_identity=identity,
)
assert f"git config user.name {shlex.quote(hostile)}" in prompt
# The raw, unescaped name must never appear as a bare shell argument.
assert f"git config user.name {hostile}" not in prompt
def test_add_pr_collaboration_note_replaces_legacy_footer() -> None:
identity = CollaboratorIdentity(
display_name="Mona Lisa",

View file

@ -470,9 +470,30 @@ def _setup_slack_mention_fakes(
monkeypatch.setattr(
webapp, "resolve_slack_links_in_context", fake_resolve_slack_links_in_context
)
async def fake_login_for_slack_id(slack_user_id):
return "mason-gh"
async def fake_login_for_email(email):
return None
async def fake_refresh_cache() -> list:
return []
async def fake_get_valid_access_token(login):
return "user-token"
async def fake_post_prompt(*args, **kwargs) -> None:
captured["prompt"] = {"args": args, "kwargs": kwargs}
monkeypatch.setattr(webapp, "is_thread_active", fake_is_thread_active)
monkeypatch.setattr(webapp, "post_slack_trace_reply", fake_post_slack_trace_reply)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
def test_process_slack_mention_creates_thread_first_run_with_trace_reply(
@ -652,6 +673,23 @@ def test_process_slack_mention_queues_active_thread_message(
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "get_client", lambda url: _FakeLangGraphClientForProcess())
async def fake_login_for_slack_id(slack_user_id):
return "mason-gh"
async def fake_login_for_email(email):
return None
async def fake_refresh_cache() -> list:
return []
async def fake_get_valid_access_token(login):
return "user-token"
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
thread_ts = "1700000000.000100"
event_ts = "1700000000.000200"
expected_thread_id = generate_thread_id_from_slack_thread("C123", thread_ts)
@ -677,10 +715,10 @@ def test_process_slack_mention_queues_active_thread_message(
assert "## Latest Mention Request\ninclude this screenshot" in queued_payload["text"]
def test_process_slack_mention_unmapped_user_uses_fallback_and_prompts(
def test_process_slack_mention_unmapped_user_blocked_and_prompted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unmapped Slack user runs on the installation token and is prompted to link."""
"""An unmapped Slack user is blocked (no run) and prompted to link."""
from agent.dashboard import user_mappings
captured: dict[str, object] = {}
@ -690,20 +728,16 @@ def test_process_slack_mention_unmapped_user_uses_fallback_and_prompts(
async def fake_thread_exists(thread_id: str) -> bool:
return False
async def fake_refresh_cache() -> list:
return []
async def fake_login_for_slack_id(slack_user_id):
return None
async def fake_login_for_email(email):
return None
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email) -> None:
captured["prompt"] = {"user_id": user_id, "user_email": user_email}
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
captured["prompt"] = {"user_id": user_id, "user_email": user_email, "reason": reason}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
@ -722,36 +756,71 @@ def test_process_slack_mention_unmapped_user_uses_fallback_and_prompts(
)
)
run_create = captured["run_create"]
configurable = run_create["kwargs"]["config"]["configurable"]
assert configurable["use_installation_token_fallback"] is True
assert "github_login" not in configurable
assert captured["prompt"] == {"user_id": "U123", "user_email": "mason@example.com"}
assert "run_create" not in captured
assert captured["prompt"] == {
"user_id": "U123",
"user_email": "mason@example.com",
"reason": "unlinked",
}
def test_process_slack_mention_mapped_user_no_prompt(
def test_process_slack_mention_mapped_user_no_token_blocked_and_prompted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A mapped Slack user runs as themselves with no link prompt and no fallback."""
"""A mapped Slack user with no valid token is blocked and prompted to re-link."""
captured: dict[str, object] = {}
_setup_slack_mention_fakes(monkeypatch, captured)
async def fake_thread_exists(thread_id: str) -> bool:
return False
async def fake_refresh_cache() -> list:
return []
async def fake_login_for_slack_id(slack_user_id):
return "mason-gh" if slack_user_id == "U123" else None
async def fake_get_valid_access_token(login):
return None
async def fake_post_prompt(channel_id, thread_ts, user_id, user_email, reason="unlinked"):
captured["prompt"] = {"reason": reason}
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
asyncio.run(
webapp.process_slack_mention(
{
"channel_id": "C123",
"thread_ts": "1700000000.000100",
"event_ts": "1700000000.000200",
"user_id": "U123",
"text": "<@UBOT> do the thing",
"bot_user_id": "UBOT",
},
{"owner": "langchain-ai", "name": "open-swe"},
)
)
assert "run_create" not in captured
assert captured["prompt"] == {"reason": "expired"}
def test_process_slack_mention_mapped_user_with_token_runs_as_user(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A mapped, authenticated Slack user runs as themselves with no prompt."""
captured: dict[str, object] = {}
_setup_slack_mention_fakes(monkeypatch, captured)
async def fake_thread_exists(thread_id: str) -> bool:
return False
async def fake_login_for_slack_id(slack_user_id):
return "mason-gh" if slack_user_id == "U123" else None
async def fake_post_prompt(*args, **kwargs) -> None:
captured["prompt"] = True
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "refresh_user_mapping_cache", fake_refresh_cache)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "_post_account_link_prompt", fake_post_prompt)
asyncio.run(
webapp.process_slack_mention(
@ -772,3 +841,42 @@ def test_process_slack_mention_mapped_user_no_prompt(
assert configurable["github_login"] == "mason-gh"
assert "use_installation_token_fallback" not in configurable
assert "prompt" not in captured
def test_process_slack_mention_bot_only_mode_runs_without_user_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""In bot-token-only mode an unmapped user still gets a run (no blocking)."""
captured: dict[str, object] = {}
_setup_slack_mention_fakes(monkeypatch, captured)
async def fake_thread_exists(thread_id: str) -> bool:
return False
async def fake_login_for_slack_id(slack_user_id):
return None
async def fake_login_for_email(email):
return None
monkeypatch.setattr(webapp, "_thread_exists", fake_thread_exists)
monkeypatch.setattr(webapp, "login_for_slack_id", fake_login_for_slack_id)
monkeypatch.setattr(webapp, "login_for_email", fake_login_for_email)
monkeypatch.setattr(webapp, "is_bot_token_only_mode", lambda: True)
asyncio.run(
webapp.process_slack_mention(
{
"channel_id": "C123",
"thread_ts": "1700000000.000100",
"event_ts": "1700000000.000200",
"user_id": "U123",
"text": "<@UBOT> do the thing",
"bot_user_id": "UBOT",
},
{"owner": "langchain-ai", "name": "open-swe"},
)
)
assert "run_create" in captured
assert "prompt" not in captured

86
tests/test_slack_oauth.py Normal file
View file

@ -0,0 +1,86 @@
from __future__ import annotations
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import HTTPException
from agent.dashboard import slack_oauth
def test_slack_oauth_configured(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(slack_oauth, "SLACK_CLIENT_ID", "cid")
monkeypatch.setattr(slack_oauth, "SLACK_CLIENT_SECRET", "secret")
assert slack_oauth.slack_oauth_configured() is True
monkeypatch.setattr(slack_oauth, "SLACK_CLIENT_SECRET", "")
assert slack_oauth.slack_oauth_configured() is False
def test_build_authorize_url(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(slack_oauth, "SLACK_CLIENT_ID", "cid")
monkeypatch.setattr(slack_oauth, "SLACK_TEAM_ID", "")
url = slack_oauth.build_authorize_url(
redirect_uri="http://localhost:2024/dashboard/api/slack/callback", state="ST8"
)
parsed = urlparse(url)
q = parse_qs(parsed.query)
assert parsed.netloc == "slack.com"
assert q["response_type"] == ["code"]
assert q["scope"] == ["openid email profile"]
assert q["client_id"] == ["cid"]
assert q["redirect_uri"] == ["http://localhost:2024/dashboard/api/slack/callback"]
assert q["state"] == ["ST8"]
assert "team" not in q
def test_build_authorize_url_includes_team_when_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(slack_oauth, "SLACK_CLIENT_ID", "cid")
monkeypatch.setattr(slack_oauth, "SLACK_TEAM_ID", "T123")
url = slack_oauth.build_authorize_url(redirect_uri="https://x/cb", state="S")
assert parse_qs(urlparse(url).query)["team"] == ["T123"]
def test_parse_slack_identity_success() -> None:
identity = slack_oauth.parse_slack_identity(
{
"ok": True,
"https://slack.com/user_id": "U999",
"https://slack.com/team_id": "T123",
"email": "dev@example.com",
"email_verified": True,
"name": "Dev",
}
)
assert identity.user_id == "U999"
assert identity.team_id == "T123"
assert identity.email == "dev@example.com"
assert identity.email_verified is True
assert identity.name == "Dev"
def test_parse_slack_identity_missing_user_id() -> None:
with pytest.raises(HTTPException):
slack_oauth.parse_slack_identity({"ok": True, "email": "x@y.com"})
def test_parse_slack_identity_not_ok() -> None:
with pytest.raises(HTTPException):
slack_oauth.parse_slack_identity({"ok": False, "error": "bad"})
def test_verify_team(monkeypatch: pytest.MonkeyPatch) -> None:
ident = slack_oauth.SlackIdentity(
user_id="U1", team_id="T1", email="a@b.com", email_verified=True, name=None
)
# No workspace restriction configured → always allowed.
monkeypatch.setattr(slack_oauth, "SLACK_TEAM_ID", "")
slack_oauth.verify_team(ident)
# Matching workspace → allowed.
monkeypatch.setattr(slack_oauth, "SLACK_TEAM_ID", "T1")
slack_oauth.verify_team(ident)
# Different workspace → rejected.
monkeypatch.setattr(slack_oauth, "SLACK_TEAM_ID", "T2")
with pytest.raises(HTTPException):
slack_oauth.verify_team(ident)

View file

@ -2,7 +2,6 @@ import { Link } from "@tanstack/react-router";
import {
IoArrowBackOutline,
IoCloudOutline,
IoExtensionPuzzleOutline,
IoGitPullRequestOutline,
IoOptionsOutline,
IoSettingsOutline,
@ -28,10 +27,9 @@ interface NavItem {
}
const NAV: Array<NavItem> = [
{ to: "/my-settings", label: "My Settings", icon: IoOptionsOutline },
{ to: "/cloud-agents", label: "Cloud Agents", icon: IoCloudOutline },
{ to: "/my-settings", label: "Profile Settings", icon: IoOptionsOutline },
{ to: "/cloud-agents", label: "Open SWE Agent", icon: IoCloudOutline },
{ to: "/review", label: "Open SWE Review", icon: IoGitPullRequestOutline },
{ to: "/integrations", label: "Integrations", icon: IoExtensionPuzzleOutline },
{ to: "/admin", label: "Admin", icon: IoSettingsOutline, adminOnly: true },
];

View file

@ -52,6 +52,7 @@ export interface SessionUser {
email: string | null;
avatar_url: string | null;
is_admin: boolean;
slack_oauth_enabled?: boolean;
}
export interface ModelOption {
@ -212,6 +213,7 @@ export const api = {
method: "PUT",
body: JSON.stringify({ full_name, enabled }),
}),
myMapping: () => request<Partial<UserMapping>>("/my-mapping"),
adminListUserMappings: (page = 1, pageSize = 20) =>
request<UserMappingsPage>(
`/admin/user-mappings?page=${page}&page_size=${pageSize}`,
@ -234,3 +236,7 @@ export function loginUrl(redirectTo?: string): string {
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : "";
return `${API_BASE}/dashboard/api/auth/login${qs}`;
}
export function slackConnectUrl(): string {
return `${API_BASE}/dashboard/api/slack/login`;
}

View file

@ -223,7 +223,7 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
<div className="divide-y divide-border">
<RolePicker
label="Open SWE Agent"
description="Model used for code-writing runs triggered from Slack, Linear, GitHub, and Cloud Agents."
description="Model used for code-writing runs triggered from Slack, Linear, GitHub, and the Open SWE Agent."
models={models}
model={settings.data?.default_agent_model ?? null}
effort={settings.data?.default_agent_reasoning_effort ?? null}

View file

@ -135,8 +135,8 @@ function CloudAgentsPage() {
return (
<AppShell
user={session.data}
title="Cloud Agents"
description="Configure how Cloud Agents pick a model, repository, and PR defaults."
title="Open SWE Agent"
description="Configure how the Open SWE Agent picks a model, repository, and PR defaults."
>
<SettingsSection title="Defaults">
<div className="divide-y divide-border">

View file

@ -1,123 +1,7 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { GithubLogoIcon, KanbanIcon, SlackLogoIcon } from "@phosphor-icons/react";
import type { ComponentType } from "react";
import type { ReposPayload } from "@/lib/api";
import { AppShell, SettingsSection } from "@/components/AppShell";
import { Skeleton } from "@/components/ui/skeleton";
import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/integrations")({ component: IntegrationsPage });
type IconType = ComponentType<{ className?: string; weight?: "regular" | "fill" | "duotone" }>;
interface IntegrationRowProps {
icon: IconType;
name: string;
description: string;
connected: boolean;
badge?: string;
}
function IntegrationRow({ icon: Icon, name, description, connected, badge }: IntegrationRowProps) {
return (
<div className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Icon className="size-4" />
</div>
<div>
<div className="flex items-center gap-2 text-xs font-medium">
{name}
{badge && (
<span className="rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
{badge}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
connected
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{connected ? "Connected" : "Not connected"}
</span>
</div>
);
}
function IntegrationsPage() {
const session = useSession();
const repos = useQuery<ReposPayload>({
queryKey: ["repos"],
queryFn: async () => {
try {
return await api.repos();
} catch (e) {
if (e instanceof ApiError && e.status === 401)
return { installations: [], repositories: [] };
throw e;
}
},
enabled: !!session.data,
});
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
const installs = repos.data?.installations ?? [];
const githubDescription = installs.length
? `Connected to ${installs.length} ${installs.length === 1 ? "installation" : "installations"}: ${installs
.map((i) => i.account ?? "?")
.join(", ")}`
: "Install the open-swe GitHub App to enable PR and issue triggers.";
return (
<AppShell
user={session.data}
title="Integrations"
description="Connect external tools that can trigger or receive updates from open-swe."
>
<SettingsSection title="Source Control">
<IntegrationRow
icon={GithubLogoIcon}
name="GitHub"
description={githubDescription}
connected={installs.length > 0}
/>
</SettingsSection>
<SettingsSection title="Communication">
<IntegrationRow
icon={SlackLogoIcon}
name="Slack"
description="Trigger Open SWE from Slack and receive run notifications."
badge="Team-managed"
connected
/>
<IntegrationRow
icon={KanbanIcon}
name="Linear"
description="Delegate Linear issues to Open SWE via mentions or commands."
badge="Team-managed"
connected
/>
</SettingsSection>
</AppShell>
);
}
// Integrations were folded into Profile Settings. Keep the route as a redirect
// so existing links don't 404.
export const Route = createFileRoute("/integrations")({
component: () => <Navigate to="/my-settings" replace />,
});

View file

@ -1,7 +1,9 @@
import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SlackLogoIcon } from "@phosphor-icons/react";
import { useState } from "react";
import type { SessionUser } from "@/lib/api";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import {
@ -12,9 +14,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import { api, slackConnectUrl } from "@/lib/api";
import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile";
import { useSession } from "@/lib/session";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/my-settings")({ component: MySettingsPage });
@ -32,6 +35,70 @@ function fromChoice(choice: DraftReviewChoice): boolean | null {
return null;
}
function UserMappingSection({ session }: { session: SessionUser }) {
const qc = useQueryClient();
const mapping = useQuery({ queryKey: ["myMapping"], queryFn: api.myMapping });
const [connecting, setConnecting] = useState(false);
const slackUserId = mapping.data?.slack_user_id ?? null;
const workEmail = mapping.data?.work_email ?? null;
const connected = !!slackUserId;
const connect = () => {
setConnecting(true);
// Refresh the cached mapping when the user returns from the OAuth redirect.
void qc.invalidateQueries({ queryKey: ["myMapping"] });
window.location.assign(slackConnectUrl());
};
return (
<SettingsSection
title="User mapping"
description="Connect your Slack account so Open SWE can act as you when you tag it from Slack. We use the email Slack verifies, which also lets Linear mentions resolve to you."
>
<div className="divide-y divide-border">
<SettingsRow
label="GitHub account"
control={<span className="text-xs text-muted-foreground">{session.login}</span>}
/>
<SettingsRow
label="Slack account"
description={
connected
? `Linked to Slack member ${slackUserId}${workEmail ? ` · ${workEmail}` : ""}.`
: "Not connected. Sign in with Slack to verify your identity — no manual IDs to copy."
}
control={
<div className="flex items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
connected ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{connected ? "Connected" : "Not connected"}
</span>
{session.slack_oauth_enabled ? (
<Button
size="sm"
variant={connected ? "outline" : "default"}
onClick={connect}
disabled={connecting || mapping.isLoading}
>
<SlackLogoIcon className="size-4" />
{connecting ? "Redirecting…" : connected ? "Reconnect" : "Connect Slack"}
</Button>
) : (
<span className="text-[10px] text-muted-foreground">Sign in with Slack unavailable</span>
)}
</div>
}
/>
</div>
</SettingsSection>
);
}
function MySettingsPage() {
const session = useSession();
const qc = useQueryClient();
@ -84,18 +151,18 @@ function MySettingsPage() {
};
return (
<AppShell user={session.data} title="My Settings">
<AppShell user={session.data} title="Profile Settings">
<SettingsSection title="Profile">
<SettingsRow
label="Email"
control={
<span className="text-xs text-muted-foreground">
{session.data.email ?? "—"}
</span>
<span className="text-xs text-muted-foreground">{session.data.email ?? "—"}</span>
}
/>
</SettingsSection>
<UserMappingSection session={session.data} />
<SettingsSection title="Open SWE Review">
<SettingsRow
label="Review my draft PRs"

View file

@ -157,7 +157,7 @@ function ReviewPage() {
/>
<SettingsRow
label="Review Draft PRs"
description="Org-wide default for whether Open SWE Review runs on draft PRs. Each user can override this in My Settings."
description="Org-wide default for whether Open SWE Review runs on draft PRs. Each user can override this in Profile Settings."
control={
<Switch
checked={current.review_draft_prs}