feat: add user-scoped Currents.dev API key for e2e test investigation (#1566)

* feat: add user-scoped Currents.dev API key for e2e test investigation

Allow each user to configure their own Currents.dev API key on the
Profile Settings page. The key is encrypted at rest in a per-user
LangGraph Store namespace and feeds server-side read-only tools that
query the Currents REST API (runs, instances, projects, test results)
so agent runs can inspect e2e test failures including screenshots and
DOM snapshots.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: add pagination cursors to currents_list_project_runs

Address review feedback: forward starting_after/ending_before cursor
parameters to /projects/{projectId}/runs so the agent can paginate
beyond the first 50 results.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-17 13:59:55 -07:00 • committed by GitHub
parent e4d737e18c
commit 60b7f4677a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 652 additions and 3 deletions

View file

@ -141,6 +141,12 @@ from .thread_api import (
send_dashboard_message,
stream_dashboard_thread,
)
from .user_credentials import (
CurrentsCredentialsUpdate,
connect_currents,
disconnect_currents,
get_currents_status,
)
from .user_mappings import (
delete_mapping,
get_mapping,
@ -399,6 +405,31 @@ async def get_my_mapping(
return mapping or {}
@router.get("/my-credentials/currents")
async def get_my_currents_status(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
status = await get_currents_status(session["sub"])
return status.get("currents", {"connected": False})
@router.put("/my-credentials/currents")
async def connect_my_currents(
update: CurrentsCredentialsUpdate,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
status = await connect_currents(session["sub"], update)
return status.get("currents", {"connected": False})
@router.delete("/my-credentials/currents")
async def disconnect_my_currents(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
status = await disconnect_currents(session["sub"])
return status.get("currents", {"connected": False})
@router.get("/slack/login")
async def slack_login(
_session: dict[str, Any] = _SESSION_DEP,

View file

@ -0,0 +1,109 @@
"""Per-user third-party service credentials (Currents.dev).
Credentials are encrypted at rest with :mod:`agent.encryption` and stored in a
dedicated LangGraph Store namespace, keyed by the user's GitHub login. The
sandbox never holds these keys — they feed server-side read-only tools.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
from langgraph_sdk import get_client
from pydantic import BaseModel, field_validator
from ..encryption import decrypt_token, encrypt_token
logger = logging.getLogger(__name__)
USER_CREDENTIALS_NAMESPACE: list[str] = ["user_credentials"]
CURRENTS_KEY = "currents"
CURRENTS_API_BASE = "https://api.currents.dev/v1"
def _client():
return get_client()
def _last4(value: str) -> str:
return value[-4:] if len(value) >= 4 else value
async def _get_provider(login: str, key: str) -> dict[str, Any] | None:
try:
item = await _client().store.get_item([*USER_CREDENTIALS_NAMESPACE, login], key)
except Exception as e: # noqa: BLE001
logger.debug("user credentials lookup failed for %s/%s: %s", login, key, e)
return None
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
async def _put_provider(login: str, key: str, value: dict[str, Any]) -> None:
await _client().store.put_item([*USER_CREDENTIALS_NAMESPACE, login], key, value)
async def _delete_provider(login: str, key: str) -> None:
try:
await _client().store.delete_item([*USER_CREDENTIALS_NAMESPACE, login], key)
except Exception as e: # noqa: BLE001
logger.debug("user credentials delete failed for %s/%s: %s", login, key, e)
class CurrentsCredentialsUpdate(BaseModel):
"""Connect Currents.dev with an organization API key."""
api_key: str
@field_validator("api_key")
@classmethod
def _require_non_empty(cls, v: object) -> str:
if not isinstance(v, str) or not v.strip():
raise ValueError("api_key must be a non-empty string")
return v.strip()
async def get_currents_status(login: str) -> dict[str, Any]:
"""Return a redacted, dashboard-safe view of the user's Currents key."""
currents = await _get_provider(login, CURRENTS_KEY)
return {
"currents": {
"connected": True,
"api_key_last4": currents.get("api_key_last4", ""),
"updated_at": currents.get("updated_at"),
}
if currents
else {"connected": False},
}
async def connect_currents(login: str, update: CurrentsCredentialsUpdate) -> dict[str, Any]:
await _put_provider(
login,
CURRENTS_KEY,
{
"encrypted_api_key": encrypt_token(update.api_key),
"api_key_last4": _last4(update.api_key),
"updated_at": datetime.now(UTC).isoformat(),
},
)
return await get_currents_status(login)
async def disconnect_currents(login: str) -> dict[str, Any]:
await _delete_provider(login, CURRENTS_KEY)
return await get_currents_status(login)
async def get_currents_api_key(login: str) -> str | None:
"""Return the decrypted Currents API key, or ``None`` when not connected."""
currents = await _get_provider(login, CURRENTS_KEY)
if not isinstance(currents, dict):
return None
api_key = decrypt_token(currents.get("encrypted_api_key", ""))
return api_key or None

View file

@ -0,0 +1,178 @@
"""Server-side, read-only Currents.dev tools for e2e test investigation.
Credentials are per-user (encrypted at rest in the user-credentials Store
namespace). The tools run in the LangGraph server process and call the
Currents REST API directly — the sandbox never holds a Currents key.
The surface is intentionally read-only: fetch runs, instances, test results,
and list projects so the agent can dig into e2e test failures including
screenshots and DOM snapshots.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from langchain_core.tools import BaseTool, StructuredTool
from ..dashboard.user_credentials import CURRENTS_API_BASE, get_currents_api_key
logger = logging.getLogger(__name__)
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
def _headers(api_key: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"Accept-Encoding": "gzip",
}
async def _get(path: str, api_key: str, **params: Any) -> dict[str, Any]:
url = f"{CURRENTS_API_BASE}{path}"
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(url, headers=_headers(api_key), params=params)
resp.raise_for_status()
return resp.json()
def _make_tools(api_key: str) -> list[BaseTool]:
async def currents_list_projects(
limit: int = 10,
starting_after: str | None = None,
) -> dict[str, Any]:
"""List Currents.dev projects for your organization.
Args:
limit: Maximum number of items to return (default 10, max 50).
starting_after: Cursor for pagination.
Returns:
Dictionary with project list, or an error message.
"""
try:
params: dict[str, Any] = {"limit": max(1, min(limit, 50))}
if starting_after:
params["starting_after"] = starting_after
return await _get("/projects", api_key, **params)
except Exception as e: # noqa: BLE001
logger.warning("currents_list_projects failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
async def currents_get_run(run_id: str) -> dict[str, Any]:
"""Get a single Currents.dev test run by ID with full details.
Use this to inspect a specific e2e test run including specs,
screenshots, video URLs, and test stats.
Args:
run_id: The Currents run ID (e.g. "run_abc123").
Returns:
Dictionary with the run details, or an error message.
"""
try:
return await _get(f"/runs/{run_id}", api_key)
except Exception as e: # noqa: BLE001
logger.warning("currents_get_run failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
async def currents_find_run(
project_id: str,
ci_build_id: str | None = None,
branch: str | None = None,
) -> dict[str, Any]:
"""Find the most recent completed Currents.dev run matching criteria.
Args:
project_id: The Currents project ID (e.g. "proj_abc123").
ci_build_id: Optional CI build ID to find an exact run.
branch: Optional branch name or prefix (append * for prefix match).
Returns:
Dictionary with the run details, or an error message.
"""
try:
params: dict[str, Any] = {"projectId": project_id}
if ci_build_id:
params["ciBuildId"] = ci_build_id
if branch:
params["branch"] = branch
return await _get("/runs/find", api_key, **params)
except Exception as e: # noqa: BLE001
logger.warning("currents_find_run failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
async def currents_list_project_runs(
project_id: str,
limit: int = 10,
status: str | None = None,
branch: str | None = None,
starting_after: str | None = None,
ending_before: str | None = None,
) -> dict[str, Any]:
"""List runs for a Currents.dev project with optional filters.
Args:
project_id: The Currents project ID.
limit: Maximum number of runs to return (default 10, max 50).
status: Optional status filter: PASSED, FAILED, RUNNING, FAILING.
branch: Optional branch filter (append * for prefix match).
starting_after: Cursor for pagination (next page).
ending_before: Cursor for pagination (previous page).
Returns:
Dictionary with a list of runs, or an error message.
"""
try:
params: dict[str, Any] = {"limit": max(1, min(limit, 50))}
if status:
params["status"] = status
if branch:
params["branches[]"] = branch
if starting_after:
params["starting_after"] = starting_after
if ending_before:
params["ending_before"] = ending_before
return await _get(f"/projects/{project_id}/runs", api_key, **params)
except Exception as e: # noqa: BLE001
logger.warning("currents_list_project_runs failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
async def currents_get_instance(instance_id: str) -> dict[str, Any]:
"""Get a single Currents.dev spec file execution instance by ID.
An instance represents one spec file's execution within a run,
including detailed test results, errors, and attempt history.
Args:
instance_id: The Currents instance ID (e.g. "inst_abc123").
Returns:
Dictionary with the instance details, or an error message.
"""
try:
return await _get(f"/instances/{instance_id}", api_key)
except Exception as e: # noqa: BLE001
logger.warning("currents_get_instance failed", exc_info=True)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
return [
StructuredTool.from_function(coroutine=currents_list_projects),
StructuredTool.from_function(coroutine=currents_get_run),
StructuredTool.from_function(coroutine=currents_find_run),
StructuredTool.from_function(coroutine=currents_list_project_runs),
StructuredTool.from_function(coroutine=currents_get_instance),
]
async def load_currents_tools(login: str) -> list[BaseTool]:
"""Return read-only Currents tools when the user has connected Currents."""
api_key = await get_currents_api_key(login)
if not api_key:
return []
return _make_tools(api_key)

View file

@ -43,6 +43,7 @@ from .dashboard.agent_usage import record_agent_thread_usage
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model_pair, get_team_default_repo
from .dashboard.user_mappings import email_for_login
from .integrations.currents_tools import load_currents_tools
from .integrations.datadog_mcp import load_datadog_tools
from .integrations.langsmith import _configure_github_proxy
from .integrations.langsmith_tools import load_langsmith_tools
@ -679,6 +680,14 @@ async def get_agent(config: RunnableConfig) -> Pregel:
await _observability_authorized(config, profile_login)
)
currents_tools: list[Any] = []
if profile_login:
try:
currents_tools = await load_currents_tools(profile_login)
except Exception:
logger.warning("Failed to load Currents tools", exc_info=True)
currents_tools = []
logger.info("Returning agent with sandbox for thread %s", thread_id)
main_model = make_model(model_id, **model_kwargs)
subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
@ -710,6 +719,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
slack_read_thread_messages,
slack_thread_reply,
*observability_tools,
*currents_tools,
],
subagents=[_general_purpose_subagent(subagent_model)],
backend=backend_factory,

View file

@ -0,0 +1,124 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from agent.integrations import currents_tools
@pytest.mark.asyncio
async def test_load_currents_tools_empty_when_not_connected() -> None:
with patch.object(currents_tools, "get_currents_api_key", AsyncMock(return_value=None)):
assert await currents_tools.load_currents_tools("alice") == []
@pytest.mark.asyncio
async def test_load_currents_tools_names() -> None:
with patch.object(currents_tools, "get_currents_api_key", AsyncMock(return_value="k")):
tools = await currents_tools.load_currents_tools("alice")
assert {t.name for t in tools} == {
"currents_list_projects",
"currents_get_run",
"currents_find_run",
"currents_list_project_runs",
"currents_get_instance",
}
@pytest.mark.asyncio
async def test_currents_get_run_success() -> None:
payload = {"status": "OK", "data": {"runId": "run_123", "status": "failed"}}
with patch.object(currents_tools, "_get", AsyncMock(return_value=payload)):
tools = currents_tools._make_tools("test-key")
get_run = next(t for t in tools if t.name == "currents_get_run")
result = await get_run.ainvoke({"run_id": "run_123"})
assert result == payload
@pytest.mark.asyncio
async def test_currents_get_run_error() -> None:
with patch.object(currents_tools, "_get", AsyncMock(side_effect=RuntimeError("boom"))):
tools = currents_tools._make_tools("bad-key")
get_run = next(t for t in tools if t.name == "currents_get_run")
result = await get_run.ainvoke({"run_id": "run_123"})
assert result["success"] is False
assert "boom" in result["error"]
@pytest.mark.asyncio
async def test_currents_list_projects_caps_limit() -> None:
captured: dict[str, object] = {}
async def fake_get(path: str, api_key: str, **params):
captured["path"] = path
captured["limit"] = params.get("limit")
return {"status": "OK", "data": []}
with patch.object(currents_tools, "_get", side_effect=fake_get):
tools = currents_tools._make_tools("k")
list_projects = next(t for t in tools if t.name == "currents_list_projects")
result = await list_projects.ainvoke({"limit": 9999})
assert result["status"] == "OK"
assert captured["limit"] == 50
assert captured["path"] == "/projects"
@pytest.mark.asyncio
async def test_currents_find_run_passes_params() -> None:
captured: dict[str, object] = {}
async def fake_get(path: str, api_key: str, **params):
captured["path"] = path
captured.update(params)
return {"status": "OK", "data": {"runId": "r1"}}
with patch.object(currents_tools, "_get", side_effect=fake_get):
tools = currents_tools._make_tools("k")
find_run = next(t for t in tools if t.name == "currents_find_run")
result = await find_run.ainvoke(
{"project_id": "proj_1", "ci_build_id": "build-42", "branch": "main"}
)
assert result["status"] == "OK"
assert captured["path"] == "/runs/find"
assert captured["projectId"] == "proj_1"
assert captured["ciBuildId"] == "build-42"
assert captured["branch"] == "main"
@pytest.mark.asyncio
async def test_currents_list_project_runs_caps_limit() -> None:
captured: dict[str, object] = {}
async def fake_get(path: str, api_key: str, **params):
captured["path"] = path
captured.update(params)
return {"status": "OK", "data": []}
with patch.object(currents_tools, "_get", side_effect=fake_get):
tools = currents_tools._make_tools("k")
list_runs = next(t for t in tools if t.name == "currents_list_project_runs")
await list_runs.ainvoke(
{
"project_id": "proj_1",
"limit": 9999,
"status": "FAILED",
"branch": "main",
"starting_after": "cursor-abc",
}
)
assert captured["path"] == "/projects/proj_1/runs"
assert captured["limit"] == 50
assert captured["status"] == "FAILED"
assert captured["branches[]"] == "main"
assert captured["starting_after"] == "cursor-abc"
@pytest.mark.asyncio
async def test_currents_get_instance() -> None:
payload = {"status": "OK", "data": {"instanceId": "inst_1"}}
with patch.object(currents_tools, "_get", AsyncMock(return_value=payload)):
tools = currents_tools._make_tools("k")
get_instance = next(t for t in tools if t.name == "currents_get_instance")
result = await get_instance.ainvoke({"instance_id": "inst_1"})
assert result == payload

View file

@ -0,0 +1,95 @@
from __future__ import annotations
from typing import Any
import pytest
from cryptography.fernet import Fernet
from pydantic import ValidationError
from agent.dashboard import user_credentials as uc
from agent.dashboard.user_credentials import CurrentsCredentialsUpdate
class _FakeStore:
def __init__(self) -> None:
self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {}
async def get_item(self, namespace: list[str], key: str):
value = self.items.get((tuple(namespace), key))
return {"value": value} if value is not None else None
async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None:
self.items[(tuple(namespace), key)] = value
async def delete_item(self, namespace: list[str], key: str) -> None:
self.items.pop((tuple(namespace), key), None)
class _FakeClient:
def __init__(self, store: _FakeStore) -> None:
self.store = store
@pytest.fixture()
def fake_store(monkeypatch: pytest.MonkeyPatch) -> _FakeStore:
store = _FakeStore()
monkeypatch.setattr(uc, "_client", lambda: _FakeClient(store))
monkeypatch.setenv("TOKEN_ENCRYPTION_KEY", Fernet.generate_key().decode())
return store
class TestValidators:
def test_empty_key_rejected(self) -> None:
with pytest.raises(ValidationError):
CurrentsCredentialsUpdate(api_key="")
def test_whitespace_key_rejected(self) -> None:
with pytest.raises(ValidationError):
CurrentsCredentialsUpdate(api_key=" ")
def test_key_trimmed(self) -> None:
u = CurrentsCredentialsUpdate(api_key=" secret ")
assert u.api_key == "secret"
@pytest.mark.asyncio
async def test_currents_roundtrip_and_redaction(fake_store: _FakeStore) -> None:
status = await uc.connect_currents(
"alice", CurrentsCredentialsUpdate(api_key="secret-currents-key-1234")
)
assert status["currents"]["connected"] is True
assert status["currents"]["api_key_last4"] == "1234"
record = fake_store.items[(("user_credentials", "alice"), "currents")]
assert record["encrypted_api_key"] != "secret-currents-key-1234"
api_key = await uc.get_currents_api_key("alice")
assert api_key == "secret-currents-key-1234"
after = await uc.disconnect_currents("alice")
assert after["currents"]["connected"] is False
assert await uc.get_currents_api_key("alice") is None
@pytest.mark.asyncio
async def test_currents_isolation_between_users(fake_store: _FakeStore) -> None:
await uc.connect_currents("alice", CurrentsCredentialsUpdate(api_key="alice-key-abcd"))
await uc.connect_currents("bob", CurrentsCredentialsUpdate(api_key="bob-key-wxyz"))
assert await uc.get_currents_api_key("alice") == "alice-key-abcd"
assert await uc.get_currents_api_key("bob") == "bob-key-wxyz"
await uc.disconnect_currents("alice")
assert await uc.get_currents_api_key("alice") is None
assert await uc.get_currents_api_key("bob") == "bob-key-wxyz"
@pytest.mark.asyncio
async def test_currents_status_when_not_connected(fake_store: _FakeStore) -> None:
status = await uc.get_currents_status("nobody")
assert status["currents"]["connected"] is False
@pytest.mark.asyncio
async def test_get_currents_api_key_none_when_not_connected(fake_store: _FakeStore) -> None:
assert await uc.get_currents_api_key("nobody") is None

View file

@ -161,6 +161,16 @@ export interface LangSmithConnectBody {
endpoint?: string | null
}
export interface CurrentsCredentialStatus {
connected: boolean
api_key_last4?: string
updated_at?: string | null
}
export interface CurrentsConnectBody {
api_key: string
}
export interface UserMapping {
github_login: string
work_email: string
@ -560,6 +570,17 @@ export const api = {
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
method: "DELETE",
}),
getMyCurrentsStatus: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents"),
connectCurrents: (body: CurrentsConnectBody) =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "PUT",
body: JSON.stringify(body),
}),
disconnectCurrents: () =>
request<CurrentsCredentialStatus>("/my-credentials/currents", {
method: "DELETE",
}),
listEnabledReviewRepos: () =>
request<{ repos: Array<string> }>("/enabled-review-repos"),
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>

View file

@ -1,11 +1,12 @@
import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { IoLogoSlack } from "react-icons/io5"
import type { SessionUser } from "@/lib/api"
import type { CurrentsConnectBody, SessionUser } from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
@ -187,6 +188,83 @@ function NotificationsSection() {
)
}
function CurrentsCredentialsSection() {
const qc = useQueryClient()
const creds = useQuery({
queryKey: ["myCurrents"],
queryFn: api.getMyCurrentsStatus,
})
const [error, setError] = useState<string | null>(null)
const [apiKey, setApiKey] = useState("")
const onSuccess = () => {
qc.invalidateQueries({ queryKey: ["myCurrents"] })
setError(null)
setApiKey("")
}
const onError = (e: Error) => setError(e.message)
const connect = useMutation({
mutationFn: (body: CurrentsConnectBody) => api.connectCurrents(body),
onSuccess,
onError,
})
const disconnect = useMutation({
mutationFn: () => api.disconnectCurrents(),
onSuccess,
onError,
})
const connected = creds.data?.connected
return (
<SettingsSection
title="Currents.dev"
description="Connect your personal Currents API key to let agent runs inspect e2e test results — failed specs, error traces, screenshots, and DOM snapshots. The key is encrypted at rest and scoped to your account only."
>
<SettingsRow
label="API key"
description={
connected
? `Connected · key ••••${creds.data?.api_key_last4 ?? ""}`
: "Find your API key in Currents under Organization → API & Record Keys."
}
control={
connected ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
>
Disconnect
</Button>
) : (
<div className="flex flex-col items-end gap-2">
<Input
className="w-56"
placeholder="Currents API key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={creds.isLoading}
/>
<Button
size="sm"
onClick={() => connect.mutate({ api_key: apiKey.trim() })}
disabled={connect.isPending || !apiKey.trim()}
>
Connect
</Button>
</div>
)
}
/>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
function MySettingsPage() {
const session = useSession()
const qc = useQueryClient()
@ -217,7 +295,8 @@ function MySettingsPage() {
}
const firstModel = options.data?.models[0]
const fallbackModel = options.data?.default_agent_model ?? firstModel?.id ?? ""
const fallbackModel =
options.data?.default_agent_model ?? firstModel?.id ?? ""
const fallbackEffort =
options.data?.default_agent_reasoning_effort ??
firstModel?.default_effort ??
@ -287,6 +366,8 @@ function MySettingsPage() {
<NotificationsSection />
<CurrentsCredentialsSection />
<SettingsSection title="Account">
<SettingsRow
label="Sign out"