2026-03-04 15:57:03 -08:00
|
|
|
"""GitHub OAuth and LangSmith authentication utilities."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
2026-03-09 17:14:13 -07:00
|
|
|
from typing import Any, Literal
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import jwt
|
|
|
|
|
from langgraph.config import get_config
|
2026-03-09 17:14:13 -07:00
|
|
|
from langgraph.graph.state import RunnableConfig
|
2026-03-04 15:57:03 -08:00
|
|
|
from langgraph_sdk import get_client
|
|
|
|
|
|
2026-05-08 22:57:01 +00:00
|
|
|
from .github_app import get_github_app_installation_token_with_expiry
|
2026-06-04 09:33:51 -07:00
|
|
|
from .github_token import cache_github_token_for_thread, get_github_token_from_thread
|
2026-03-04 15:57:03 -08:00
|
|
|
from .linear import comment_on_linear_issue
|
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply
Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral
returns ok, but ephemeral messages are silently dropped in Slack's assistant
threads (where Open SWE runs), so the user sees nothing. Post the prompt as a
normal threaded reply instead — the same channel the agent uses to reply.
* fix: deliver Slack auth-failure prompt as a visible threaded reply
leave_failure_comment() tried an ephemeral message first and only fell back
to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped
in Slack's assistant threads, so the fallback never fired and the user saw no
auth-failure prompt. Post the visible threaded reply directly, matching the
account-link prompt fix.
* fix: prompt blocked Slack users with a generic, token-free dashboard link
Addresses the review findings that posting the per-user account-link token /
auth URL in a visible thread lets any channel member bind their GitHub account
to the triggering user's Slack identity.
Drop the per-user signed link entirely. Both the account-link prompt
(_post_account_link_prompt) and the runtime auth-failure prompt
(leave_failure_comment) now post a plain dashboard settings link
(build_settings_url) as a visible threaded reply. The user signs in with GitHub
from their own session and connects Slack via verified OIDC on the settings
page — no secret in the thread, nothing to hijack, and no DM machinery.
* feat: nudge first-time users to connect Slack from the dashboard home
Show a Connect Slack banner on the agents landing page whenever Slack OAuth is
enabled and the user hasn't linked Slack yet. A first-time user (no Slack
mapping) sees it immediately after signing in; it disappears once connected.
* feat: prompt first-time users to connect Slack via a dialog
Replace the inline Connect Slack card on the agents home with a modal dialog
(Base UI). It opens automatically once the mapping query resolves to
"not connected" and closes itself once Slack is linked; "Maybe later" dismisses
it for the session. No new dependency — uses the design system's Base UI.
* copy: frame Slack connect as resolving the user's GitHub account
Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the
Slack thread prompts (blocked + auth-failure), and the settings description.
Connecting Slack lets Open SWE resolve the user's GitHub account when they tag
it in Slack.
2026-06-02 20:55:07 -07:00
|
|
|
from .slack import post_slack_thread_reply
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
client = get_client()
|
|
|
|
|
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
|
|
|
|
|
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}'")
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 15:57:03 -08:00
|
|
|
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")
|
|
|
|
|
GITHUB_OAUTH_PROVIDER_ID = os.environ.get("GITHUB_OAUTH_PROVIDER_ID", "")
|
|
|
|
|
X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "")
|
2026-03-09 17:14:13 -07:00
|
|
|
USER_ID_API_KEY_MAP = os.environ.get("USER_ID_API_KEY_MAP", "")
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
"Auth env snapshot: LANGSMITH_API_KEY_PROD=%s LANGSMITH_ENDPOINT=%s "
|
2026-03-09 17:14:13 -07:00
|
|
|
"LANGSMITH_HOST_API_URL=%s GITHUB_OAUTH_PROVIDER_ID=%s",
|
2026-03-04 15:57:03 -08:00
|
|
|
"set" if LANGSMITH_API_KEY else "missing",
|
|
|
|
|
"set" if LANGSMITH_API_URL else "missing",
|
|
|
|
|
"set" if LANGSMITH_HOST_API_URL else "missing",
|
|
|
|
|
"set" if GITHUB_OAUTH_PROVIDER_ID else "missing",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 23:57:56 -07:00
|
|
|
def is_bot_token_only_mode() -> bool:
|
|
|
|
|
"""Check if we're in bot-token-only mode.
|
|
|
|
|
|
|
|
|
|
This is the case when LANGSMITH_API_KEY_PROD is set (deployed) but neither
|
|
|
|
|
X_SERVICE_AUTH_JWT_SECRET nor USER_ID_API_KEY_MAP is configured, meaning we
|
|
|
|
|
can't resolve per-user GitHub OAuth tokens. In this mode the GitHub App
|
|
|
|
|
installation token is used for all git operations instead.
|
|
|
|
|
"""
|
|
|
|
|
return bool(LANGSMITH_API_KEY and not X_SERVICE_AUTH_JWT_SECRET and not USER_ID_API_KEY_MAP)
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 16:43:28 -08:00
|
|
|
def _retry_instruction(source: str) -> str:
|
|
|
|
|
if source == "slack":
|
|
|
|
|
return "Once authenticated, mention me again in this Slack thread to retry."
|
|
|
|
|
return "Once authenticated, reply to this issue mentioning @openswe to retry."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _source_account_label(source: str) -> str:
|
|
|
|
|
if source == "slack":
|
|
|
|
|
return "Slack"
|
|
|
|
|
return "Linear"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auth_link_text(source: str, auth_url: str) -> str:
|
|
|
|
|
if source == "slack":
|
|
|
|
|
return auth_url
|
|
|
|
|
return f"[Authenticate with GitHub]({auth_url})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _work_item_label(source: str) -> str:
|
|
|
|
|
if source == "slack":
|
|
|
|
|
return "thread"
|
|
|
|
|
return "issue"
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 17:14:13 -07:00
|
|
|
def get_secret_key_for_user(
|
2026-03-04 15:57:03 -08:00
|
|
|
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
2026-03-09 17:14:13 -07:00
|
|
|
) -> tuple[str, Literal["service", "api_key"]]:
|
2026-03-04 15:57:03 -08:00
|
|
|
"""Create a short-lived service JWT for authenticating as a specific user."""
|
|
|
|
|
if not X_SERVICE_AUTH_JWT_SECRET:
|
|
|
|
|
msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys."
|
|
|
|
|
raise ValueError(msg)
|
|
|
|
|
|
|
|
|
|
payload = {
|
2026-03-04 16:43:28 -08:00
|
|
|
"sub": "unspecified",
|
2026-03-04 15:57:03 -08:00
|
|
|
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
|
2026-03-04 16:43:28 -08:00
|
|
|
"user_id": user_id,
|
|
|
|
|
"tenant_id": tenant_id,
|
2026-03-04 15:57:03 -08:00
|
|
|
}
|
2026-03-09 17:14:13 -07:00
|
|
|
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256"), "service"
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]:
|
|
|
|
|
"""Get the LangSmith user ID and tenant ID from a user's email."""
|
|
|
|
|
if not LANGSMITH_API_KEY:
|
|
|
|
|
logger.warning("LangSmith API key not configured; cannot resolve LS user for %s", email)
|
|
|
|
|
return {"ls_user_id": None, "tenant_id": None}
|
|
|
|
|
|
|
|
|
|
url = f"{LANGSMITH_API_URL}/api/v1/workspaces/current/members/active"
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
try:
|
|
|
|
|
response = await client.get(
|
|
|
|
|
url,
|
|
|
|
|
headers={"X-API-Key": LANGSMITH_API_KEY},
|
|
|
|
|
params={"emails": [email]},
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
members = response.json()
|
|
|
|
|
|
|
|
|
|
if members and len(members) > 0:
|
|
|
|
|
member = members[0]
|
|
|
|
|
return {
|
|
|
|
|
"ls_user_id": member.get("ls_user_id"),
|
|
|
|
|
"tenant_id": member.get("tenant_id"),
|
|
|
|
|
}
|
2026-03-19 10:26:26 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("Error getting LangSmith user info for email: %s", e)
|
2026-03-04 15:57:03 -08:00
|
|
|
return {"ls_user_id": None, "tenant_id": None}
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 22:57:01 +00:00
|
|
|
def _extract_expires_at(response_data: dict[str, Any]) -> str | None:
|
|
|
|
|
"""Pull an expiry from a LangSmith auth response in any of its known shapes."""
|
|
|
|
|
expires_at = response_data.get("expires_at") or response_data.get("expiresAt")
|
|
|
|
|
if isinstance(expires_at, str) and expires_at:
|
|
|
|
|
return expires_at
|
|
|
|
|
if isinstance(expires_at, int | float):
|
|
|
|
|
return datetime.fromtimestamp(float(expires_at), tz=UTC).isoformat()
|
|
|
|
|
expires_in = response_data.get("expires_in") or response_data.get("expiresIn")
|
|
|
|
|
if isinstance(expires_in, int | float) and expires_in > 0:
|
|
|
|
|
return (datetime.now(UTC) + timedelta(seconds=int(expires_in))).isoformat()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 15:57:03 -08:00
|
|
|
async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str, Any]:
|
|
|
|
|
"""Get GitHub OAuth token for a user via LangSmith agent auth."""
|
|
|
|
|
if not GITHUB_OAUTH_PROVIDER_ID:
|
|
|
|
|
logger.error("GitHub auth failed: GITHUB_OAUTH_PROVIDER_ID is not configured")
|
|
|
|
|
return {"error": "GITHUB_OAUTH_PROVIDER_ID not configured"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
headers = {
|
|
|
|
|
"X-Tenant-Id": tenant_id,
|
2026-03-04 16:43:28 -08:00
|
|
|
"X-User-Id": ls_user_id,
|
2026-03-04 15:57:03 -08:00
|
|
|
}
|
2026-03-09 17:14:13 -07:00
|
|
|
secret_key, secret_type = get_secret_key_for_user(ls_user_id, tenant_id)
|
|
|
|
|
if secret_type == "api_key":
|
|
|
|
|
headers["X-API-Key"] = secret_key
|
|
|
|
|
else:
|
|
|
|
|
headers["X-Service-Key"] = secret_key
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"provider": GITHUB_OAUTH_PROVIDER_ID,
|
|
|
|
|
"scopes": ["repo"],
|
|
|
|
|
"user_id": ls_user_id,
|
|
|
|
|
"ls_user_id": ls_user_id,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
response = await client.post(
|
|
|
|
|
f"{LANGSMITH_HOST_API_URL}/v2/auth/authenticate",
|
|
|
|
|
json=payload,
|
|
|
|
|
headers=headers,
|
|
|
|
|
)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
response_data = response.json()
|
|
|
|
|
|
|
|
|
|
token = response_data.get("token")
|
|
|
|
|
auth_url = response_data.get("url")
|
|
|
|
|
|
|
|
|
|
if token:
|
2026-05-08 22:57:01 +00:00
|
|
|
result: dict[str, Any] = {"token": token}
|
|
|
|
|
expires_at = _extract_expires_at(response_data)
|
|
|
|
|
if expires_at:
|
|
|
|
|
result["expires_at"] = expires_at
|
|
|
|
|
return result
|
2026-03-04 15:57:03 -08:00
|
|
|
if auth_url:
|
|
|
|
|
return {"auth_url": auth_url}
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
# Log the full upstream body server-side only; the returned error becomes a
|
|
|
|
|
# user-facing Slack/Linear comment, so never echo the raw response body.
|
|
|
|
|
logger.error(
|
|
|
|
|
"GitHub auth returned an unexpected result (no token/url): %s", response_data
|
|
|
|
|
)
|
|
|
|
|
return {"error": "GitHub auth returned an unexpected result"}
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
except httpx.HTTPStatusError as e:
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
# Log the full upstream body server-side only; the returned error becomes a
|
|
|
|
|
# user-facing Slack/Linear comment, so never echo the raw response text.
|
2026-03-04 15:57:03 -08:00
|
|
|
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
return {"error": f"GitHub auth failed (status {e.response.status_code})"}
|
2026-03-04 15:57:03 -08:00
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e))
|
|
|
|
|
return {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def resolve_github_token_from_email(email: str) -> dict[str, Any]:
|
|
|
|
|
"""Resolve a GitHub token for a user identified by email.
|
|
|
|
|
|
|
|
|
|
Chains get_ls_user_id_from_email -> get_github_token_for_user.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with one of:
|
|
|
|
|
- {"token": str} on success
|
|
|
|
|
- {"auth_url": str} if user needs to authenticate via OAuth
|
|
|
|
|
- {"error": str} on failure; error="no_ls_user" if email not in LangSmith
|
|
|
|
|
"""
|
|
|
|
|
user_info = await get_ls_user_id_from_email(email)
|
|
|
|
|
ls_user_id = user_info.get("ls_user_id")
|
|
|
|
|
tenant_id = user_info.get("tenant_id")
|
|
|
|
|
|
|
|
|
|
if not ls_user_id or not tenant_id:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"No LangSmith user found for email %s (ls_user_id=%s, tenant_id=%s)",
|
|
|
|
|
email,
|
|
|
|
|
ls_user_id,
|
|
|
|
|
tenant_id,
|
|
|
|
|
)
|
|
|
|
|
return {"error": "no_ls_user", "email": email}
|
|
|
|
|
|
|
|
|
|
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
|
|
|
|
return auth_result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def leave_failure_comment(
|
|
|
|
|
source: str,
|
|
|
|
|
message: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Leave an auth failure comment for the appropriate source."""
|
2026-03-04 16:43:28 -08:00
|
|
|
config = get_config()
|
|
|
|
|
configurable = config.get("configurable", {})
|
|
|
|
|
|
2026-03-04 15:57:03 -08:00
|
|
|
if source == "linear":
|
|
|
|
|
linear_issue = configurable.get("linear_issue", {})
|
|
|
|
|
issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None
|
|
|
|
|
if issue_id:
|
|
|
|
|
logger.info(
|
|
|
|
|
"Posting auth failure comment to Linear issue %s (source=%s)",
|
|
|
|
|
issue_id,
|
|
|
|
|
source,
|
|
|
|
|
)
|
|
|
|
|
await comment_on_linear_issue(issue_id, message)
|
|
|
|
|
return
|
2026-03-04 16:43:28 -08:00
|
|
|
if source == "slack":
|
|
|
|
|
slack_thread = configurable.get("slack_thread", {})
|
|
|
|
|
channel_id = slack_thread.get("channel_id") if isinstance(slack_thread, dict) else None
|
|
|
|
|
thread_ts = slack_thread.get("thread_ts") if isinstance(slack_thread, dict) else None
|
|
|
|
|
if channel_id and thread_ts:
|
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply
Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral
returns ok, but ephemeral messages are silently dropped in Slack's assistant
threads (where Open SWE runs), so the user sees nothing. Post the prompt as a
normal threaded reply instead — the same channel the agent uses to reply.
* fix: deliver Slack auth-failure prompt as a visible threaded reply
leave_failure_comment() tried an ephemeral message first and only fell back
to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped
in Slack's assistant threads, so the fallback never fired and the user saw no
auth-failure prompt. Post the visible threaded reply directly, matching the
account-link prompt fix.
* fix: prompt blocked Slack users with a generic, token-free dashboard link
Addresses the review findings that posting the per-user account-link token /
auth URL in a visible thread lets any channel member bind their GitHub account
to the triggering user's Slack identity.
Drop the per-user signed link entirely. Both the account-link prompt
(_post_account_link_prompt) and the runtime auth-failure prompt
(leave_failure_comment) now post a plain dashboard settings link
(build_settings_url) as a visible threaded reply. The user signs in with GitHub
from their own session and connects Slack via verified OIDC on the settings
page — no secret in the thread, nothing to hijack, and no DM machinery.
* feat: nudge first-time users to connect Slack from the dashboard home
Show a Connect Slack banner on the agents landing page whenever Slack OAuth is
enabled and the user hasn't linked Slack yet. A first-time user (no Slack
mapping) sees it immediately after signing in; it disappears once connected.
* feat: prompt first-time users to connect Slack via a dialog
Replace the inline Connect Slack card on the agents home with a modal dialog
(Base UI). It opens automatically once the mapping query resolves to
"not connected" and closes itself once Slack is linked; "Maybe later" dismisses
it for the session. No new dependency — uses the design system's Base UI.
* copy: frame Slack connect as resolving the user's GitHub account
Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the
Slack thread prompts (blocked + auth-failure), and the settings description.
Connecting Slack lets Open SWE resolve the user's GitHub account when they tag
it in Slack.
2026-06-02 20:55:07 -07:00
|
|
|
# The auth-failure ``message`` can carry a per-user GitHub auth URL,
|
|
|
|
|
# which must not be posted in a shared thread (anyone could complete
|
|
|
|
|
# it and bind the wrong account). Post a generic, token-free notice and
|
|
|
|
|
# let the user finish sign-in from their own authenticated dashboard.
|
|
|
|
|
from ..dashboard.oauth import build_settings_url
|
|
|
|
|
|
|
|
|
|
settings_url = build_settings_url()
|
|
|
|
|
link = (
|
|
|
|
|
f"<{settings_url}|your Open SWE settings>"
|
|
|
|
|
if settings_url
|
|
|
|
|
else "your Open SWE settings"
|
|
|
|
|
)
|
2026-03-04 16:43:28 -08:00
|
|
|
logger.info(
|
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply
Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral
returns ok, but ephemeral messages are silently dropped in Slack's assistant
threads (where Open SWE runs), so the user sees nothing. Post the prompt as a
normal threaded reply instead — the same channel the agent uses to reply.
* fix: deliver Slack auth-failure prompt as a visible threaded reply
leave_failure_comment() tried an ephemeral message first and only fell back
to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped
in Slack's assistant threads, so the fallback never fired and the user saw no
auth-failure prompt. Post the visible threaded reply directly, matching the
account-link prompt fix.
* fix: prompt blocked Slack users with a generic, token-free dashboard link
Addresses the review findings that posting the per-user account-link token /
auth URL in a visible thread lets any channel member bind their GitHub account
to the triggering user's Slack identity.
Drop the per-user signed link entirely. Both the account-link prompt
(_post_account_link_prompt) and the runtime auth-failure prompt
(leave_failure_comment) now post a plain dashboard settings link
(build_settings_url) as a visible threaded reply. The user signs in with GitHub
from their own session and connects Slack via verified OIDC on the settings
page — no secret in the thread, nothing to hijack, and no DM machinery.
* feat: nudge first-time users to connect Slack from the dashboard home
Show a Connect Slack banner on the agents landing page whenever Slack OAuth is
enabled and the user hasn't linked Slack yet. A first-time user (no Slack
mapping) sees it immediately after signing in; it disappears once connected.
* feat: prompt first-time users to connect Slack via a dialog
Replace the inline Connect Slack card on the agents home with a modal dialog
(Base UI). It opens automatically once the mapping query resolves to
"not connected" and closes itself once Slack is linked; "Maybe later" dismisses
it for the session. No new dependency — uses the design system's Base UI.
* copy: frame Slack connect as resolving the user's GitHub account
Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the
Slack thread prompts (blocked + auth-failure), and the settings description.
Connecting Slack lets Open SWE resolve the user's GitHub account when they tag
it in Slack.
2026-06-02 20:55:07 -07:00
|
|
|
"Posting generic auth-failure notice to Slack channel %s thread %s",
|
|
|
|
|
channel_id,
|
|
|
|
|
thread_ts,
|
|
|
|
|
)
|
|
|
|
|
await post_slack_thread_reply(
|
2026-03-04 16:43:28 -08:00
|
|
|
channel_id,
|
|
|
|
|
thread_ts,
|
fix: reliable, safe Slack account-connect prompt + first-login Slack dialog (#1383)
* fix: deliver Slack account-link prompt as a visible threaded reply
Blocked Slack users got no prompt at all. Prod logs show chat.postEphemeral
returns ok, but ephemeral messages are silently dropped in Slack's assistant
threads (where Open SWE runs), so the user sees nothing. Post the prompt as a
normal threaded reply instead — the same channel the agent uses to reply.
* fix: deliver Slack auth-failure prompt as a visible threaded reply
leave_failure_comment() tried an ephemeral message first and only fell back
to a thread reply on failure. Ephemeral messages succeed (ok) but are dropped
in Slack's assistant threads, so the fallback never fired and the user saw no
auth-failure prompt. Post the visible threaded reply directly, matching the
account-link prompt fix.
* fix: prompt blocked Slack users with a generic, token-free dashboard link
Addresses the review findings that posting the per-user account-link token /
auth URL in a visible thread lets any channel member bind their GitHub account
to the triggering user's Slack identity.
Drop the per-user signed link entirely. Both the account-link prompt
(_post_account_link_prompt) and the runtime auth-failure prompt
(leave_failure_comment) now post a plain dashboard settings link
(build_settings_url) as a visible threaded reply. The user signs in with GitHub
from their own session and connects Slack via verified OIDC on the settings
page — no secret in the thread, nothing to hijack, and no DM machinery.
* feat: nudge first-time users to connect Slack from the dashboard home
Show a Connect Slack banner on the agents landing page whenever Slack OAuth is
enabled and the user hasn't linked Slack yet. A first-time user (no Slack
mapping) sees it immediately after signing in; it disappears once connected.
* feat: prompt first-time users to connect Slack via a dialog
Replace the inline Connect Slack card on the agents home with a modal dialog
(Base UI). It opens automatically once the mapping query resolves to
"not connected" and closes itself once Slack is linked; "Maybe later" dismisses
it for the session. No new dependency — uses the design system's Base UI.
* copy: frame Slack connect as resolving the user's GitHub account
Drop 'act/reply on your behalf' wording across the connect-Slack dialog, the
Slack thread prompts (blocked + auth-failure), and the settings description.
Connecting Slack lets Open SWE resolve the user's GitHub account when they tag
it in Slack.
2026-06-02 20:55:07 -07:00
|
|
|
"⚠️ I couldn't resolve your GitHub account for this run. Sign in with GitHub and "
|
|
|
|
|
f"connect your Slack account in {link}, then tag me again.",
|
2026-03-04 16:43:28 -08:00
|
|
|
)
|
|
|
|
|
return
|
2026-06-04 12:11:23 -07:00
|
|
|
if source in ("github", "github_push"):
|
2026-03-09 17:14:13 -07:00
|
|
|
logger.warning(
|
|
|
|
|
"Auth failure for GitHub-triggered run (no token to post comment): %s", message
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-03-04 15:57:03 -08:00
|
|
|
raise ValueError(f"Unknown source: {source}")
|
|
|
|
|
|
|
|
|
|
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
def _current_repo() -> Any:
|
|
|
|
|
"""Best-effort read of the run's repo (owner/name) for cache binding."""
|
|
|
|
|
try:
|
|
|
|
|
configurable = get_config().get("configurable", {})
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
return configurable.get("repo") if isinstance(configurable, dict) else None
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 09:33:51 -07:00
|
|
|
def _cache_resolved_github_token(
|
2026-05-08 22:57:01 +00:00
|
|
|
thread_id: str, token: str, expires_at: str | None = None
|
2026-06-04 09:33:51 -07:00
|
|
|
) -> tuple[str, str | None]:
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
cache_github_token_for_thread(thread_id, token, expires_at=expires_at, repo=_current_repo())
|
2026-06-04 09:33:51 -07:00
|
|
|
return token, expires_at
|
2026-03-04 15:57:03 -08:00
|
|
|
|
|
|
|
|
|
2026-06-04 09:33:51 -07:00
|
|
|
async def resolve_token_from_email(
|
2026-03-04 15:57:03 -08:00
|
|
|
email: str | None,
|
|
|
|
|
source: str,
|
2026-06-04 09:33:51 -07:00
|
|
|
) -> tuple[str, str | None]:
|
|
|
|
|
"""Resolve and cache a GitHub token based on user email."""
|
2026-03-04 15:57:03 -08:00
|
|
|
config = get_config()
|
|
|
|
|
configurable = config.get("configurable", {})
|
|
|
|
|
thread_id = configurable.get("thread_id")
|
|
|
|
|
if not thread_id:
|
|
|
|
|
raise ValueError("GitHub auth failed: missing thread_id")
|
|
|
|
|
if not email:
|
|
|
|
|
message = (
|
|
|
|
|
"❌ **GitHub Auth Error**\n\n"
|
|
|
|
|
"Failed to authenticate with GitHub: missing_user_email\n\n"
|
|
|
|
|
"Please try again or contact support."
|
|
|
|
|
)
|
|
|
|
|
await leave_failure_comment(source, message)
|
|
|
|
|
raise ValueError("GitHub auth failed: missing user_email")
|
|
|
|
|
|
|
|
|
|
user_info = await get_ls_user_id_from_email(email)
|
|
|
|
|
ls_user_id = user_info.get("ls_user_id")
|
|
|
|
|
tenant_id = user_info.get("tenant_id")
|
|
|
|
|
if not ls_user_id or not tenant_id:
|
2026-03-04 16:43:28 -08:00
|
|
|
account_label = _source_account_label(source)
|
2026-03-04 15:57:03 -08:00
|
|
|
message = (
|
|
|
|
|
"🔐 **GitHub Authentication Required**\n\n"
|
|
|
|
|
f"Could not find a LangSmith account for **{email}**.\n\n"
|
|
|
|
|
"Please ensure this email is invited to the main LangSmith organization. "
|
2026-03-04 16:43:28 -08:00
|
|
|
f"If your {account_label} account uses a different email than your LangSmith account, "
|
2026-03-04 15:57:03 -08:00
|
|
|
"you may need to update one of them to match.\n\n"
|
|
|
|
|
"Once your email is added to LangSmith, "
|
2026-03-04 16:43:28 -08:00
|
|
|
f"{_retry_instruction(source)}"
|
2026-03-04 15:57:03 -08:00
|
|
|
)
|
|
|
|
|
await leave_failure_comment(source, message)
|
|
|
|
|
raise ValueError(f"No ls_user_id found from email {email}")
|
|
|
|
|
|
|
|
|
|
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
|
|
|
|
auth_url = auth_result.get("auth_url")
|
|
|
|
|
if auth_url:
|
2026-03-04 16:43:28 -08:00
|
|
|
work_item_label = _work_item_label(source)
|
|
|
|
|
auth_link_text = _auth_link_text(source, auth_url)
|
2026-03-04 15:57:03 -08:00
|
|
|
message = (
|
|
|
|
|
"🔐 **GitHub Authentication Required**\n\n"
|
2026-03-04 16:43:28 -08:00
|
|
|
f"To allow the Open SWE agent to work on this {work_item_label}, "
|
2026-03-04 15:57:03 -08:00
|
|
|
"please authenticate with GitHub by clicking the link below:\n\n"
|
2026-03-04 16:43:28 -08:00
|
|
|
f"{auth_link_text}\n\n"
|
|
|
|
|
f"{_retry_instruction(source)}"
|
2026-03-04 15:57:03 -08:00
|
|
|
)
|
|
|
|
|
await leave_failure_comment(source, message)
|
|
|
|
|
raise ValueError("User not authenticated.")
|
|
|
|
|
|
|
|
|
|
token = auth_result.get("token")
|
|
|
|
|
if not token:
|
|
|
|
|
error = auth_result.get("error", "unknown")
|
|
|
|
|
message = (
|
|
|
|
|
"❌ **GitHub Auth Error**\n\n"
|
|
|
|
|
f"Failed to authenticate with GitHub: {error}\n\n"
|
|
|
|
|
"Please try again or contact support."
|
|
|
|
|
)
|
|
|
|
|
await leave_failure_comment(source, message)
|
|
|
|
|
raise ValueError(f"No token found: {error}")
|
|
|
|
|
|
2026-05-08 22:57:01 +00:00
|
|
|
expires_at = auth_result.get("expires_at") if isinstance(auth_result, dict) else None
|
2026-06-04 09:33:51 -07:00
|
|
|
return _cache_resolved_github_token(
|
|
|
|
|
thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None
|
|
|
|
|
)
|
2026-03-09 17:14:13 -07:00
|
|
|
|
|
|
|
|
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
async def _resolve_dashboard_user_token(
|
|
|
|
|
thread_id: str, github_login: str
|
2026-06-04 09:33:51 -07:00
|
|
|
) -> tuple[str, str | None] | None:
|
|
|
|
|
"""Resolve a per-user GitHub token from the dashboard OAuth store."""
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
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
|
2026-06-04 09:33:51 -07:00
|
|
|
return _cache_resolved_github_token(
|
|
|
|
|
thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None
|
|
|
|
|
)
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
|
|
|
|
|
|
2026-06-04 09:33:51 -07:00
|
|
|
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | None]:
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
"""Get a GitHub App installation token and cache it for the thread.
|
|
|
|
|
|
|
|
|
|
AUTHZ-003 (accepted): in bot-token-only mode every run shares one GitHub App
|
|
|
|
|
installation token, so its blast radius is the whole installation rather than
|
|
|
|
|
a single user. This is a documented, accepted prod posture for this
|
|
|
|
|
single-tenant deployment, not a defect.
|
|
|
|
|
"""
|
2026-05-08 22:57:01 +00:00
|
|
|
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
|
2026-03-11 23:57:56 -07:00
|
|
|
if not bot_token:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Bot-token-only mode is active (LANGSMITH_API_KEY_PROD set without "
|
|
|
|
|
"X_SERVICE_AUTH_JWT_SECRET) but the GitHub App is not configured. "
|
|
|
|
|
"Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_INSTALLATION_ID."
|
|
|
|
|
)
|
|
|
|
|
logger.info(
|
|
|
|
|
"Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id
|
|
|
|
|
)
|
2026-06-04 09:33:51 -07:00
|
|
|
return _cache_resolved_github_token(thread_id, bot_token, expires_at=expires_at)
|
2026-03-11 23:57:56 -07:00
|
|
|
|
|
|
|
|
|
2026-06-04 09:33:51 -07:00
|
|
|
async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str | None]:
|
2026-03-09 17:14:13 -07:00
|
|
|
"""Resolve a GitHub token from the run config based on the source.
|
|
|
|
|
|
|
|
|
|
Routes to the correct auth method depending on whether the run was
|
|
|
|
|
triggered from GitHub (login-based) or Linear/Slack (email-based).
|
|
|
|
|
|
2026-03-11 23:57:56 -07:00
|
|
|
In bot-token-only mode (LANGSMITH_API_KEY_PROD set without
|
|
|
|
|
X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used
|
|
|
|
|
for all operations instead of per-user OAuth tokens.
|
|
|
|
|
|
2026-03-09 17:14:13 -07:00
|
|
|
Raises:
|
|
|
|
|
RuntimeError: If source is missing or token resolution fails.
|
|
|
|
|
"""
|
|
|
|
|
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")
|
|
|
|
|
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
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.
|
2026-06-04 19:20:24 -07:00
|
|
|
if (
|
|
|
|
|
source in ("slack", "dashboard", "schedule")
|
|
|
|
|
and isinstance(github_login, str)
|
|
|
|
|
and github_login.strip()
|
|
|
|
|
):
|
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.
2026-06-02 15:04:20 -07:00
|
|
|
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():
|
feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369)
* Replace hardcoded GitHub-email map with Store-backed user mapping
Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional
mapping (GitHub login <-> work email <-> optional Slack ID) with an
in-process cache, self-service onboarding, and admin management.
- agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id
indexes, sync cache readers for hot paths, async fallthrough, and a
bulk_import that preserves existing richer records.
- Migrate all read sites (auth.py, agent_overrides.py, authorship.py,
github_comments.py, webapp.py x2) off the dict.
- Unmapped Slack tags now run on the GitHub App installation token
(use_installation_token_fallback) and get an ephemeral "link your
GitHub account" prompt carrying the Slack id + email via a signed
account-link token threaded through the OAuth state.
- OAuth callback completes a self-service (org-gated) mapping from that
token, falling back to the verified GitHub email.
- Admin CRUD endpoints + one-time legacy import; dashboard UI section.
- Legacy dict retained only as the import payload (no longer read).
Tests: mapping store, account-link round-trip + completion, mapped vs
unmapped Slack flows; existing trust-gate tests updated to prime cache.
* Address review: cold-cache email resolution + stale alias de-indexing
- agent_overrides: add resolve_login_from_email_async that falls through to
the Store on a cold cache; use it at the async repo-resolution call sites
(Slack repo config, Linear comment, owner-metadata) so a mapped user still
resolves to their GitHub login + dashboard default_repo on a fresh worker.
- user_mappings.upsert_mapping: de-index the existing login before re-indexing
so a changed email/Slack id no longer leaves stale aliases resolving to the
login in-process.
- Tests for both fixes; update Slack repo-config test to patch the async resolver.
2026-06-01 14:37:19 -07:00
|
|
|
return await _resolve_bot_installation_token(thread_id)
|
|
|
|
|
|
2026-03-09 17:14:13 -07:00
|
|
|
try:
|
|
|
|
|
if source == "github":
|
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)
verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.
* fix: stop leaking upstream auth-error bodies into user comments
get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).
* fix: bind sandbox and token caches to repo to prevent thread-id collision
A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.
Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
refuse to reuse a sandbox whose bound repo does not match the current event
(SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.
* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)
The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.
* chore: suppress test-fixture credential false positive; document AUTHZ-002
Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.
* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch
GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
* fix: stop leaking upstream auth body in unexpected-result branch
The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).
* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch
Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.
Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.
* chore: suppress test-fixture credential false positive in token-TTL tests
Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
|
|
|
cached_token, cached_expires_at = await get_github_token_from_thread(
|
|
|
|
|
thread_id, expected_repo=configurable.get("repo")
|
|
|
|
|
)
|
2026-06-04 09:33:51 -07:00
|
|
|
if cached_token:
|
|
|
|
|
return cached_token, cached_expires_at
|
feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369)
* Replace hardcoded GitHub-email map with Store-backed user mapping
Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional
mapping (GitHub login <-> work email <-> optional Slack ID) with an
in-process cache, self-service onboarding, and admin management.
- agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id
indexes, sync cache readers for hot paths, async fallthrough, and a
bulk_import that preserves existing richer records.
- Migrate all read sites (auth.py, agent_overrides.py, authorship.py,
github_comments.py, webapp.py x2) off the dict.
- Unmapped Slack tags now run on the GitHub App installation token
(use_installation_token_fallback) and get an ephemeral "link your
GitHub account" prompt carrying the Slack id + email via a signed
account-link token threaded through the OAuth state.
- OAuth callback completes a self-service (org-gated) mapping from that
token, falling back to the verified GitHub email.
- Admin CRUD endpoints + one-time legacy import; dashboard UI section.
- Legacy dict retained only as the import payload (no longer read).
Tests: mapping store, account-link round-trip + completion, mapped vs
unmapped Slack flows; existing trust-gate tests updated to prime cache.
* Address review: cold-cache email resolution + stale alias de-indexing
- agent_overrides: add resolve_login_from_email_async that falls through to
the Store on a cold cache; use it at the async repo-resolution call sites
(Slack repo config, Linear comment, owner-metadata) so a mapped user still
resolves to their GitHub login + dashboard default_repo on a fresh worker.
- user_mappings.upsert_mapping: de-index the existing login before re-indexing
so a changed email/Slack id no longer leaves stale aliases resolving to the
login in-process.
- Tests for both fixes; update Slack repo-config test to patch the async resolver.
2026-06-01 14:37:19 -07:00
|
|
|
from ..dashboard.user_mappings import email_for_login
|
|
|
|
|
|
|
|
|
|
email = await email_for_login(github_login)
|
2026-03-09 17:14:13 -07:00
|
|
|
if not email:
|
|
|
|
|
raise ValueError(f"No email mapping found for GitHub user '{github_login}'")
|
2026-06-04 09:33:51 -07:00
|
|
|
return await resolve_token_from_email(email, source)
|
|
|
|
|
return await resolve_token_from_email(configurable.get("user_email"), source)
|
2026-03-09 17:14:13 -07:00
|
|
|
except ValueError as exc:
|
|
|
|
|
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
|
|
|
|
|
raise RuntimeError(str(exc)) from exc
|