mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(copilot): inject GH_TOKEN and add connect_integration tool for missing GitHub credentials
When the user has connected GitHub, GH_TOKEN is automatically injected into the Claude Agent SDK subprocess environment so `gh` CLI works without any manual auth step. When GitHub is not connected, the copilot can call the new connect_integration(provider="github") MCP tool, which surfaces the same credentials setup card used by GitHub blocks — letting the user connect their account inline without leaving the chat. - backend: _get_github_token_for_user() fetches the user's GitHub credentials (OAuth2 or API key) and injects GH_TOKEN + GITHUB_TOKEN into sdk_env before the Claude Agent SDK subprocess starts - backend: ConnectIntegrationTool MCP tool returns a SetupRequirementsResponse for any known provider (github for now) - backend: prompting.py documents the gh CLI / connect_integration flow in _SHARED_TOOL_NOTES so the copilot knows when to call it - frontend: ConnectIntegrationTool component renders the existing SetupRequirementsCard with a tailored retry instruction - frontend: MessagePartRenderer dispatches tool-connect_integration to the new component
This commit is contained in:
@@ -61,6 +61,14 @@ an @@agptfile: expansion), the string will be parsed into the correct type.
|
||||
### Sub-agent tasks
|
||||
- When using the Task tool, NEVER set `run_in_background` to true.
|
||||
All tasks must run in the foreground.
|
||||
|
||||
### GitHub CLI (`gh`)
|
||||
- If the user has connected their GitHub account, `GH_TOKEN` is automatically
|
||||
set in the environment — `gh` CLI commands work without any login step.
|
||||
- If `gh` fails with an authentication error (e.g. "authentication required"
|
||||
or exit code 4), call `connect_integration(provider="github")` to surface
|
||||
the GitHub credentials setup card so the user can connect their account.
|
||||
Once connected, retry the operation.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -29,8 +29,10 @@ from langfuse import propagate_attributes
|
||||
from langsmith.integrations.claude_agent_sdk import configure_claude_agent_sdk
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.data.model import APIKeyCredentials, OAuth2Credentials
|
||||
from backend.data.redis_client import get_redis_async
|
||||
from backend.executor.cluster_lock import AsyncClusterLock
|
||||
from backend.integrations.credentials_store import IntegrationCredentialsStore
|
||||
from backend.util.exceptions import NotFoundError
|
||||
from backend.util.prompt import compress_context
|
||||
from backend.util.settings import Settings
|
||||
@@ -172,6 +174,27 @@ def _resolve_sdk_model() -> str | None:
|
||||
return model
|
||||
|
||||
|
||||
async def _get_github_token_for_user(user_id: str) -> str | None:
|
||||
"""Return the user's GitHub access token, or None if not connected.
|
||||
|
||||
Checks the credentials store for any GitHub API key or OAuth token.
|
||||
The first matching credential is returned; preference is given to OAuth
|
||||
tokens since they carry scope information.
|
||||
"""
|
||||
store = IntegrationCredentialsStore()
|
||||
try:
|
||||
creds_list = await store.get_creds_by_provider(user_id, "github")
|
||||
except Exception:
|
||||
logger.debug("Failed to fetch GitHub credentials for user %s", user_id)
|
||||
return None
|
||||
for creds in creds_list:
|
||||
if isinstance(creds, OAuth2Credentials):
|
||||
return creds.access_token.get_secret_value()
|
||||
if isinstance(creds, APIKeyCredentials):
|
||||
return creds.api_key.get_secret_value()
|
||||
return None
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _validate_claude_code_subscription() -> None:
|
||||
"""Validate Claude CLI is installed and responds to ``--version``.
|
||||
@@ -854,6 +877,14 @@ async def stream_chat_completion_sdk(
|
||||
|
||||
# Fail fast when no API credentials are available at all.
|
||||
sdk_env = _build_sdk_env(session_id=session_id, user_id=user_id)
|
||||
if user_id:
|
||||
gh_token = await _get_github_token_for_user(user_id)
|
||||
if gh_token:
|
||||
# Inject GitHub token so `gh` CLI works without manual auth.
|
||||
# GH_TOKEN is the canonical env var; GITHUB_TOKEN is kept for
|
||||
# tools (e.g. git, actions) that check the legacy name.
|
||||
sdk_env["GH_TOKEN"] = gh_token
|
||||
sdk_env["GITHUB_TOKEN"] = gh_token
|
||||
if not config.api_key and not config.use_claude_code_subscription:
|
||||
raise RuntimeError(
|
||||
"No API key configured. Set OPEN_ROUTER_API_KEY, "
|
||||
|
||||
@@ -12,6 +12,7 @@ from .agent_browser import BrowserActTool, BrowserNavigateTool, BrowserScreensho
|
||||
from .agent_output import AgentOutputTool
|
||||
from .base import BaseTool
|
||||
from .bash_exec import BashExecTool
|
||||
from .connect_integration import ConnectIntegrationTool
|
||||
from .continue_run_block import ContinueRunBlockTool
|
||||
from .create_agent import CreateAgentTool
|
||||
from .customize_agent import CustomizeAgentTool
|
||||
@@ -84,6 +85,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
||||
"browser_screenshot": BrowserScreenshotTool(),
|
||||
# Sandboxed code execution (bubblewrap)
|
||||
"bash_exec": BashExecTool(),
|
||||
"connect_integration": ConnectIntegrationTool(),
|
||||
# Persistent workspace tools (cloud storage, survives across sessions)
|
||||
# Feature request tools
|
||||
"search_feature_requests": SearchFeatureRequestsTool(),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tool for prompting the user to connect a required integration.
|
||||
|
||||
When the copilot encounters an authentication failure (e.g. `gh` CLI returns
|
||||
"authentication required"), it calls this tool to surface the credentials
|
||||
setup card in the chat — the same UI that appears when a GitHub block runs
|
||||
without configured credentials.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.blocks.github._auth import GITHUB_OAUTH_IS_CONFIGURED
|
||||
from backend.copilot.model import ChatSession
|
||||
from backend.copilot.tools.models import (
|
||||
ResponseType,
|
||||
SetupInfo,
|
||||
SetupRequirementsResponse,
|
||||
ToolResponseBase,
|
||||
UserReadiness,
|
||||
)
|
||||
|
||||
from .base import BaseTool
|
||||
|
||||
# Registry of known providers: name + supported credential types.
|
||||
# Extend this dict when adding support for new integrations.
|
||||
_PROVIDER_INFO: dict[str, dict[str, Any]] = {
|
||||
"github": {
|
||||
"name": "GitHub",
|
||||
"types": (["api_key", "oauth2"] if GITHUB_OAUTH_IS_CONFIGURED else ["api_key"]),
|
||||
"scopes": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConnectIntegrationTool(BaseTool):
|
||||
"""Surface the credentials setup UI when an integration is not connected."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "connect_integration"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Prompt the user to connect a required integration (e.g. GitHub). "
|
||||
"Call this when an external CLI or API call fails because the user "
|
||||
"has not connected the relevant account. "
|
||||
"The tool surfaces a credentials setup card in the chat so the user "
|
||||
"can authenticate without leaving the page. "
|
||||
"After the user connects the account, retry the operation — the token "
|
||||
"will be automatically available in the execution environment."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Integration provider slug, e.g. 'github'. "
|
||||
"Must be one of the supported providers."
|
||||
),
|
||||
"enum": list(_PROVIDER_INFO.keys()),
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Brief explanation of why the integration is needed, "
|
||||
"shown to the user in the setup card."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["provider"],
|
||||
}
|
||||
|
||||
@property
|
||||
def requires_auth(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _execute(
|
||||
self,
|
||||
user_id: str | None,
|
||||
session: ChatSession,
|
||||
**kwargs: Any,
|
||||
) -> ToolResponseBase:
|
||||
del user_id # not needed; setup card is user-agnostic
|
||||
session_id = session.session_id if session else None
|
||||
provider: str = (kwargs.get("provider") or "").strip().lower()
|
||||
reason: str = (kwargs.get("reason") or "").strip()
|
||||
|
||||
info = _PROVIDER_INFO.get(provider)
|
||||
if not info:
|
||||
supported = ", ".join(f"'{p}'" for p in _PROVIDER_INFO)
|
||||
return SetupRequirementsResponse(
|
||||
type=ResponseType.SETUP_REQUIREMENTS,
|
||||
message=(
|
||||
f"Unknown provider '{provider}'. "
|
||||
f"Supported providers: {supported}."
|
||||
),
|
||||
session_id=session_id,
|
||||
setup_info=SetupInfo(
|
||||
agent_id=f"connect_{provider}",
|
||||
agent_name=f"{provider.title()} Integration",
|
||||
),
|
||||
)
|
||||
|
||||
provider_name: str = info["name"]
|
||||
supported_types: list[str] = info["types"]
|
||||
scopes: list[str] = info["scopes"]
|
||||
field_key = f"{provider}_credentials"
|
||||
|
||||
message_parts = [
|
||||
f"To continue, please connect your {provider_name} account.",
|
||||
]
|
||||
if reason:
|
||||
message_parts.append(reason)
|
||||
|
||||
missing_credentials: dict[str, Any] = {
|
||||
field_key: {
|
||||
"id": field_key,
|
||||
"title": f"{provider_name} Credentials",
|
||||
"provider": provider,
|
||||
"provider_name": provider_name,
|
||||
"type": supported_types[0],
|
||||
"types": supported_types,
|
||||
"scopes": scopes,
|
||||
}
|
||||
}
|
||||
|
||||
return SetupRequirementsResponse(
|
||||
type=ResponseType.SETUP_REQUIREMENTS,
|
||||
message=" ".join(message_parts),
|
||||
session_id=session_id,
|
||||
setup_info=SetupInfo(
|
||||
agent_id=f"connect_{provider}",
|
||||
agent_name=f"{provider_name} Integration",
|
||||
user_readiness=UserReadiness(
|
||||
has_all_credentials=False,
|
||||
missing_credentials=missing_credentials,
|
||||
ready_to_run=False,
|
||||
),
|
||||
requirements={
|
||||
"credentials": [missing_credentials[field_key]],
|
||||
"inputs": [],
|
||||
"execution_modes": [],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { ExclamationMarkIcon } from "@phosphor-icons/react";
|
||||
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { useState } from "react";
|
||||
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
|
||||
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
|
||||
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
|
||||
import {
|
||||
@@ -129,6 +130,8 @@ export function MessagePartRenderer({ part, messageID, partIndex }: Props) {
|
||||
case "tool-search_docs":
|
||||
case "tool-get_doc_page":
|
||||
return <SearchDocsTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-connect_integration":
|
||||
return <ConnectIntegrationTool key={key} part={part as ToolUIPart} />;
|
||||
case "tool-run_block":
|
||||
case "tool-continue_run_block":
|
||||
return <RunBlockTool key={key} part={part as ToolUIPart} />;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { SetupRequirementsCard } from "../RunBlock/components/SetupRequirementsCard/SetupRequirementsCard";
|
||||
|
||||
interface Props {
|
||||
part: ToolUIPart;
|
||||
}
|
||||
|
||||
function parseOutput(raw: unknown): SetupRequirementsResponse | null {
|
||||
try {
|
||||
const text = typeof raw === "string" ? raw : JSON.stringify(raw ?? "");
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === "object" && "setup_info" in parsed) {
|
||||
return parsed as SetupRequirementsResponse;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ConnectIntegrationTool({ part }: Props) {
|
||||
const isStreaming =
|
||||
part.state === "input-streaming" || part.state === "input-available";
|
||||
|
||||
const output =
|
||||
part.state === "output-available"
|
||||
? parseOutput((part as { output?: unknown }).output)
|
||||
: null;
|
||||
|
||||
const providerName =
|
||||
output?.setup_info?.agent_name ??
|
||||
(part as { input?: { provider?: string } }).input?.provider ??
|
||||
"integration";
|
||||
|
||||
const label = isStreaming
|
||||
? `Connecting ${providerName}…`
|
||||
: output
|
||||
? `Connect ${output.setup_info.agent_name}`
|
||||
: `Connect ${providerName}`;
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MorphingTextAnimation text={label} />
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className="mt-2">
|
||||
<SetupRequirementsCard
|
||||
output={output}
|
||||
credentialsLabel="Integration credentials"
|
||||
retryInstruction="I've connected my account. Please continue."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user