mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
* feat(ui): add Agents chat UI ported from open-swe-app
Introduce a Cursor-style Agents surface separate from the dashboard, with ported chat/diff components and mock thread data until LangGraph APIs land.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(dashboard): wire Agents UI to LangGraph thread APIs
Add dashboard thread list/detail/run/message/stream endpoints with a LangGraph message adapter, dashboard OAuth auth for runs, and TanStack Query hooks replacing mock data.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(dashboard): single agent reply per turn in Agents UI
Use UUID thread IDs LangGraph accepts, skip confirming_completion for
dashboard threads, and merge adapter agent messages so duplicate bubbles
do not render.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(ui): polish Agents UI with floating prompt and layout cleanup
Remove no-op chrome (git panel, headers, sidebar search), port CloudPromptBar
from open-swe-app, and refine chat layout so messages scroll behind the input.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(agent): patch deepagents reducer for None messages on checkpoint replay
LangGraph thread state could 500 when cancelled runs left messages as None.
Apply the reducer guard before graph import, fall back to metadata in the
dashboard API, and adjust Agents prompt bar layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(ui): unify sidebar user menu and clean up Agents UI navigation
Extract SidebarUserMenu so the dashboard and Agents sidebars render the
same profile button, drop the redundant Agents nav row in favor of the
existing Back to Agents link, add the open-swe logo header to the Agents
sidebar, flatten the New Agent button, and cap the home screen run list
to keep the prompt input in view.
* feat(ui): resizable/collapsible sidebar shared across dashboard and Agents
Add a useSidebarLayout hook + SidebarFrame wrapper so both sidebars
share a persisted width (default 260px, drag to resize, 200-420 range)
and a collapse toggle that hides the panel and surfaces a floating
reopen button. Also adds a DELETE /threads/{id} endpoint and an X-on-
hover thread delete control in the Agents sidebar.
* feat(ui): instant user message and busy indicator on Agents transition
Stash submitted prompts in sessionStorage, pre-populate the new thread
detail cache, and merge pending prompts into the rendered message list
so the Agents page renders the user bubble plus the existing thinking
spinner immediately instead of flashing a skeleton and "Agent is
starting" while the run boots.
* feat(ui): token-stream agent replies in the Agents thread view
Opt the LangGraph runs into messages-tuple streaming and forward those
events through the existing SSE channel. The frontend now applies
AIMessageChunk deltas directly to the cached thread (cancelling any
in-flight refetch first so optimistic tokens are not clobbered) and
keeps positional pending prompts so the user bubble stays in the right
place while the agent streams its reply.
* fix(dashboard): await threads.join_stream before iterating
threads.join_stream is async def returning an AsyncIterator, so it must
be awaited before async for. The SSE endpoint was raising
TypeError: 'async for' requires an object with __aiter__ method, got
coroutine on every connection.
* fix(dashboard): drop messages-tuple stream_mode that broke thinking-mode tool turns
Setting stream_mode=["values","messages-tuple","updates"] on
runs.create forces langchain_anthropic into streaming, and on the
second model call (after tool execution) its serialized thinking
blocks come back malformed, so Anthropic rejects the request with
'messages.1.content.0.thinking.thinking: Field required'. Revert to
the default stream_mode so claude-opus thinking + tool use runs to
completion. The frontend keeps the messages-event handler in place
as a no-op fallback for when streaming is re-enabled.
* feat(agents): per-thread model picker wired through to the run
Add optional model_id/effort to the create-thread and send-message
request bodies, forward them as agent_model_id/agent_effort in the
LangGraph run configurable, and record the resolved choice in thread
metadata so the UI can show the model the run is actually using.
get_agent now picks the per-thread override last (highest priority over
team default + profile override) and falls back gracefully when it is
absent or unsupported.
The frontend prompt bar becomes a controlled component fed by a
shared useModelOptions hook (options + profile -> defaultSelection).
AgentsHome seeds the picker from the user's profile default; the
thread view seeds from the thread's recorded model/effort and lets
each follow-up retarget the run.
* refactor(ui): align Agents prompt bar layout with open-swe-app PromptBar
Drop the absolute-positioned send button, restore the original
px-4 py-3.5 min-h-[106px] flex-col container, and move the model
picker into a mt-auto pt-2 footer row so the placeholder text and
the model selector share the same horizontal padding.
* chore: fix lint/format CI failures
Remove unused imports and reformat two files flagged by ruff.
* fix(tests): stop messages-reducer patch tests from polluting the suite
Restore agent modules after reducer patch tests and import LangSmithSandbox
from agent.server in proxy refresh tests so isinstance checks stay valid.
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
18 KiB
Python
447 lines
18 KiB
Python
"""GitHub OAuth and LangSmith authentication utilities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any, Literal
|
|
|
|
import httpx
|
|
import jwt
|
|
from langgraph.config import get_config
|
|
from langgraph.graph.state import RunnableConfig
|
|
from langgraph_sdk import get_client
|
|
|
|
from ..encryption import encrypt_token
|
|
from .github_app import get_github_app_installation_token_with_expiry
|
|
from .github_token import get_github_token_from_thread
|
|
from .github_user_email_map import GITHUB_USER_EMAIL_MAP
|
|
from .linear import comment_on_linear_issue
|
|
from .slack import post_slack_ephemeral_message, post_slack_thread_reply
|
|
|
|
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", "")
|
|
USER_ID_API_KEY_MAP = os.environ.get("USER_ID_API_KEY_MAP", "")
|
|
|
|
logger.debug(
|
|
"Auth env snapshot: LANGSMITH_API_KEY_PROD=%s LANGSMITH_ENDPOINT=%s "
|
|
"LANGSMITH_HOST_API_URL=%s GITHUB_OAUTH_PROVIDER_ID=%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",
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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"
|
|
|
|
|
|
def get_secret_key_for_user(
|
|
user_id: str, tenant_id: str, expiration_seconds: int = 300
|
|
) -> tuple[str, Literal["service", "api_key"]]:
|
|
"""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": "unspecified",
|
|
"exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds),
|
|
"user_id": user_id,
|
|
"tenant_id": tenant_id,
|
|
}
|
|
return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256"), "service"
|
|
|
|
|
|
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 Exception as e:
|
|
logger.exception("Error getting LangSmith user info for email: %s", e)
|
|
return {"ls_user_id": None, "tenant_id": None}
|
|
|
|
|
|
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
|
|
|
|
|
|
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,
|
|
"X-User-Id": ls_user_id,
|
|
}
|
|
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
|
|
|
|
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:
|
|
result: dict[str, Any] = {"token": token}
|
|
expires_at = _extract_expires_at(response_data)
|
|
if expires_at:
|
|
result["expires_at"] = expires_at
|
|
return result
|
|
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."""
|
|
config = get_config()
|
|
configurable = config.get("configurable", {})
|
|
|
|
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
|
|
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
|
|
triggering_user_id = (
|
|
slack_thread.get("triggering_user_id") if isinstance(slack_thread, dict) else None
|
|
)
|
|
if channel_id and thread_ts:
|
|
if isinstance(triggering_user_id, str) and triggering_user_id:
|
|
logger.info(
|
|
"Posting auth failure ephemeral reply to Slack user %s in channel %s thread %s",
|
|
triggering_user_id,
|
|
channel_id,
|
|
thread_ts,
|
|
)
|
|
sent = await post_slack_ephemeral_message(
|
|
channel_id=channel_id,
|
|
user_id=triggering_user_id,
|
|
text=message,
|
|
thread_ts=thread_ts,
|
|
)
|
|
if sent:
|
|
return
|
|
logger.warning(
|
|
"Failed to post ephemeral auth failure reply for Slack user %s; falling back to thread reply",
|
|
triggering_user_id,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Missing Slack triggering_user_id for auth failure reply; falling back to thread reply",
|
|
)
|
|
logger.info(
|
|
"Posting auth failure reply to Slack channel %s thread %s",
|
|
channel_id,
|
|
thread_ts,
|
|
)
|
|
await post_slack_thread_reply(channel_id, thread_ts, message)
|
|
return
|
|
if source == "github":
|
|
logger.warning(
|
|
"Auth failure for GitHub-triggered run (no token to post comment): %s", message
|
|
)
|
|
return
|
|
raise ValueError(f"Unknown source: {source}")
|
|
|
|
|
|
async def persist_encrypted_github_token(
|
|
thread_id: str, token: str, expires_at: str | None = None
|
|
) -> str:
|
|
"""Encrypt a GitHub token and store it (and its expiry) on the thread metadata."""
|
|
encrypted = encrypt_token(token)
|
|
metadata: dict[str, Any] = {
|
|
"github_token_encrypted": encrypted,
|
|
"github_token_expires_at": expires_at,
|
|
}
|
|
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
|
return encrypted
|
|
|
|
|
|
async def save_encrypted_token_from_email(
|
|
email: str | None,
|
|
source: str,
|
|
) -> tuple[str, str, str | None]:
|
|
"""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:
|
|
account_label = _source_account_label(source)
|
|
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. "
|
|
f"If your {account_label} 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, "
|
|
f"{_retry_instruction(source)}"
|
|
)
|
|
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:
|
|
work_item_label = _work_item_label(source)
|
|
auth_link_text = _auth_link_text(source, auth_url)
|
|
message = (
|
|
"🔐 **GitHub Authentication Required**\n\n"
|
|
f"To allow the Open SWE agent to work on this {work_item_label}, "
|
|
"please authenticate with GitHub by clicking the link below:\n\n"
|
|
f"{auth_link_text}\n\n"
|
|
f"{_retry_instruction(source)}"
|
|
)
|
|
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}")
|
|
|
|
expires_at = auth_result.get("expires_at") if isinstance(auth_result, dict) else None
|
|
encrypted = await persist_encrypted_github_token(thread_id, token, expires_at=expires_at)
|
|
return token, encrypted, expires_at
|
|
|
|
|
|
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str, str | None]:
|
|
"""Get a GitHub App installation token and persist it for the thread."""
|
|
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
|
|
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
|
|
)
|
|
encrypted = await persist_encrypted_github_token(thread_id, bot_token, expires_at=expires_at)
|
|
return bot_token, encrypted, expires_at
|
|
|
|
|
|
async def resolve_github_token(
|
|
config: RunnableConfig, thread_id: str
|
|
) -> tuple[str, str, str | None]:
|
|
"""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).
|
|
|
|
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.
|
|
|
|
Returns:
|
|
(github_token, new_encrypted, expires_at) tuple. ``expires_at`` is the
|
|
ISO-8601 expiry persisted alongside the ciphertext, or ``None``.
|
|
|
|
Raises:
|
|
RuntimeError: If source is missing or token resolution fails.
|
|
"""
|
|
if is_bot_token_only_mode():
|
|
return await _resolve_bot_installation_token(thread_id)
|
|
|
|
configurable = config["configurable"]
|
|
source = configurable.get("source")
|
|
if not source:
|
|
logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id)
|
|
raise RuntimeError(f"GitHub auth failed for thread {thread_id}: missing source")
|
|
|
|
try:
|
|
if source == "github":
|
|
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
|
|
thread_id
|
|
)
|
|
if cached_token and cached_encrypted:
|
|
return cached_token, cached_encrypted, cached_expires_at
|
|
github_login = configurable.get("github_login")
|
|
email = GITHUB_USER_EMAIL_MAP.get(github_login or "")
|
|
if not email:
|
|
raise ValueError(f"No email mapping found for GitHub user '{github_login}'")
|
|
return await save_encrypted_token_from_email(email, source)
|
|
if source == "dashboard":
|
|
cached_token, cached_encrypted, cached_expires_at = await get_github_token_from_thread(
|
|
thread_id
|
|
)
|
|
if cached_token and cached_encrypted:
|
|
return cached_token, cached_encrypted, cached_expires_at
|
|
github_login = configurable.get("github_login")
|
|
if not isinstance(github_login, str) or not github_login.strip():
|
|
raise ValueError("missing github_login for dashboard run")
|
|
from ..dashboard.profiles import OAUTH_TOKENS_NAMESPACE, get_valid_access_token
|
|
from ..dashboard.profiles import _get_value as get_oauth_record
|
|
|
|
token = await get_valid_access_token(github_login.strip())
|
|
if not token:
|
|
raise ValueError("github token unavailable, re-login required")
|
|
record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, github_login.strip())
|
|
expires_at = record.get("token_expires_at") if isinstance(record, dict) else None
|
|
encrypted = await persist_encrypted_github_token(
|
|
thread_id,
|
|
token,
|
|
expires_at=expires_at if isinstance(expires_at, str) else None,
|
|
)
|
|
return token, encrypted, expires_at if isinstance(expires_at, str) else None
|
|
return await save_encrypted_token_from_email(configurable.get("user_email"), source)
|
|
except ValueError as exc:
|
|
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
|
|
raise RuntimeError(str(exc)) from exc
|