mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 19:03:21 +00:00
chore: resolve github token in get_agent and read from thread metadata (#1002)
* chore: resolve github token in get_agent and read from thread metadata * feat: add linear_comment tool and fix middleware PR response * refactor: simplify github token resolution flow * linting * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul <braceasproul@gmail.com> * Apply suggestion from @bracesproul Co-authored-by: Brace Sproul <braceasproul@gmail.com> * refactor: simplify auth helpers and token flow * linting * open_pr changes * Update open_pr.py * refactor: centralize github token helpers and config lookup * Apply suggestions from code review --------- Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
parent
3dee480e82
commit
b5951413d7
6 changed files with 346 additions and 258 deletions
|
|
@ -114,7 +114,8 @@ CORE_BEHAVIOR_SECTION = """---
|
||||||
### Core Behavior
|
### Core Behavior
|
||||||
|
|
||||||
- **Persistence:** Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete.
|
- **Persistence:** Keep working until the current task is completely resolved. Only terminate when you are certain the task is complete.
|
||||||
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure."""
|
- **Accuracy:** Never guess or make up information. Always use tools to gather accurate data about files and codebase structure.
|
||||||
|
- **Autonomy:** Never ask the user for permission mid-task. Run linters, fix errors, and call `commit_and_open_pr` without waiting for confirmation."""
|
||||||
|
|
||||||
|
|
||||||
DEPENDENCY_SECTION = """---
|
DEPENDENCY_SECTION = """---
|
||||||
|
|
@ -200,6 +201,8 @@ When you have completed your implementation, follow these steps in order:
|
||||||
|
|
||||||
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
|
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
|
||||||
|
|
||||||
|
**IMPORTANT: Never ask the user for permission or confirmation before calling `commit_and_open_pr`. Do not say "if you want, I can proceed" or "shall I open the PR?". When your implementation is done and checks pass, call the tool immediately and autonomously.**
|
||||||
|
|
||||||
4. **Comment on the Linear ticket** via `linear_comment` immediately after `commit_and_open_pr` succeeds. Include:
|
4. **Comment on the Linear ticket** via `linear_comment` immediately after `commit_and_open_pr` succeeds. Include:
|
||||||
- A brief summary of what was done
|
- A brief summary of what was done
|
||||||
- The PR link returned by `commit_and_open_pr`
|
- The PR link returned by `commit_and_open_pr`
|
||||||
|
|
@ -214,7 +217,7 @@ When you have completed your implementation, follow these steps in order:
|
||||||
- <change 2>
|
- <change 2>
|
||||||
```
|
```
|
||||||
|
|
||||||
Always call `commit_and_open_pr` followed by `linear_comment` once implementation is complete and code quality checks pass."""
|
Always call the `commit_and_open_pr` tool followed by the `linear_comment` tool once implementation is complete and code quality checks pass."""
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
SYSTEM_PROMPT = (
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ from deepagents import create_deep_agent
|
||||||
from deepagents.backends.protocol import SandboxBackendProtocol
|
from deepagents.backends.protocol import SandboxBackendProtocol
|
||||||
from langsmith.sandbox import SandboxClientError
|
from langsmith.sandbox import SandboxClientError
|
||||||
|
|
||||||
from .encryption import decrypt_token
|
|
||||||
from .integrations.langsmith import create_langsmith_sandbox
|
from .integrations.langsmith import create_langsmith_sandbox
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
ToolErrorMiddleware,
|
ToolErrorMiddleware,
|
||||||
|
|
@ -34,6 +33,7 @@ from .middleware import (
|
||||||
)
|
)
|
||||||
from .prompt import construct_system_prompt
|
from .prompt import construct_system_prompt
|
||||||
from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment
|
from .tools import commit_and_open_pr, fetch_url, http_request, linear_comment
|
||||||
|
from .utils.auth import save_encrypted_token_from_email
|
||||||
from .utils.model import make_model
|
from .utils.model import make_model
|
||||||
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
@ -234,7 +234,6 @@ DEFAULT_RECURSION_LIMIT = 1_000
|
||||||
async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
"""Get or create an agent with a sandbox for the given thread."""
|
"""Get or create an agent with a sandbox for the given thread."""
|
||||||
thread_id = config["configurable"].get("thread_id", None)
|
thread_id = config["configurable"].get("thread_id", None)
|
||||||
logger.info("get_agent called for thread %s", thread_id)
|
|
||||||
|
|
||||||
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
|
||||||
|
|
||||||
|
|
@ -242,10 +241,6 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
repo_owner = repo_config.get("owner")
|
repo_owner = repo_config.get("owner")
|
||||||
repo_name = repo_config.get("name")
|
repo_name = repo_config.get("name")
|
||||||
|
|
||||||
encrypted_token = config["configurable"].get("github_token_encrypted")
|
|
||||||
if encrypted_token:
|
|
||||||
github_token = decrypt_token(encrypted_token)
|
|
||||||
|
|
||||||
if thread_id is None or not graph_loaded_for_execution(config):
|
if thread_id is None or not graph_loaded_for_execution(config):
|
||||||
logger.info("No thread_id or not for execution, returning agent without sandbox")
|
logger.info("No thread_id or not for execution, returning agent without sandbox")
|
||||||
return create_deep_agent(
|
return create_deep_agent(
|
||||||
|
|
@ -253,6 +248,21 @@ async def get_agent(config: RunnableConfig) -> Pregel: # noqa: PLR0915
|
||||||
tools=[],
|
tools=[],
|
||||||
).with_config(config)
|
).with_config(config)
|
||||||
|
|
||||||
|
# --- GitHub token resolution ---
|
||||||
|
user_email = config["configurable"].get("user_email")
|
||||||
|
source = config["configurable"].get("source")
|
||||||
|
if not source:
|
||||||
|
logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id)
|
||||||
|
msg = f"GitHub auth failed for thread {thread_id}: missing source"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
try:
|
||||||
|
github_token, new_encrypted = await save_encrypted_token_from_email(user_email, source)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
|
||||||
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
|
config["metadata"]["github_token_encrypted"] = new_encrypted
|
||||||
|
|
||||||
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
|
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
|
||||||
sandbox_id = await get_sandbox_id_from_metadata(thread_id)
|
sandbox_id = await get_sandbox_id_from_metadata(thread_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from typing import Any
|
||||||
|
|
||||||
from langgraph.config import get_config
|
from langgraph.config import get_config
|
||||||
|
|
||||||
from ..encryption import decrypt_token
|
|
||||||
from ..utils.github import (
|
from ..utils.github import (
|
||||||
create_github_pr,
|
create_github_pr,
|
||||||
get_github_default_branch,
|
get_github_default_branch,
|
||||||
|
|
@ -18,6 +17,7 @@ from ..utils.github import (
|
||||||
git_has_unpushed_commits,
|
git_has_unpushed_commits,
|
||||||
git_push,
|
git_push,
|
||||||
)
|
)
|
||||||
|
from ..utils.github_token import get_github_token
|
||||||
from ..utils.sandbox_state import get_sandbox_backend_sync
|
from ..utils.sandbox_state import get_sandbox_backend_sync
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -114,6 +114,7 @@ def commit_and_open_pr(
|
||||||
config = get_config()
|
config = get_config()
|
||||||
configurable = config.get("configurable", {})
|
configurable = config.get("configurable", {})
|
||||||
thread_id = configurable.get("thread_id")
|
thread_id = configurable.get("thread_id")
|
||||||
|
|
||||||
if not thread_id:
|
if not thread_id:
|
||||||
return {"success": False, "error": "Missing thread_id in config", "pr_url": None}
|
return {"success": False, "error": "Missing thread_id in config", "pr_url": None}
|
||||||
|
|
||||||
|
|
@ -168,10 +169,14 @@ def commit_and_open_pr(
|
||||||
"pr_url": None,
|
"pr_url": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
encrypted_token = configurable.get("github_token_encrypted")
|
github_token = get_github_token()
|
||||||
github_token = decrypt_token(encrypted_token) if encrypted_token else None
|
|
||||||
if not github_token:
|
if not github_token:
|
||||||
return {"success": False, "error": "Missing GitHub token", "pr_url": None}
|
logger.error("commit_and_open_pr missing GitHub token for thread %s", thread_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing GitHub token",
|
||||||
|
"pr_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
push_result = git_push(sandbox_backend, repo_dir, target_branch, github_token)
|
push_result = git_push(sandbox_backend, repo_dir, target_branch, github_token)
|
||||||
if push_result.exit_code != 0:
|
if push_result.exit_code != 0:
|
||||||
|
|
|
||||||
251
apps/agent/agent/utils/auth.py
Normal file
251
apps/agent/agent/utils/auth.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
"""GitHub OAuth and LangSmith authentication utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from langgraph.config import get_config
|
||||||
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
|
from ..encryption import encrypt_token
|
||||||
|
from .linear import comment_on_linear_issue
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Auth env snapshot: LANGSMITH_API_KEY_PROD=%s LANGSMITH_ENDPOINT=%s "
|
||||||
|
"LANGSMITH_HOST_API_URL=%s GITHUB_OAUTH_PROVIDER_ID=%s X_SERVICE_AUTH_JWT_SECRET=%s",
|
||||||
|
"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",
|
||||||
|
"set" if X_SERVICE_AUTH_JWT_SECRET else "missing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_jwt_token_for_user(
|
||||||
|
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
||||||
|
) -> str:
|
||||||
|
"""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 = {
|
||||||
|
"sub": user_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
logger.debug("HTTP error getting LangSmith user info for email")
|
||||||
|
return {"ls_user_id": None, "tenant_id": None}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
service_token = get_service_jwt_token_for_user(ls_user_id, tenant_id)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Service-Key": service_token,
|
||||||
|
"X-Tenant-Id": tenant_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
return {"token": token}
|
||||||
|
if auth_url:
|
||||||
|
return {"auth_url": auth_url}
|
||||||
|
return {"error": f"Unexpected auth result: {response_data}"}
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
|
||||||
|
return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"}
|
||||||
|
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."""
|
||||||
|
if source == "linear":
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {})
|
||||||
|
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
|
||||||
|
raise ValueError(f"Unknown source: {source}")
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_encrypted_github_token(thread_id: str, token: str) -> str:
|
||||||
|
"""Encrypt a GitHub token and store it on the thread metadata."""
|
||||||
|
encrypted = encrypt_token(token)
|
||||||
|
await client.threads.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
metadata={"github_token_encrypted": encrypted},
|
||||||
|
)
|
||||||
|
return encrypted
|
||||||
|
|
||||||
|
|
||||||
|
async def save_encrypted_token_from_email(
|
||||||
|
email: str | None,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Resolve, encrypt, and store a GitHub token based on user email."""
|
||||||
|
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:
|
||||||
|
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. "
|
||||||
|
"If your Linear account uses a different email than your LangSmith account, "
|
||||||
|
"you may need to update one of them to match.\n\n"
|
||||||
|
"Once your email is added to LangSmith, "
|
||||||
|
"reply to this issue mentioning @openswe to retry."
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
message = (
|
||||||
|
"🔐 **GitHub Authentication Required**\n\n"
|
||||||
|
"To allow the Open SWE agent to work on this issue, "
|
||||||
|
"please authenticate with GitHub by clicking the link below:\n\n"
|
||||||
|
f"[Authenticate with GitHub]({auth_url})\n\n"
|
||||||
|
"Once authenticated, reply to this issue mentioning @openswe to retry."
|
||||||
|
)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
encrypted = await persist_encrypted_github_token(thread_id, token)
|
||||||
|
return token, encrypted
|
||||||
36
apps/agent/agent/utils/github_token.py
Normal file
36
apps/agent/agent/utils/github_token.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""GitHub token lookup utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from langgraph.config import get_config
|
||||||
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
|
from ..encryption import decrypt_token
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def get_github_token() -> str | None:
|
||||||
|
"""Resolve a GitHub token from config metadata or thread metadata."""
|
||||||
|
config = get_config()
|
||||||
|
configurable = config.get("configurable", {})
|
||||||
|
thread_id = configurable.get("thread_id")
|
||||||
|
|
||||||
|
encrypted_token = None
|
||||||
|
metadata = config.get("metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
encrypted_token = metadata.get("github_token_encrypted")
|
||||||
|
if not encrypted_token and thread_id:
|
||||||
|
try:
|
||||||
|
thread = asyncio.run(client.threads.get(thread_id))
|
||||||
|
thread_metadata = (thread or {}).get("metadata", {})
|
||||||
|
if isinstance(thread_metadata, dict):
|
||||||
|
encrypted_token = thread_metadata.get("github_token_encrypted")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
||||||
|
return decrypt_token(encrypted_token) if encrypted_token else None
|
||||||
|
|
@ -5,19 +5,14 @@ import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
||||||
from langchain_core.messages.content import create_text_block
|
from langchain_core.messages.content import create_text_block
|
||||||
from langgraph_sdk import get_client
|
from langgraph_sdk import get_client
|
||||||
|
|
||||||
# Local import for encryption
|
|
||||||
from .encryption import encrypt_token
|
|
||||||
from .utils.comments import get_recent_comments
|
from .utils.comments import get_recent_comments
|
||||||
from .utils.linear import comment_on_linear_issue
|
|
||||||
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -30,48 +25,8 @@ LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL") or os.environ.get(
|
||||||
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
"LANGGRAPH_URL_PROD", "http://localhost:2024"
|
||||||
)
|
)
|
||||||
|
|
||||||
LANGSMITH_API_KEY = os.environ.get("LANGSMITH_API_KEY_PROD", "")
|
|
||||||
LANGSMITH_API_URL = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
|
||||||
|
|
||||||
GITHUB_OAUTH_PROVIDER_ID = os.environ.get("GITHUB_OAUTH_PROVIDER_ID", "")
|
|
||||||
|
|
||||||
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
|
LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY", "")
|
||||||
|
|
||||||
X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "")
|
|
||||||
|
|
||||||
|
|
||||||
def get_service_jwt_token_for_user(
|
|
||||||
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
|
||||||
) -> str:
|
|
||||||
"""Create a short-lived service JWT for authenticating as a specific user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tenant_id: The LangSmith tenant ID to associate with the token
|
|
||||||
user_id: The LangSmith user ID to associate with the token
|
|
||||||
expiration_seconds: Token expiration time in seconds (default: 5 minutes)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JWT token string
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If X_SERVICE_AUTH_JWT_SECRET is not configured
|
|
||||||
"""
|
|
||||||
if not X_SERVICE_AUTH_JWT_SECRET:
|
|
||||||
msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys."
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
exp_datetime = datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
|
|
||||||
exp = int(exp_datetime.timestamp())
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": "unspecified",
|
|
||||||
"exp": exp,
|
|
||||||
"tenant_id": tenant_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256")
|
|
||||||
|
|
||||||
|
|
||||||
LINEAR_TEAM_TO_REPO: dict[str, dict[str, Any] | dict[str, str]] = {
|
LINEAR_TEAM_TO_REPO: dict[str, dict[str, Any] | dict[str, str]] = {
|
||||||
"Brace's test workspace": {"owner": "langchain-ai", "name": "open-swe"},
|
"Brace's test workspace": {"owner": "langchain-ai", "name": "open-swe"},
|
||||||
|
|
@ -136,100 +91,6 @@ def get_repo_config_from_team_mapping(
|
||||||
return {"owner": "langchain-ai", "name": "langchainplus"}
|
return {"owner": "langchain-ai", "name": "langchainplus"}
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
email: The user's email address
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'ls_user_id' and 'tenant_id' keys (values may be None if not found)
|
|
||||||
"""
|
|
||||||
if not LANGSMITH_API_KEY:
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
except httpx.HTTPError:
|
|
||||||
logger.debug("HTTP error getting LangSmith user info for email")
|
|
||||||
return {"ls_user_id": None, "tenant_id": None}
|
|
||||||
|
|
||||||
|
|
||||||
LANGSMITH_HOST_API_URL = os.environ.get("LANGSMITH_HOST_API_URL", "https://api.host.langchain.com")
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ls_user_id: The LangSmith user ID
|
|
||||||
tenant_id: The LangSmith tenant ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with either 'token' key or 'auth_url' key
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
service_token = get_service_jwt_token_for_user(ls_user_id, tenant_id)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"X-Service-Key": service_token,
|
|
||||||
"X-Tenant-Id": tenant_id,
|
|
||||||
"X-User-Id": ls_user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
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:
|
|
||||||
return {"token": token}
|
|
||||||
if auth_url:
|
|
||||||
return {"auth_url": auth_url}
|
|
||||||
return {"error": f"Unexpected auth result: {response_data}"}
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
|
|
||||||
return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"}
|
|
||||||
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 react_to_linear_comment(comment_id: str, emoji: str = "👀") -> bool:
|
async def react_to_linear_comment(comment_id: str, emoji: str = "👀") -> bool:
|
||||||
"""Add an emoji reaction to a Linear comment.
|
"""Add an emoji reaction to a Linear comment.
|
||||||
|
|
||||||
|
|
@ -479,59 +340,7 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
user_email = assignee.get("email")
|
user_email = assignee.get("email")
|
||||||
user_name = user_name or assignee.get("name")
|
user_name = user_name or assignee.get("name")
|
||||||
|
|
||||||
user_mention = f"@{user_name}" if user_name else ""
|
logger.info("User email for issue %s: %s", issue_id, user_email)
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"User email: %s, GITHUB_OAUTH_PROVIDER_ID set: %s",
|
|
||||||
user_email,
|
|
||||||
bool(GITHUB_OAUTH_PROVIDER_ID),
|
|
||||||
)
|
|
||||||
|
|
||||||
github_token = None
|
|
||||||
if user_email and GITHUB_OAUTH_PROVIDER_ID:
|
|
||||||
user_info = await get_ls_user_id_from_email(user_email)
|
|
||||||
ls_user_id = user_info.get("ls_user_id")
|
|
||||||
tenant_id = user_info.get("tenant_id")
|
|
||||||
logger.info(
|
|
||||||
"LangSmith user ID for %s: %s, tenant_id: %s", user_email, ls_user_id, tenant_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if ls_user_id and tenant_id:
|
|
||||||
auth_result = await get_github_token_for_user(ls_user_id, tenant_id)
|
|
||||||
logger.info("Auth result keys: %s", list(auth_result.keys()))
|
|
||||||
|
|
||||||
if "token" in auth_result:
|
|
||||||
github_token = auth_result["token"]
|
|
||||||
logger.info("GitHub token obtained for user %s", user_email)
|
|
||||||
elif "auth_url" in auth_result:
|
|
||||||
auth_url = auth_result["auth_url"]
|
|
||||||
logger.info("GitHub auth required for user %s, sending auth URL", user_email)
|
|
||||||
comment = (
|
|
||||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
|
||||||
"To allow the Open SWE agent to work on this issue, "
|
|
||||||
"please authenticate with GitHub by clicking the link below:\n\n"
|
|
||||||
f"[Authenticate with GitHub]({auth_url})\n\n"
|
|
||||||
"Once authenticated, reply to this issue mentioning @openswe to retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
await comment_on_linear_issue(issue_id, comment)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
logger.warning("Auth result has neither token nor auth_url: %s", auth_result)
|
|
||||||
else:
|
|
||||||
logger.warning("User %s not found in LangSmith workspace", user_email)
|
|
||||||
comment = (
|
|
||||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
|
||||||
f"Could not find a LangSmith account for **{user_email}**.\n\n"
|
|
||||||
"Please ensure this email is invited to the main LangSmith organization. "
|
|
||||||
"If your Linear account uses a different email than your LangSmith account, "
|
|
||||||
"you may need to update one of them to match.\n\n"
|
|
||||||
"Once your email is added to LangSmith, "
|
|
||||||
"reply to this issue mentioning @openswe to retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
await comment_on_linear_issue(issue_id, comment)
|
|
||||||
return
|
|
||||||
|
|
||||||
title = full_issue.get("title", "No title")
|
title = full_issue.get("title", "No title")
|
||||||
description = full_issue.get("description") or "No description"
|
description = full_issue.get("description") or "No description"
|
||||||
|
|
@ -667,67 +476,41 @@ async def process_linear_issue( # noqa: PLR0912, PLR0915
|
||||||
"linear_issue_number": linear_issue_number,
|
"linear_issue_number": linear_issue_number,
|
||||||
"triggering_user_name": user_name or "",
|
"triggering_user_name": user_name or "",
|
||||||
},
|
},
|
||||||
|
"user_email": user_email,
|
||||||
|
"source": "linear",
|
||||||
}
|
}
|
||||||
if github_token:
|
|
||||||
configurable["github_token_encrypted"] = encrypt_token(github_token)
|
|
||||||
|
|
||||||
logger.info("Checking if thread %s is active before creating run", thread_id)
|
logger.info("Checking if thread %s is active before creating run", thread_id)
|
||||||
thread_active = await is_thread_active(thread_id)
|
thread_active = await is_thread_active(thread_id)
|
||||||
logger.info("Thread %s active status: %s", thread_id, thread_active)
|
logger.info("Thread %s active status: %s", thread_id, thread_active)
|
||||||
|
|
||||||
if thread_active:
|
if thread_active:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Thread %s is active (busy), will queue message instead of creating run",
|
"Thread %s is active (busy), will queue message instead of creating run",
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
queued_payload = {"text": prompt, "image_urls": image_urls}
|
queued_payload = {"text": prompt, "image_urls": image_urls}
|
||||||
queued = await queue_message_for_thread(
|
queued = await queue_message_for_thread(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
message_content=queued_payload,
|
message_content=queued_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
if queued:
|
if queued:
|
||||||
logger.info(
|
logger.info("Message queued for thread %s, will be processed by middleware", thread_id)
|
||||||
"Message queued for thread %s, will be processed by middleware", thread_id
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error("Failed to queue message for thread %s", thread_id)
|
|
||||||
else:
|
else:
|
||||||
logger.info("Creating LangGraph run for thread %s", thread_id)
|
logger.error("Failed to queue message for thread %s", thread_id)
|
||||||
langgraph_client = get_client(url=LANGGRAPH_URL)
|
|
||||||
await langgraph_client.runs.create(
|
|
||||||
thread_id,
|
|
||||||
"agent",
|
|
||||||
input={"messages": [{"role": "user", "content": content_blocks}]},
|
|
||||||
config={"configurable": configurable},
|
|
||||||
if_not_exists="create",
|
|
||||||
)
|
|
||||||
logger.info("LangGraph run created successfully for thread %s", thread_id)
|
|
||||||
else:
|
else:
|
||||||
logger.warning("No GitHub token available, cannot create run for issue %s", issue_id)
|
logger.info("Creating LangGraph run for thread %s", thread_id)
|
||||||
if not user_email:
|
langgraph_client = get_client(url=LANGGRAPH_URL)
|
||||||
comment = (
|
await langgraph_client.runs.create(
|
||||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
thread_id,
|
||||||
"Could not determine the user email from this issue. "
|
"agent",
|
||||||
"Please ensure your Linear account has an email address configured.\n\n"
|
input={"messages": [{"role": "user", "content": content_blocks}]},
|
||||||
"Reply to this issue mentioning @openswe to retry."
|
config={"configurable": configurable},
|
||||||
)
|
if_not_exists="create",
|
||||||
elif not GITHUB_OAUTH_PROVIDER_ID:
|
)
|
||||||
comment = (
|
logger.info("LangGraph run created successfully for thread %s", thread_id)
|
||||||
f"❌ **Configuration Error** {user_mention}\n\n"
|
|
||||||
"The Open SWE agent is not properly configured (missing GitHub OAuth provider).\n\n"
|
|
||||||
"Please contact your administrator."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
comment = (
|
|
||||||
f"🔐 **GitHub Authentication Required** {user_mention}\n\n"
|
|
||||||
"Unable to authenticate with GitHub. "
|
|
||||||
"Please ensure you have connected your GitHub account in LangSmith.\n\n"
|
|
||||||
"Reply to this issue mentioning @openswe to retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
await comment_on_linear_issue(issue_id, comment)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
|
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue