fix(backend): github token not working for v1 conversations (#11814)

This commit is contained in:
Hiep Le 2025-11-26 01:04:45 +07:00 committed by GitHub
parent c58e2157ea
commit b532a5e7fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 57 additions and 14 deletions

View File

@ -73,12 +73,12 @@ from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.tools.preset.default import get_default_agent
from openhands.tools.preset.planning import get_planning_agent
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_logger = logging.getLogger(__name__)
GIT_TOKEN = 'GIT_TOKEN'
@dataclass
@ -97,6 +97,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
httpx_client: httpx.AsyncClient
web_url: str | None
access_token_hard_timeout: timedelta | None
app_mode: str | None = None
keycloak_auth_cookie: str | None = None
async def search_app_conversations(
self,
@ -529,6 +531,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Set up a secret for the git token
secrets = await self.user_context.get_secrets()
if git_provider:
secret_name = f'{git_provider.name}_TOKEN'
if self.web_url:
# If there is a web url, then we create an access token to access it.
# For security reasons, we are explicit here - only this user, and
@ -540,9 +543,15 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
},
expires_in=self.access_token_hard_timeout,
)
secrets[GIT_TOKEN] = LookupSecret(
headers = {'X-Access-Token': access_token}
# Include keycloak_auth cookie in headers if app_mode is SaaS
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
headers={'X-Access-Token': access_token},
headers=headers,
)
else:
# If there is no URL specified where the sandbox can access the app server
@ -550,7 +559,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# on the type, this may eventually expire.
static_token = await self.user_context.get_latest_token(git_provider)
if static_token:
secrets[GIT_TOKEN] = StaticSecret(value=static_token)
secrets[secret_name] = StaticSecret(value=static_token)
workspace = LocalWorkspace(working_dir=working_dir)
@ -841,6 +850,21 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
if isinstance(sandbox_service, DockerSandboxService):
web_url = f'http://host.docker.internal:{sandbox_service.host_port}'
# Get app_mode and keycloak_auth cookie for SaaS mode
app_mode = None
keycloak_auth_cookie = None
try:
from openhands.server.shared import server_config
app_mode = (
server_config.app_mode.value if server_config.app_mode else None
)
if request and server_config.app_mode == AppMode.SAAS:
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
except (ImportError, AttributeError):
# If server_config is not available (e.g., in tests), continue without it
pass
yield LiveStatusAppConversationService(
init_git_in_empty_workspace=self.init_git_in_empty_workspace,
user_context=user_context,
@ -855,4 +879,6 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
httpx_client=httpx_client,
web_url=web_url,
access_token_hard_timeout=access_token_hard_timeout,
app_mode=app_mode,
keycloak_auth_cookie=keycloak_auth_cookie,
)

View File

@ -6,9 +6,10 @@ import logging
import pkgutil
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
from pydantic import SecretStr
from openhands import tools # type: ignore[attr-defined]
from openhands.agent_server.models import ConversationInfo, Success
@ -33,6 +34,7 @@ from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
@ -41,6 +43,10 @@ from openhands.app_server.user.specifiy_user_context import (
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import Event
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
sandbox_service_dependency = depends_sandbox_service()
@ -154,23 +160,34 @@ async def on_event(
async def get_secret(
access_token: str = Depends(APIKeyHeader(name='X-Access-Token', auto_error=False)),
jwt_service: JwtService = jwt_dependency,
) -> str:
) -> Response:
"""Given an access token, retrieve a user secret. The access token
is limited by user and provider type, and may include a timeout, limiting
the damage in the event that a token is ever leaked"""
try:
payload = jwt_service.verify_jws_token(access_token)
user_id = payload['user_id']
provider_type = ProviderType[payload['provider_type']]
user_injector = config.user
assert user_injector is not None
user_context = await user_injector.get_for_user(user_id)
secret = None
if user_context:
secret = await user_context.get_latest_token(provider_type)
provider_type = ProviderType(payload['provider_type'])
# Get UserAuth for the user_id
if user_id:
user_auth = await get_user_auth_for_user(user_id)
else:
# OSS mode - use default user auth
user_auth = DefaultUserAuth()
# Create UserContext directly
user_context = AuthUserContext(user_auth=user_auth)
secret = await user_context.get_latest_token(provider_type)
if secret is None:
raise HTTPException(404, 'No such provider')
return secret
if isinstance(secret, SecretStr):
secret_value = secret.get_secret_value()
else:
secret_value = secret
return Response(content=secret_value, media_type='text/plain')
except InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)