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:
Zamil Majdy
2026-03-15 22:55:08 +07:00
parent d9c16ded65
commit 9358b525a0
6 changed files with 255 additions and 0 deletions

View File

@@ -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.
"""

View File

@@ -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, "

View File

@@ -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(),

View File

@@ -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": [],
},
),
)

View File

@@ -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} />;

View File

@@ -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>
);
}