Compare commits

..

16 Commits

Author SHA1 Message Date
openhands
d895197b36 docs: address review comments on bi-directional event loading
- Add comment explaining cursor parameter is exposed for future pagination
- Document 100 event limit and add TODO for cursor-based pagination (#12705)
- Improve comment clarity on oldest timestamp extraction (DESC ordering)
- Document why there's no race condition (server timestamps + >= comparison)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 21:28:06 +00:00
openhands
42109ac692 feat(frontend): implement bi-directional event loading for V1 conversations
This PR completes the frontend integration for bi-directional event loading, building on the agent-server work in software-agent-sdk#1880.

Closes #12705
Depends on software-agent-sdk#1880 (already merged)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 21:02:01 +00:00
Jamie Chicago
8ce3089a68 Add contributors section to README (#13696)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-04 01:27:45 +02:00
Tim O'Farrell
b9b10ebf5e APP-1197 Mark conversation endpoints as deprecated with updated docs (#13775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:45:32 -06:00
Tim O'Farrell
ce6d5b77c4 Add more endpoints as deprecated (microagent repository endpoints) (#13776)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 20:45:14 +00:00
simonrosenberg
a458c9b785 Fix credential leak in callback event logging (#13718)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:29:26 +00:00
Hiep Le
a65ddc3db6 feat(backend): route Slack resolver conversations to claimed org workspaces (#13758) 2026-04-04 03:09:21 +07:00
Tim O'Farrell
732a1c1991 APP-1197 Migrate secrets endpoints to V1 API (#13770)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 14:06:51 -06:00
Hiep Le
d058323a87 feat(backend): route gitlab resolver conversations to claimed org workspaces (#13755) 2026-04-04 02:27:46 +07:00
aivong-openhands
7d04cffe4e Fix CVE-2026-25645: Update requests to 2.33.1 (#13692)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:55:31 -05:00
Hiep Le
6ad27b77bb feat(backend): route resolver conversations to claimed org workspaces (#13713) 2026-04-04 01:32:43 +07:00
aivong-openhands
2739fc8fbe Fix CVE-2026-22815: Update aiohttp to 3.13.5 (#13705)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-03 13:21:05 -05:00
dependabot[bot]
38b7e10252 chore(deps): bump the security-all group across 1 directory with 2 updates (#13764)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 11:46:46 -05:00
mamoodi
7b7d1c0c55 Update CODEOWNERS (#13762) 2026-04-03 12:01:58 -04:00
Tim O'Farrell
e38eda4ac9 APP-1197 Migrate settings endpoints to V1 API (/api/v1/settings) (#13759)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:38:24 -06:00
aivong-openhands
99c19b6ef0 enterprise lock update openhands aci to version already in openhands (#13704)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-03 09:57:14 -04:00
67 changed files with 2478 additions and 824 deletions

7
.github/CODEOWNERS vendored
View File

@@ -1,8 +1,7 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/frontend/ @hieptl
/openhands-ui/ @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
/evaluation/ @xingyaoww @neubig
/enterprise/ @chuckbutkus @tofarr @malhotra5 @jlav @aivong-openhands

View File

@@ -1,30 +1,136 @@
---
name: PR Artifacts
run-name: PR Artifacts Smoke Test
on:
workflow_dispatch:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
smoke-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8core
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
steps:
- name: Show runner details
run: |
set -euxo pipefail
echo "runner_name=${RUNNER_NAME}"
echo "runner_os=${RUNNER_OS}"
echo "runner_arch=${RUNNER_ARCH}"
uname -a
nproc
free -h || true
df -h
- uses: actions/checkout@v5
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
- name: Simple shell check
run: |
set -euxo pipefail
echo "smoke test start"
sleep 10
echo "smoke test done"
- name: Remove .pr/ directory
id: remove
if: steps.check-fork.outputs.is_fork == 'false'
run: |
if [ -d ".pr" ]; then
git config user.name "allhands-bot"
git config user.email "allhands-bot@users.noreply.github.com"
git rm -rf .pr/
git commit -m "chore: Remove PR-only artifacts [automated]"
git push || {
echo "::error::Failed to push cleanup commit. Check branch protection rules."
exit 1
}
echo "removed=true" >> $GITHUB_OUTPUT
echo "::notice::Removed .pr/ directory"
else
echo "removed=false" >> $GITHUB_OUTPUT
echo "::notice::No .pr/ directory to remove"
fi
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
✅ **PR Artifacts Cleaned Up**
The \`.pr/\` directory has been automatically removed.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
}
# Warn if .pr/ directory exists (will be auto-removed on approval)
check-pr-artifacts:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- name: Check for .pr/ directory
id: check
run: |
if [ -d ".pr" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
📁 **PR Artifacts Notice**
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
> For fork PRs: Manual removal is required before merging.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}

View File

@@ -86,8 +86,19 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<hr>
### Thank You to Our Contributors
<div align="center">
[![OpenHands Contributors](https://assets.openhands.dev/readme/openhands-openhands-contributors.svg)](https://github.com/OpenHands/OpenHands/graphs/contributors)
</div>
<hr>
### Trusted by Engineers at
<div align="center">
<strong>Trusted by engineers at</strong>
<br/><br/>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
@@ -138,3 +149,5 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
</picture>
</div>
</div>

View File

@@ -10,6 +10,7 @@ from integrations.github.github_types import (
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
return user_secrets.custom_secrets if user_secrets else None
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
self.v1_enabled = await is_v1_enabled_for_github_resolver(
self.user_info.keycloak_user_id
)
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='github',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
)
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITHUB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITHUB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
github_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_V1_GITLAB_RESOLVER,
@@ -14,6 +15,7 @@ from integrations.utils import (
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.saas_conversation_store import SaasConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from openhands.agent_server.models import SendMessageRequest
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.services.conversation_service import start_conversation
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.conversation_summary import get_default_conversation_title
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
CONFIDENTIAL_NOTE = 'confidential_note'
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# v1_enabled is already set at construction time in the factory method
# This is the source of truth for the conversation type
# Resolve target org based on claimed git organizations
self.resolved_org_id = await resolve_org_for_repo(
provider='gitlab',
full_repo_name=self.full_repo_name,
keycloak_user_id=self.user_info.keycloak_user_id,
)
if self.v1_enabled:
# Create dummy conversation metadata
# Don't save to conversation store
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
selected_repository=self.full_repo_name,
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
conversation_trigger=ConversationTrigger.RESOLVER,
git_provider=ProviderType.GITLAB,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
self.user_info.keycloak_user_id,
self.resolved_org_id,
)
self.conversation_id = conversation_metadata.conversation_id
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.RESOLVER,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=self.user_info.keycloak_user_id,
selected_repository=self.full_repo_name,
selected_branch=self._get_branch_name(),
git_provider=ProviderType.GITLAB,
)
await store.save_metadata(conversation_metadata)
self.conversation_id = conversation_id
return conversation_metadata
async def create_new_conversation(
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
)
# Set up the GitLab user context for the V1 system
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
gitlab_user_context = ResolverUserContext(
saas_user_auth=saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
async with get_app_conversation_service(
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
'is_mr': self.is_mr,
'discussion_id': getattr(self, 'discussion_id', None),
},
send_summary_instruction=self.send_summary_instruction,
should_request_summary=self.send_summary_instruction,
)

View File

@@ -1,3 +1,5 @@
from uuid import UUID
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
def __init__(
self,
saas_user_auth: UserAuth,
resolver_org_id: UUID | None = None,
):
self.saas_user_auth = saas_user_auth
self.resolver_org_id = resolver_org_id
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:

View File

@@ -0,0 +1,68 @@
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
This module provides a reusable utility for routing resolver conversations
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
workspace based on claimed Git organizations.
"""
from uuid import UUID
from storage.org_git_claim_store import OrgGitClaimStore
from storage.org_member_store import OrgMemberStore
from openhands.core.logger import openhands_logger as logger
async def resolve_org_for_repo(
provider: str,
full_repo_name: str,
keycloak_user_id: str,
) -> UUID | None:
"""Determine the OpenHands org_id for a resolver conversation.
If the repo's git organization is claimed by an OpenHands org AND the user
is a member of that org, returns the claiming org's ID. Otherwise returns
None (caller should fall back to user.current_org_id / personal workspace).
Args:
provider: Git provider name ("github", "gitlab", "bitbucket")
full_repo_name: Full repository name (e.g., "OpenHands/foo")
keycloak_user_id: The user's Keycloak UUID string
Returns:
The org_id if the repo's org is claimed and user is a member, else None
"""
git_org = full_repo_name.split('/')[0].lower()
try:
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
provider, git_org
)
if not claim:
logger.debug(
f'[OrgResolver] No claim found for {provider}/{git_org}',
)
return None
member = await OrgMemberStore.get_org_member(
claim.org_id, UUID(keycloak_user_id)
)
if not member:
logger.debug(
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
f'{claim.org_id} (claimed {provider}/{git_org}). '
f'Falling back to personal workspace.',
)
return None
logger.info(
f'[OrgResolver] Routing conversation to org {claim.org_id} '
f'for {provider}/{git_org} (user {keycloak_user_id})',
)
return claim.org_id
except Exception as e:
logger.error(
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
exc_info=True,
)
return None

View File

@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.resolver_org_router import resolve_org_for_repo
from integrations.slack.slack_types import (
SlackMessageView,
SlackViewInterface,
@@ -17,7 +18,9 @@ from integrations.utils import (
get_user_v1_enabled_setting,
)
from jinja2 import Environment
from server.config import get_config
from slack_sdk import WebClient
from storage.saas_conversation_store import SaasConversationStore
from storage.slack_conversation import SlackConversation
from storage.slack_conversation_store import SlackConversationStore
from storage.slack_team_store import SlackTeamStore
@@ -36,18 +39,20 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.integrations.provider import ProviderHandler
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
start_conversation,
)
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
from openhands.utils.async_utils import GENERAL_TIMEOUT
from openhands.utils.conversation_summary import get_default_conversation_title
# =================================================
# SECTION: Slack view types
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
# Determine git provider from repository (needed for both org routing and conversation creation)
self._resolved_git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
self._resolved_git_provider = repository.git_provider
# Resolve target org based on claimed git organizations
self.resolved_org_id = None
if self._resolved_git_provider and self.selected_repo:
self.resolved_org_id = await resolve_org_for_repo(
provider=self._resolved_git_provider.value,
full_repo_name=self.selected_repo,
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
)
# Check if V1 conversations are enabled for this user
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
self.slack_to_openhands_user.keycloak_user_id
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
jinja
)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
user_id = self.slack_to_openhands_user.keycloak_user_id
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
# Create the conversation store with resolver org routing
# (bypasses initialize_conversation to avoid threading enterprise-only
# resolver_org_id through the generic OSS interface)
store = await SaasConversationStore.get_resolver_instance(
get_config(),
user_id,
self.resolved_org_id,
)
conversation_id = uuid4().hex
conversation_metadata = ConversationMetadata(
trigger=ConversationTrigger.SLACK,
conversation_id=conversation_id,
title=get_default_conversation_title(conversation_id),
user_id=user_id,
selected_repository=self.selected_repo,
selected_branch=None,
git_provider=self._resolved_git_provider,
)
await store.save_metadata(conversation_metadata)
await start_conversation(
user_id=user_id,
git_provider_tokens=provider_tokens,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
initial_user_msg=user_instructions,
image_urls=None,
replay_json=None,
conversation_id=conversation_id,
conversation_metadata=conversation_metadata,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
self.conversation_id = conversation_id
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
await self.save_slack_convo(v1_enabled=False)
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
# Create the Slack V1 callback processor
slack_callback_processor = self._create_slack_v1_callback_processor()
# Determine git provider from repository
git_provider = None
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = ProviderType(repository.git_provider.value)
# Use git provider resolved in create_or_update_conversation
git_provider = self._resolved_git_provider
# Get the app conversation service and start the conversation
injector_state = InjectorState()
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
)
# Set up the Slack user context for the V1 system
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
slack_user_context = ResolverUserContext(
saas_user_auth=self.saas_user_auth,
resolver_org_id=self.resolved_org_id,
)
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
async with get_app_conversation_service(

View File

@@ -6486,7 +6486,7 @@ files = []
develop = true
[package.dependencies]
aiohttp = ">=3.13.3"
aiohttp = ">=3.13.5"
anthropic = {version = "*", extras = ["vertex"]}
anyio = "4.9"
asyncpg = ">=0.30"
@@ -6554,7 +6554,7 @@ pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
rapidfuzz = ">=3.9"
redis = ">=5.2,<7"
requests = ">=2.32.5"
requests = ">=2.33.0"
setuptools = ">=78.1.1"
shellingham = ">=1.5.4"
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}

View File

@@ -287,28 +287,7 @@ def get_api_key_from_header(request: Request):
return session_api_key
# Fallback to X-Access-Token header as an additional option
access_token = request.headers.get('X-Access-Token')
if access_token:
return access_token
# DEPRECATED: Accept api_key from query parameters for backward compatibility.
# Passing API keys as query parameters is a security risk because they are
# logged in proxy access logs (e.g. Traefik) and application logs.
# Callers should migrate to using the Authorization header instead:
# Authorization: Bearer sk-oh-...
# See: https://github.com/OpenHands/evaluation/issues/391
query_api_key = request.query_params.get('api_key')
if query_api_key:
logger.warning(
'DEPRECATED: api_key passed as URL query parameter. '
'This is a security risk as tokens are logged in proxy/access logs. '
'Use the Authorization header instead: Authorization: Bearer <token>. '
'Query parameter support will be removed in a future release.',
extra={'path': request.url.path},
)
return query_api_key
return None
return request.headers.get('X-Access-Token')
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:

View File

@@ -106,17 +106,12 @@ class SetAuthCookieMiddleware:
auth_header = request.headers.get('Authorization')
mcp_auth_header = request.headers.get('X-Session-API-Key')
api_auth_header = request.headers.get('X-Access-Token')
# DEPRECATED: Also check for api_key in query params for backward
# compatibility. The actual deprecation warning is logged in
# get_api_key_from_header() when the key is extracted.
query_api_key = request.query_params.get('api_key')
accepted_tos: bool | None = False
if (
keycloak_auth_cookie is None
and (auth_header is None or not auth_header.startswith('Bearer '))
and mcp_auth_header is None
and api_auth_header is None
and query_api_key is None
):
raise NoCredentialsError

View File

@@ -7,8 +7,8 @@ from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.events.event_store import EventStore
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.user_auth import get_user_id

View File

@@ -7,6 +7,7 @@ from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
@@ -23,7 +24,6 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.git import (
get_repository_branches,
get_repository_microagent_content,

View File

@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
if api_key_org_id is not None:
org_id = api_key_org_id
# Override with resolver org_id if set (from git org claim resolution)
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
if resolver_org_id is not None:
org_id = resolver_org_id
# Check if SAAS metadata already exists
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(info.id)

View File

@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
session_maker: sessionmaker
org_id: UUID | None = None # will be fetched automatically
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
def __init__(
self,
user_id: str,
org_id: UUID,
session_maker: sessionmaker,
resolver_org_id: UUID | None = None,
):
self.user_id = user_id
self.org_id = org_id
self.session_maker = session_maker
self.resolver_org_id = resolver_org_id
def _select_by_id(self, session, conversation_id: str):
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
stored_metadata = StoredConversationMetadata(**kwargs)
# Override with resolver org_id if set (from git org claim resolution),
# same pattern as V1's save_app_conversation_info in
# saas_app_conversation_info_injector.py
org_id = self.org_id
if self.resolver_org_id is not None:
org_id = self.resolver_org_id
def _save_metadata():
with self.session_maker() as session:
# Save the main conversation metadata
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
saas_metadata = StoredConversationMetadataSaas(
conversation_id=stored_metadata.conversation_id,
user_id=UUID(self.user_id),
org_id=self.org_id,
org_id=org_id,
)
session.add(saas_metadata)
else:
# Validate
expected_user_id = UUID(self.user_id)
expected_org_id = self.org_id
expected_org_id = org_id
if saas_metadata.user_id != expected_user_id:
raise ValueError(
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker)
@classmethod
async def get_resolver_instance(
cls,
config: OpenHandsConfig,
user_id: str,
resolver_org_id: UUID | None = None,
) -> 'SaasConversationStore':
"""Get a store for resolver conversations with explicit org routing.
Unlike get_instance, this accepts a resolver_org_id that overrides
the user's default org when saving conversation metadata.
"""
user = await UserStore.get_user_by_id(user_id)
org_id = user.current_org_id if user else None
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)

View File

@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
]
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_app_conversation_service.return_value = (
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
view.previous_comments = []
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
view.resolved_org_id = None
fake_service = _FakeAppConversationService()
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)

View File

@@ -32,6 +32,28 @@ def resolver_context(mock_saas_user_auth):
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
# ---------------------------------------------------------------------------
# Tests for resolver_org_id - org routing for resolver conversations
# ---------------------------------------------------------------------------
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
"""Test that resolver_org_id defaults to None when not provided."""
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
assert ctx.resolver_org_id is None
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
"""Test that resolver_org_id can be set via constructor for org routing."""
from uuid import UUID
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ctx = ResolverUserContext(
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
)
assert ctx.resolver_org_id == org_id
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
"""Helper to create CustomSecret instances."""
return CustomSecret(secret=SecretStr(value), description=description)

View File

@@ -0,0 +1,111 @@
"""Tests for resolver org routing logic.
Tests the resolve_org_for_repo function which determines which OpenHands
organization workspace a resolver conversation should be created in.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
# Patch at module level where the names are looked up
_CLAIM_STORE = 'enterprise.integrations.resolver_org_router.OrgGitClaimStore'
_MEMBER_STORE = 'enterprise.integrations.resolver_org_router.OrgMemberStore'
@pytest.fixture(autouse=True)
def mock_stores():
"""Mock OrgGitClaimStore and OrgMemberStore for all tests."""
with (
patch(_CLAIM_STORE) as mock_claim_store,
patch(_MEMBER_STORE) as mock_member_store,
):
mock_claim_store.get_claim_by_provider_and_git_org = AsyncMock(
return_value=None
)
mock_member_store.get_org_member = AsyncMock(return_value=None)
yield mock_claim_store, mock_member_store
@pytest.mark.asyncio
async def test_returns_org_id_when_claimed_and_user_is_member(mock_stores):
"""When the git org is claimed and the user is a member, return the claiming org's ID."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = MagicMock() # member exists
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result == CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'openhands'
)
mock_member_store.get_org_member.assert_called_once_with(
CLAIMING_ORG_ID, UUID(USER_ID)
)
@pytest.mark.asyncio
async def test_returns_none_when_claimed_but_user_not_member(mock_stores):
"""When the git org is claimed but user is not a member, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, mock_member_store = mock_stores
# Arrange
claim = MagicMock()
claim.org_id = CLAIMING_ORG_ID
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
mock_member_store.get_org_member.return_value = None
# Act
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_claim_exists(mock_stores):
"""When no org has claimed the git organization, return None."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
mock_claim_store.get_claim_by_provider_and_git_org.return_value = None
# Act
result = await resolve_org_for_repo('github', 'UnclaimedOrg/repo', USER_ID)
# Assert
assert result is None
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'unclaimedorg'
)
@pytest.mark.asyncio
async def test_extracts_git_org_lowercase_from_repo_name(mock_stores):
"""The git org is extracted from repo name and lowercased for claim lookup."""
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
mock_claim_store, _ = mock_stores
# Act
await resolve_org_for_repo('github', 'MyOrg/some-repo', USER_ID)
# Assert
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
'github', 'myorg'
)

View File

@@ -1306,3 +1306,100 @@ class TestApiKeyOrgIdHandling:
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
assert conv_from_org1 is not None
assert conv_from_org1.id == conv_id
class TestResolverOrgIdRouting:
"""Test that resolver_org_id on user_context overrides the default org_id."""
@pytest.mark.asyncio
async def test_save_uses_resolver_org_id_when_set_on_context(
self,
async_session_with_users: AsyncSession,
):
"""When user_context has resolver_org_id, conversation is saved in that org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 is in ORG1, but resolver routes to ORG2
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = ORG2_ID
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_resolver',
title='Resolver Routed Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation is stored in ORG2, not user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG2_ID
assert saas_metadata.user_id == USER1_ID
@pytest.mark.asyncio
async def test_save_uses_default_org_when_resolver_org_id_is_none(
self,
async_session_with_users: AsyncSession,
):
"""When resolver_org_id is None, conversation uses user's default org."""
from unittest.mock import AsyncMock
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
from enterprise.integrations.resolver_context import ResolverUserContext
# Arrange: user1 in ORG1 with no resolver override
# Use spec to prevent MagicMock from auto-creating undefined attributes
mock_context = MagicMock(spec=ResolverUserContext)
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
mock_context.resolver_org_id = None
service = SaasSQLAppConversationInfoService(
db_session=async_session_with_users,
user_context=mock_context,
)
conv_id = uuid4()
conv_info = AppConversationInfo(
id=conv_id,
created_by_user_id=str(USER1_ID),
sandbox_id='sandbox_default',
title='Default Org Conversation',
)
# Act
await service.save_app_conversation_info(conv_info)
# Assert: conversation stored in user's default ORG1
saas_query = select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == str(conv_id)
)
result = await async_session_with_users.execute(saas_query)
saas_metadata = result.scalar_one_or_none()
assert saas_metadata is not None
assert saas_metadata.org_id == ORG1_ID

View File

@@ -25,7 +25,6 @@ def middleware():
def mock_request():
request = MagicMock(spec=Request)
request.cookies = {}
request.query_params = {}
return request
@@ -357,7 +356,6 @@ async def test_middleware_does_not_skip_similar_non_webhook_paths(
mock_request.url.path = path
mock_request.headers = MagicMock()
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
mock_request.query_params = {}
# Since these paths start with /api, _should_attach returns True
# Since there's no auth, middleware catches NoCredentialsError and returns 401

View File

@@ -1,5 +1,6 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
@@ -215,3 +216,119 @@ class TestGithubV1ConversationRouting(TestCase):
jinja_env, saas_user_auth, conversation_metadata
)
mock_create_v0.assert_not_called()
class TestGithubOrgRouting(TestCase):
"""Test org routing for GitHub resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITHUB,
message={
'payload': {
'action': 'opened',
'issue': {'number': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_github_issue(self):
return GithubIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
installation_id=456,
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
uuid='test-uuid',
title='',
description='',
previous_comments=[],
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_app_conversation_service')
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_v1_setting, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_v1_setting.return_value = True
mock_resolve_org.return_value = self.resolved_org_id
github_issue = self._create_github_issue()
# Initialize to set resolved_org_id and v1_enabled
await github_issue.initialize_new_conversation()
# Assert
assert github_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.github.github_view.resolve_org_for_repo')
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_v1_setting.return_value = False
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
github_issue = self._create_github_issue()
# Act
await github_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None

View File

@@ -0,0 +1,126 @@
"""Tests for GitLab resolver org routing logic.
Tests that the GitLab resolver correctly resolves the target organization
and passes resolver_org_id through V0 and V1 conversation paths.
"""
from unittest import TestCase
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.gitlab.gitlab_view import GitlabIssue
from integrations.models import Message, SourceType
from integrations.types import UserData
class TestGitlabOrgRouting(TestCase):
"""Test org routing for GitLab resolver conversations."""
def setUp(self):
self.user_data = UserData(
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
)
self.raw_payload = Message(
source=SourceType.GITLAB,
message={
'payload': {
'object_kind': 'issue',
'object_attributes': {'action': 'open', 'iid': 42},
}
},
)
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
def _create_gitlab_issue(self):
return GitlabIssue(
user_info=self.user_data,
full_repo_name='ClaimedOrg/repo',
issue_number=42,
project_id=100,
installation_id='install-123',
conversation_id='',
should_extract=True,
send_summary_instruction=False,
is_public_repo=True,
raw_payload=self.raw_payload,
title='',
description='',
previous_comments=[],
is_mr=False,
v1_enabled=False,
)
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
self, mock_resolve_org, mock_get_resolver
):
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
mock_resolve_org.assert_called_once_with(
provider='gitlab',
full_repo_name='ClaimedOrg/repo',
keycloak_user_id='test-keycloak-id',
)
# get_resolver_instance(config, user_id, resolver_org_id)
args, _ = mock_get_resolver.call_args
assert args[1] == 'test-keycloak-id'
assert args[2] == self.resolved_org_id
@pytest.mark.asyncio
@patch('integrations.gitlab.gitlab_view.get_app_conversation_service')
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
self, mock_resolve_org, mock_get_service
):
"""V1 path passes resolved org_id to ResolverUserContext."""
# Arrange
mock_resolve_org.return_value = self.resolved_org_id
gitlab_issue = self._create_gitlab_issue()
gitlab_issue.v1_enabled = True
# Initialize to set resolved_org_id
await gitlab_issue.initialize_new_conversation()
# Assert
assert gitlab_issue.resolved_org_id == self.resolved_org_id
@pytest.mark.asyncio
@patch(
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
)
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
async def test_no_claim_passes_none_resolver_org_id(
self, mock_resolve_org, mock_get_resolver
):
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
# Arrange
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver.return_value = mock_store
gitlab_issue = self._create_gitlab_issue()
# Act
await gitlab_issue.initialize_new_conversation()
# Assert
args, _ = mock_get_resolver.call_args
assert args[2] is None

View File

@@ -214,3 +214,125 @@ class TestGetInstance:
# Assert
assert store.user_id == user_id
assert store.org_id is None
@pytest.mark.asyncio
async def test_get_resolver_instance_passes_resolver_org_id(self):
"""Verify get_resolver_instance forwards resolver_org_id to the store."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_resolver_instance(
mock_config, user_id, resolver_org_id=resolver_org_id
)
# Assert
assert store.resolver_org_id == resolver_org_id
@pytest.mark.asyncio
async def test_get_instance_does_not_have_resolver_org_id(self):
"""Verify get_instance does not set resolver_org_id (it's not a resolver path)."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
mock_user = MagicMock(spec=User)
mock_user.current_org_id = UUID(user_id)
mock_config = MagicMock(spec=OpenHandsConfig)
with patch(
'storage.saas_conversation_store.UserStore.get_user_by_id',
AsyncMock(return_value=mock_user),
), patch('storage.saas_conversation_store.session_maker'):
# Act
store = await SaasConversationStore.get_instance(mock_config, user_id)
# Assert
assert store.resolver_org_id is None
class TestResolverOrgIdRouting:
"""Tests for resolver_org_id overriding org_id in save_metadata."""
@pytest.mark.asyncio
async def test_save_metadata_uses_resolver_org_id_over_default(self, session_maker):
"""When resolver_org_id is set, save_metadata stores it instead of the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
store = SaasConversationStore(
user_id, default_org_id, session_maker, resolver_org_id=resolver_org_id
)
metadata = ConversationMetadata(
conversation_id='resolver-routed-conv',
user_id=user_id,
selected_repository='ClaimedOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert - verify the SaaS metadata record has the resolver org, not the default
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id
== 'resolver-routed-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == resolver_org_id
assert saas_record.org_id != default_org_id
@pytest.mark.asyncio
async def test_save_metadata_uses_default_org_when_no_resolver_org(
self, session_maker
):
"""When resolver_org_id is None, save_metadata uses the default org_id."""
# Arrange
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
store = SaasConversationStore(user_id, default_org_id, session_maker)
metadata = ConversationMetadata(
conversation_id='default-org-conv',
user_id=user_id,
selected_repository='PersonalOrg/repo',
selected_branch=None,
created_at=datetime.now(UTC),
last_updated_at=datetime.now(UTC),
)
# Act
await store.save_metadata(metadata)
# Assert
from storage.stored_conversation_metadata_saas import (
StoredConversationMetadataSaas,
)
with session_maker() as session:
saas_record = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id == 'default-org-conv'
)
.first()
)
assert saas_record is not None
assert saas_record.org_id == default_org_id

View File

@@ -31,7 +31,6 @@ def mock_request():
request = MagicMock(spec=Request)
request.headers = {}
request.cookies = {}
request.query_params = {}
return request
@@ -512,7 +511,6 @@ async def test_saas_user_auth_from_bearer_no_auth_header():
"""Test that saas_user_auth_from_bearer returns None if no auth header."""
mock_request = MagicMock()
mock_request.headers = {}
mock_request.query_params = {}
result = await saas_user_auth_from_bearer(mock_request)
@@ -635,7 +633,6 @@ def test_get_api_key_from_header_with_authorization_header():
# Create a mock request with Authorization header
mock_request = MagicMock(spec=Request)
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -649,7 +646,6 @@ def test_get_api_key_from_header_with_x_session_api_key():
# Create a mock request with X-Session-API-Key header
mock_request = MagicMock(spec=Request)
mock_request.headers = {'X-Session-API-Key': 'session_api_key'}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -666,7 +662,6 @@ def test_get_api_key_from_header_with_both_headers():
'Authorization': 'Bearer auth_api_key',
'X-Session-API-Key': 'session_api_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -676,11 +671,10 @@ def test_get_api_key_from_header_with_both_headers():
def test_get_api_key_from_header_with_no_headers():
"""Test that get_api_key_from_header returns None when no relevant headers or query params are present."""
"""Test that get_api_key_from_header returns None when no relevant headers are present."""
# Create a mock request with no relevant headers
mock_request = MagicMock(spec=Request)
mock_request.headers = {'Other-Header': 'some_value'}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -694,7 +688,6 @@ def test_get_api_key_from_header_with_invalid_authorization_format():
# Create a mock request with incorrectly formatted Authorization header
mock_request = MagicMock(spec=Request)
mock_request.headers = {'Authorization': 'InvalidFormat api_key'}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -708,7 +701,6 @@ def test_get_api_key_from_header_with_x_access_token():
# Create a mock request with X-Access-Token header
mock_request = MagicMock(spec=Request)
mock_request.headers = {'X-Access-Token': 'access_token_key'}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -725,7 +717,6 @@ def test_get_api_key_from_header_priority_authorization_over_x_access_token():
'Authorization': 'Bearer auth_api_key',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -742,7 +733,6 @@ def test_get_api_key_from_header_priority_x_session_over_x_access_token():
'X-Session-API-Key': 'session_api_key',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -760,7 +750,6 @@ def test_get_api_key_from_header_all_three_headers():
'X-Session-API-Key': 'session_api_key',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -777,7 +766,6 @@ def test_get_api_key_from_header_invalid_authorization_fallback_to_x_access_toke
'Authorization': 'InvalidFormat api_key',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -795,7 +783,6 @@ def test_get_api_key_from_header_empty_headers():
'X-Session-API-Key': '',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -812,7 +799,6 @@ def test_get_api_key_from_header_bearer_with_empty_token():
'Authorization': 'Bearer ',
'X-Access-Token': 'access_token_key',
}
mock_request.query_params = {}
# Call the function
api_key = get_api_key_from_header(mock_request)
@@ -822,68 +808,6 @@ def test_get_api_key_from_header_bearer_with_empty_token():
assert api_key == ''
def test_get_api_key_from_query_param_fallback():
"""Test that get_api_key_from_header falls back to api_key query parameter (deprecated)."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {}
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
mock_request.url = MagicMock()
mock_request.url.path = '/mcp'
api_key = get_api_key_from_header(mock_request)
assert api_key == 'sk-oh-query-param-key'
def test_get_api_key_from_header_takes_priority_over_query_param():
"""Test that Authorization header takes priority over api_key query parameter."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {'Authorization': 'Bearer header_api_key'}
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
api_key = get_api_key_from_header(mock_request)
assert api_key == 'header_api_key'
def test_get_api_key_x_session_header_takes_priority_over_query_param():
"""Test that X-Session-API-Key header takes priority over api_key query parameter."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {'X-Session-API-Key': 'session_key'}
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
api_key = get_api_key_from_header(mock_request)
assert api_key == 'session_key'
def test_get_api_key_x_access_token_takes_priority_over_query_param():
"""Test that X-Access-Token header takes priority over api_key query parameter."""
mock_request = MagicMock(spec=Request)
mock_request.headers = {'X-Access-Token': 'access_token_key'}
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
api_key = get_api_key_from_header(mock_request)
assert api_key == 'access_token_key'
def test_get_api_key_from_query_param_logs_deprecation_warning(caplog):
"""Test that using api_key query parameter logs a deprecation warning."""
import logging
mock_request = MagicMock(spec=Request)
mock_request.headers = {}
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
mock_request.url = MagicMock()
mock_request.url.path = '/api/v1/auth/github'
with caplog.at_level(logging.WARNING):
api_key = get_api_key_from_header(mock_request)
assert api_key == 'sk-oh-query-param-key'
@pytest.mark.asyncio
async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config):
"""Test that saas_user_auth_from_signed_token raises AuthError when email domain is blocked."""

View File

@@ -0,0 +1,331 @@
"""Tests for Slack view org routing logic.
Tests that the SlackNewConversationView correctly resolves the target org
based on claimed git organizations and passes it through V0/V1 paths.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID
import pytest
from integrations.slack.slack_view import SlackNewConversationView
from storage.slack_user import SlackUser
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
@pytest.fixture
def mock_slack_user():
"""Create a mock SlackUser."""
user = SlackUser()
user.slack_user_id = 'U1234567890'
user.keycloak_user_id = KEYCLOAK_USER_ID
user.slack_display_name = 'Test User'
user.org_id = UUID('cccccccc-cccc-cccc-cccc-cccccccccccc')
return user
@pytest.fixture
def mock_user_auth():
"""Create a mock UserAuth."""
auth = MagicMock(spec=UserAuth)
auth.get_provider_tokens = AsyncMock(
return_value={ProviderType.GITHUB: MagicMock()}
)
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
auth.get_access_token = AsyncMock(return_value='access-token')
auth.get_user_id = AsyncMock(return_value=KEYCLOAK_USER_ID)
return auth
@pytest.fixture
def slack_view(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView instance for testing."""
return SlackNewConversationView(
bot_access_token='xoxb-test-token',
user_msg='Hello OpenHands!',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123456',
thread_ts=None,
selected_repo='OpenHands/foo',
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
@pytest.fixture
def slack_view_no_repo(mock_slack_user, mock_user_auth):
"""Create a SlackNewConversationView with no selected repo."""
return SlackNewConversationView(
bot_access_token='xoxb-test-token',
user_msg='Hello OpenHands!',
slack_user_id='U1234567890',
slack_to_openhands_user=mock_slack_user,
saas_user_auth=mock_user_auth,
channel_id='C1234567890',
message_ts='1234567890.123456',
thread_ts=None,
selected_repo=None,
should_extract=True,
send_summary_instruction=True,
conversation_id='',
team_id='T1234567890',
v1_enabled=False,
)
class TestSlackV0ConversationRouting:
"""Test V0 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_resolver_org_id(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id to SaasConversationStore.get_resolver_instance."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_get_resolver_instance.assert_called_once()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][1] == KEYCLOAK_USER_ID # user_id
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
mock_store.save_metadata.assert_called_once()
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider == ProviderType.GITHUB
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_v0_passes_none_when_no_claim(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V0 path should pass resolver_org_id=None when no claim exists."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = None
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
class TestSlackV1ConversationRouting:
"""Test V1 conversation routing logic in Slack integration."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=True,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch('integrations.slack.slack_view.ProviderHandler')
@patch('integrations.slack.slack_view.get_app_conversation_service')
@patch('integrations.slack.slack_view.ResolverUserContext')
async def test_v1_passes_resolver_org_id_to_context(
self,
mock_resolver_ctx_cls,
mock_get_service,
mock_provider_handler_cls,
mock_resolve_org,
mock_v1_enabled,
slack_view,
):
"""V1 path should pass resolver_org_id to ResolverUserContext."""
# Arrange
mock_repo = MagicMock()
mock_repo.git_provider = ProviderType.GITHUB
mock_handler = MagicMock()
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
mock_provider_handler_cls.return_value = mock_handler
mock_resolve_org.return_value = CLAIMING_ORG_ID
mock_resolver_ctx_cls.return_value = MagicMock()
# Mock the async context manager for app_conversation_service
mock_service = MagicMock()
mock_service.start_app_conversation = MagicMock(return_value=aiter_empty())
mock_ctx = MagicMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_service)
mock_ctx.__aexit__ = AsyncMock(return_value=None)
mock_get_service.return_value = mock_ctx
mock_jinja = MagicMock()
# Act
with patch.object(
slack_view,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
):
with patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock):
await slack_view.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_called_once_with(
provider='github',
full_repo_name='OpenHands/foo',
keycloak_user_id=KEYCLOAK_USER_ID,
)
mock_resolver_ctx_cls.assert_called_once_with(
saas_user_auth=slack_view.saas_user_auth,
resolver_org_id=CLAIMING_ORG_ID,
)
class TestSlackNoRepoRouting:
"""Test routing when no repository is selected."""
@pytest.mark.asyncio
@patch(
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
new_callable=AsyncMock,
return_value=False,
)
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
@patch(
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
new_callable=AsyncMock,
)
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
async def test_no_repo_skips_org_resolution(
self,
mock_start_convo,
mock_get_resolver_instance,
mock_resolve_org,
mock_v1_enabled,
slack_view_no_repo,
):
"""When selected_repo is None, org resolution should be skipped."""
# Arrange
mock_store = MagicMock()
mock_store.save_metadata = AsyncMock()
mock_get_resolver_instance.return_value = mock_store
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
mock_jinja = MagicMock()
# Act
with (
patch.object(
slack_view_no_repo,
'_get_instructions',
new_callable=AsyncMock,
return_value=('msg', 'instructions'),
),
patch.object(
slack_view_no_repo, 'save_slack_convo', new_callable=AsyncMock
),
patch.object(slack_view_no_repo, '_verify_necessary_values_are_set'),
):
await slack_view_no_repo.create_or_update_conversation(mock_jinja)
# Assert
mock_resolve_org.assert_not_called()
call_args = mock_get_resolver_instance.call_args
assert call_args[0][2] is None # resolver_org_id is None
saved_metadata = mock_store.save_metadata.call_args[0][0]
assert saved_metadata.git_provider is None
async def aiter_empty():
"""Helper: empty async iterator."""
return
yield # noqa: unreachable - makes this an async generator

View File

@@ -26,9 +26,10 @@ function makeConversation(version: "V0" | "V1"): Conversation {
};
}
function makeEvent(): OpenHandsEvent {
function makeEvent(timestamp?: string): OpenHandsEvent {
return {
id: "evt-1",
timestamp: timestamp ?? "2026-01-15T10:00:00Z",
} as OpenHandsEvent;
}
@@ -60,7 +61,7 @@ describe("useConversationHistory", () => {
vi.clearAllMocks();
});
it("calls V1 REST endpoint for V1 conversations", async () => {
it("calls V1 REST endpoint for V1 conversations with TIMESTAMP_DESC", async () => {
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
vi.mocked(useUserConversation).mockReturnValue({
@@ -72,7 +73,10 @@ describe("useConversationHistory", () => {
refetch: vi.fn(),
} as any);
v1SearchEventsSpy.mockResolvedValue([makeEvent()]);
v1SearchEventsSpy.mockResolvedValue({
items: [makeEvent("2026-01-15T12:00:00Z"), makeEvent("2026-01-15T10:00:00Z")],
next_page_id: undefined,
});
const { result } = renderHook(() => useConversationHistory("conv-123"), {
wrapper,
@@ -82,8 +86,45 @@ describe("useConversationHistory", () => {
expect(result.current.data).toBeDefined();
});
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123");
// Should call with TIMESTAMP_DESC for bi-directional loading (newest first)
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123", {
sort_order: "TIMESTAMP_DESC",
limit: 100,
});
expect(EventService.searchEventsV0).not.toHaveBeenCalled();
// Should return events and oldest timestamp for WebSocket handoff
expect(result.current.data?.events).toHaveLength(2);
expect(result.current.data?.oldestTimestamp).toBe("2026-01-15T10:00:00Z");
});
it("returns null oldestTimestamp when no events", async () => {
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
isLoading: false,
isPending: false,
isError: false,
error: null,
refetch: vi.fn(),
} as any);
v1SearchEventsSpy.mockResolvedValue({
items: [],
next_page_id: undefined,
});
const { result } = renderHook(() => useConversationHistory("conv-empty"), {
wrapper,
});
await waitFor(() => {
expect(result.current.data).toBeDefined();
});
expect(result.current.data?.events).toHaveLength(0);
expect(result.current.data?.oldestTimestamp).toBeNull();
});
it("calls V0 REST endpoint for V0 conversations", async () => {
@@ -110,6 +151,10 @@ describe("useConversationHistory", () => {
expect(EventService.searchEventsV0).toHaveBeenCalledWith("conv-456");
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
// V0 returns events but null oldestTimestamp (no bi-directional loading)
expect(result.current.data?.events).toHaveLength(1);
expect(result.current.data?.oldestTimestamp).toBeNull();
});
});
@@ -144,7 +189,7 @@ describe("useConversationHistory cache key stability", () => {
it("does not refetch when conversation object changes but version stays the same", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
const conv1 = makeConversation("V1");
vi.mocked(useUserConversation).mockReturnValue({
@@ -200,7 +245,7 @@ describe("useConversationHistory cache key stability", () => {
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v0Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
// Start with V0
vi.mocked(useUserConversation).mockReturnValue({
@@ -242,7 +287,7 @@ describe("useConversationHistory cache key stability", () => {
it("treats cached history as never stale (staleTime is Infinity)", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),
@@ -274,7 +319,7 @@ describe("useConversationHistory cache key stability", () => {
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
v1Spy.mockResolvedValue([makeEvent()]);
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation("V1"),

View File

@@ -11905,11 +11905,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT"
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true
},
"node_modules/lodash.curry": {
"version": "4.1.1",
@@ -13821,10 +13820,9 @@
"license": "MIT"
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/path-type": {
"version": "4.0.0",

View File

@@ -65,14 +65,28 @@ class EventService {
}
// V1 conversations — App Server REST endpoint
static async searchEventsV1(conversationId: string, limit = 100) {
// Note: cursor parameter is exposed for future pagination of older events.
// Currently only the initial page is fetched; background pagination is planned.
static async searchEventsV1(
conversationId: string,
options?: {
limit?: number;
sort_order?: "TIMESTAMP_ASC" | "TIMESTAMP_DESC";
cursor?: string;
},
) {
const { data } = await openHands.get<{
items: OpenHandsEvent[];
next_page_id?: string;
}>(`/api/v1/conversation/${conversationId}/events/search`, {
params: { limit },
params: {
limit: options?.limit ?? 100,
sort_order: options?.sort_order,
cursor: options?.cursor,
},
});
return data.items;
return data;
}
// V0 conversations — Legacy REST endpoint

View File

@@ -318,7 +318,11 @@ export function ConversationWebSocketProvider({
latestPlanningFileEventRef.current = null;
}, [conversationId]);
const { data: preloadedEvents } = useConversationHistory(conversationId);
const { data: conversationHistory } = useConversationHistory(conversationId);
// Extract preloaded events and oldest timestamp for WebSocket handoff
const preloadedEvents = conversationHistory?.events;
const oldestPreloadedTimestamp = conversationHistory?.oldestTimestamp;
useEffect(() => {
if (!preloadedEvents || preloadedEvents.length === 0) {
@@ -698,6 +702,15 @@ export function ConversationWebSocketProvider({
queryParams.session_api_key = sessionApiKey;
}
// Bi-directional loading: pass after_timestamp to avoid duplicate events.
// WebSocket will only send events with timestamp >= oldestPreloadedTimestamp.
// This ensures zero duplication with REST-fetched events while also catching
// any events created between REST fetch and WebSocket connect (since server
// timestamps are monotonically increasing, >= comparison handles the handoff).
if (oldestPreloadedTimestamp) {
queryParams.after_timestamp = oldestPreloadedTimestamp;
}
return {
queryParams,
reconnect: { enabled: true },
@@ -747,6 +760,7 @@ export function ConversationWebSocketProvider({
sessionApiKey,
conversationId,
conversationUrl,
oldestPreloadedTimestamp,
]);
// Separate WebSocket options for planning agent connection

View File

@@ -1,6 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import EventService from "#/api/event-service/event-service.api";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { OpenHandsEvent } from "#/types/v1/core";
export interface ConversationHistoryResult {
events: OpenHandsEvent[];
/**
* The oldest timestamp from preloaded events, used for WebSocket handoff.
* WebSocket should use after_timestamp to only receive events newer than this.
*/
oldestTimestamp: string | null;
}
export const useConversationHistory = (conversationId?: string) => {
const { data: conversation } = useUserConversation(conversationId ?? null);
@@ -9,14 +19,42 @@ export const useConversationHistory = (conversationId?: string) => {
return useQuery({
queryKey: ["conversation-history", conversationId, conversationVersion],
enabled: !!conversationId && !!conversation,
queryFn: async () => {
if (!conversationId || !conversationVersion) return [];
if (conversationVersion === "V1") {
return EventService.searchEventsV1(conversationId);
queryFn: async (): Promise<ConversationHistoryResult> => {
if (!conversationId || !conversationVersion) {
return { events: [], oldestTimestamp: null };
}
return EventService.searchEventsV0(conversationId);
if (conversationVersion === "V1") {
// Fetch newest events first for instant perceived load.
// User sees current conversation state immediately.
// NOTE: Currently limited to 100 most recent events for performance.
// Older events (if any) will be loaded via WebSocket resend_all.
// TODO(#12705): Implement cursor-based background pagination for >100 events.
const result = await EventService.searchEventsV1(conversationId, {
sort_order: "TIMESTAMP_DESC",
limit: 100,
});
// Extract oldest timestamp for WebSocket handoff.
// Events are sorted DESC (newest first), so last item has oldest timestamp.
// WebSocket will use after_timestamp to only send events >= this timestamp,
// ensuring no duplicates while also catching any events created during the
// brief window between REST fetch and WebSocket connect (server timestamps
// are monotonically increasing, so no race condition).
const oldestTimestamp =
result.items.length > 0
? result.items[result.items.length - 1].timestamp
: null;
return {
events: result.items,
oldestTimestamp,
};
}
// V0 conversations - legacy behavior (no bi-directional loading)
const events = await EventService.searchEventsV0(conversationId);
return { events, oldestTimestamp: null };
},
staleTime: Infinity,
gcTime: 30 * 60 * 1000, // 30 minutes — survive navigation away and back (AC5)

View File

@@ -65,12 +65,12 @@ from openhands.app_server.services.httpx_client_injector import (
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.dependencies import get_dependencies
# Handle anext compatibility for Python < 3.10
if sys.version_info >= (3, 10):

View File

@@ -10,8 +10,8 @@ from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.config import depends_event_service
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.sdk import Event
from openhands.server.dependencies import get_dependencies
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware

View File

@@ -22,6 +22,9 @@ from openhands.sdk.utils.models import (
get_known_concrete_subclasses,
)
# TODO(OpenHands/evaluation#418): import from openhands.sdk.utils.redact
from openhands.utils._redact_compat import redact_text_secrets
_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
EventKind = str
@@ -56,7 +59,11 @@ class LoggingCallbackProcessor(EventCallbackProcessor):
callback: EventCallback,
event: Event,
) -> EventCallbackResult:
_logger.info(f'Callback {callback.id} Invoked for event {event}')
_logger.info(
'Callback %s Invoked for event %s',
callback.id,
redact_text_secrets(str(event)),
)
return EventCallbackResult(
status=EventCallbackResultStatus.SUCCESS,
event_callback_id=callback.id,

View File

@@ -23,6 +23,9 @@ from openhands.app_server.utils.docker_utils import (
)
from openhands.sdk import Event, MessageEvent
# TODO(OpenHands/evaluation#418): import from openhands.sdk.utils.redact
from openhands.utils._redact_compat import redact_text_secrets
_logger = logging.getLogger(__name__)
# Delay between attempts to poll title
@@ -88,7 +91,11 @@ class SetTitleCallbackProcessor(EventCallbackProcessor):
get_httpx_client,
)
_logger.info(f'Callback {callback.id} Invoked for event {event}')
_logger.info(
'Callback %s Invoked for event %s',
callback.id,
redact_text_secrets(str(event)),
)
state = InjectorState()
setattr(state, USER_CONTEXT_ATTR, ADMIN)

View File

@@ -13,7 +13,7 @@ from openhands.app_server.pending_messages.pending_message_models import (
from openhands.app_server.pending_messages.pending_message_service import (
PendingMessageService,
)
from openhands.server.dependencies import get_dependencies
from openhands.app_server.utils.dependencies import get_dependencies
logger = logging.getLogger(__name__)

View File

@@ -19,7 +19,7 @@ from openhands.app_server.sandbox.sandbox_service import (
)
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.server.dependencies import get_dependencies
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.server.user_auth.user_auth import (
get_for_user as get_user_auth_for_user,
)

View File

@@ -12,7 +12,7 @@ from openhands.app_server.sandbox.sandbox_spec_models import (
from openhands.app_server.sandbox.sandbox_spec_service import (
SandboxSpecService,
)
from openhands.server.dependencies import get_dependencies
from openhands.app_server.utils.dependencies import get_dependencies
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware

View File

@@ -0,0 +1,329 @@
"""Secrets router for OpenHands App Server.
This module provides the V1 API routes for secrets under /api/v1/secrets.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from openhands.app_server.errors import AuthError
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.models import EditResponse
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
CustomSecret,
ProviderType,
)
from openhands.integrations.utils import validate_provider_token
from openhands.server.settings import (
CustomSecretModel,
CustomSecretWithoutValueModel,
GETCustomSecrets,
POSTProviderModel,
)
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets,
get_secrets_store,
)
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.secrets.secrets_store import SecretsStore
# Create router with /api/v1/secrets prefix
router = APIRouter(
prefix='/secrets',
tags=['Secrets'],
dependencies=get_dependencies(),
)
# =================================================
# SECTION: Helper functions for git providers
# =================================================
def _check_token_type(
confirmed_token_type: ProviderType | None, token_type: ProviderType
) -> None:
"""Returns error message if token type doesn't match, None otherwise."""
if not confirmed_token_type or confirmed_token_type != token_type:
raise AuthError(
f'Invalid token. Please make sure it is a valid {token_type.value} token.'
)
async def check_provider_tokens(
incoming_provider_tokens: POSTProviderModel,
existing_provider_tokens: PROVIDER_TOKEN_TYPE | None,
) -> None:
if incoming_provider_tokens.provider_tokens:
# Determine whether tokens are valid
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
if token_value.token:
confirmed_token_type = await validate_provider_token(
token_value.token, token_value.host
) # FE always sends latest host
_check_token_type(confirmed_token_type, token_type)
existing_token = (
existing_provider_tokens.get(token_type, None)
if existing_provider_tokens
else None
)
if (
existing_token
and (existing_token.host != token_value.host)
and existing_token.token
):
confirmed_token_type = await validate_provider_token(
existing_token.token, token_value.host
)
# Host has changed, check it against existing token
_check_token_type(confirmed_token_type, token_type)
# =================================================
# SECTION: Git Provider Token Endpoints
# =================================================
@router.post(
'/git-providers',
tags=['Git Providers'],
)
async def store_provider_tokens(
provider_info: POSTProviderModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
) -> EditResponse:
"""Store git provider tokens.
Saves the git provider tokens (GitHub, GitLab, Bitbucket, etc.) for the authenticated user.
Returns:
200: Git providers stored successfully
401: Invalid token
500: Error storing git providers
"""
await check_provider_tokens(provider_info, provider_tokens)
user_secrets = await secrets_store.load()
if not user_secrets:
user_secrets = Secrets()
if provider_info.provider_tokens:
existing_providers = [provider for provider in user_secrets.provider_tokens]
# Merge incoming settings store with the existing one
for provider, token_value in list(provider_info.provider_tokens.items()):
if provider in existing_providers and not token_value.token:
existing_token = user_secrets.provider_tokens.get(provider)
if existing_token and existing_token.token:
provider_info.provider_tokens[provider] = existing_token
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
provider
].model_copy(update={'host': token_value.host})
updated_secrets = user_secrets.model_copy(
update={'provider_tokens': provider_info.provider_tokens}
)
await secrets_store.store(updated_secrets)
return EditResponse(
message='Git providers stored',
)
@router.delete(
'/git-providers',
tags=['Git Providers'],
)
async def unset_provider_tokens(
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> EditResponse:
"""Unset (delete) all git provider tokens.
Removes all git provider tokens for the authenticated user.
Returns:
200: Git provider tokens unset successfully
500: Error unsetting git provider tokens
"""
user_secrets = await secrets_store.load()
if user_secrets:
updated_secrets = user_secrets.model_copy(update={'provider_tokens': {}})
await secrets_store.store(updated_secrets)
return EditResponse(message='Unset Git provider tokens')
# =================================================
# SECTION: Custom Secrets Endpoints
# =================================================
@router.get('', response_model=GETCustomSecrets)
async def load_custom_secrets_names(
user_secrets: Secrets | None = Depends(get_secrets),
) -> GETCustomSecrets:
"""Load custom secret names.
Retrieves the names and descriptions of all custom secrets for the authenticated user.
Returns:
GETCustomSecrets: List of custom secrets (without values)
"""
if not user_secrets:
return GETCustomSecrets(custom_secrets=[])
custom_secrets: list[CustomSecretWithoutValueModel] = []
if user_secrets.custom_secrets:
for secret_name, secret_value in user_secrets.custom_secrets.items():
custom_secret = CustomSecretWithoutValueModel(
name=secret_name,
description=secret_value.description,
)
custom_secrets.append(custom_secret)
return GETCustomSecrets(custom_secrets=custom_secrets)
@router.post('', status_code=status.HTTP_201_CREATED)
async def create_custom_secret(
incoming_secret: CustomSecretModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> EditResponse:
"""Create a custom secret.
Creates a new custom secret for the authenticated user.
Returns:
201: Secret created successfully
400: Secret already exists
500: Error creating secret
"""
existing_secrets = await secrets_store.load()
custom_secrets = dict(existing_secrets.custom_secrets) if existing_secrets else {}
secret_name = incoming_secret.name
secret_value = incoming_secret.value
secret_description = incoming_secret.description
if secret_name in custom_secrets:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Secret {secret_name} already exists',
)
custom_secrets[secret_name] = CustomSecret(
secret=secret_value,
description=secret_description or '',
)
# Create a new Secrets that preserves provider tokens
updated_user_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens if existing_secrets else {}, # type: ignore[arg-type]
)
await secrets_store.store(updated_user_secrets)
return EditResponse(
message='Secret created successfully',
)
@router.put('/{secret_id}')
async def update_custom_secret(
secret_id: str,
incoming_secret: CustomSecretWithoutValueModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> EditResponse:
"""Update a custom secret.
Updates the name and/or description of an existing custom secret.
Returns:
200: Secret updated successfully
400: Secret name already exists
404: Secret not found
500: Error updating secret
"""
existing_secrets = await secrets_store.load()
if existing_secrets:
# Check if the secret to update exists
if secret_id not in existing_secrets.custom_secrets:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Secret with ID {secret_id} not found',
)
secret_name = incoming_secret.name
secret_description = incoming_secret.description
custom_secrets = dict(existing_secrets.custom_secrets)
existing_secret = custom_secrets.pop(secret_id)
if secret_name != secret_id and secret_name in custom_secrets:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Secret {secret_name} already exists',
)
custom_secrets[secret_name] = CustomSecret(
secret=existing_secret.secret,
description=secret_description or '',
)
updated_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return EditResponse(
message='Secret updated successfully',
)
@router.delete('/{secret_id}')
async def delete_custom_secret(
secret_id: str,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> EditResponse:
"""Delete a custom secret.
Removes a custom secret for the authenticated user.
Returns:
200: Secret deleted successfully
404: Secret not found
500: Error deleting secret
"""
existing_secrets = await secrets_store.load()
if existing_secrets:
# Get existing custom secrets
custom_secrets = dict(existing_secrets.custom_secrets)
# Check if the secret to delete exists
if secret_id not in custom_secrets:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Secret with ID {secret_id} not found',
)
# Remove the secret
custom_secrets.pop(secret_id)
# Create a new Secrets that preserves provider tokens and remaining secrets
updated_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return EditResponse(
message='Secret deleted successfully',
)

View File

@@ -0,0 +1,268 @@
"""Settings router for OpenHands App Server.
This module provides the V1 API routes for user settings under /api/v1/settings.
"""
import os
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderType,
)
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
from openhands.server.settings import (
GETSettingsModel,
)
from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
get_user_settings,
get_user_settings_store,
)
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.llm import get_provider_api_base, is_openhands_model
LITE_LLM_API_URL = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
)
# Create router with /api/v1/settings prefix
router = APIRouter(
prefix='/settings',
tags=['Settings'],
dependencies=get_dependencies(),
)
async def store_llm_settings(
settings: Settings, existing_settings: Settings
) -> Settings:
"""Merge LLM settings with existing settings."""
if not existing_settings:
return settings
# Preserve unset LLM settings
settings.llm_api_key = settings.llm_api_key or existing_settings.llm_api_key
settings.llm_model = settings.llm_model or existing_settings.llm_model
if settings.llm_base_url is None:
# Not provided at all (e.g. MCP config save) - preserve existing or auto-detect
if existing_settings.llm_base_url:
settings.llm_base_url = existing_settings.llm_base_url
elif is_openhands_model(settings.llm_model):
# OpenHands models use the LiteLLM proxy
settings.llm_base_url = LITE_LLM_API_URL
elif settings.llm_model:
# For non-openhands models, try to get URL from litellm
try:
api_base = get_provider_api_base(settings.llm_model)
if api_base:
settings.llm_base_url = api_base
else:
logger.debug(
f'No api_base found in litellm for model: {settings.llm_model}'
)
except Exception as e:
logger.error(
f'Failed to get api_base from litellm for model {settings.llm_model}: {e}'
)
elif settings.llm_base_url == '':
# Explicitly cleared by the user (basic view save or advanced view clear)
settings.llm_base_url = None
settings.search_api_key = (
settings.search_api_key or existing_settings.search_api_key
)
return settings
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
"""Convert settings with token data to Settings model."""
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
filtered_settings_data = {
key: value
for key, value in settings_data.items()
if key in Settings.model_fields # Ensures only `Settings` fields are included
}
# Convert the API keys to `SecretStr` instances
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key
# Create a new Settings instance
settings = Settings(**filtered_settings_data)
return settings
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
# a response object directly. We document the possible responses using the 'responses'
# parameter and maintain proper type annotations for mypy.
@router.get(
'',
response_model=GETSettingsModel,
responses={
404: {'description': 'Settings not found', 'model': dict},
401: {'description': 'Invalid token', 'model': dict},
},
)
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
settings_store: SettingsStore = Depends(get_user_settings_store),
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
"""Load user settings.
Retrieves the settings for the authenticated user, including LLM configuration,
provider tokens, and other user preferences.
Returns:
GETSettingsModel: The user settings with token data
Raises:
404: Settings not found
401: Invalid token
"""
try:
if not settings:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Settings not found'},
)
# On initial load, user secrets may not be populated with values migrated from settings store
user_secrets = await invalidate_legacy_secrets_store(
settings, settings_store, secrets_store
)
# If invalidation is successful, then the returned user secrets holds the most recent values
git_providers = (
user_secrets.provider_tokens if user_secrets else provider_tokens
)
provider_tokens_set: dict[ProviderType, str | None] = {}
if git_providers:
for provider_type, provider_token in git_providers.items():
if provider_token.token or provider_token.user_id:
provider_tokens_set[provider_type] = provider_token.host
settings_with_token_data = GETSettingsModel(
**settings.model_dump(exclude={'secrets_store'}),
llm_api_key_set=settings.llm_api_key is not None
and bool(settings.llm_api_key),
search_api_key_set=settings.search_api_key is not None
and bool(settings.search_api_key),
provider_tokens_set=provider_tokens_set,
)
# If the base url matches the default for the provider, we don't send it
# So that the frontend can display basic mode
if is_openhands_model(settings.llm_model):
if settings.llm_base_url == LITE_LLM_API_URL:
settings_with_token_data.llm_base_url = None
elif settings.llm_model and settings.llm_base_url == get_provider_api_base(
settings.llm_model
):
settings_with_token_data.llm_base_url = None
settings_with_token_data.llm_api_key = None
settings_with_token_data.search_api_key = None
settings_with_token_data.sandbox_api_key = None
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
# Get user_id from settings if available
user_id = getattr(settings, 'user_id', 'unknown') if settings else 'unknown'
logger.info(
f'Returning 401 Unauthorized - Invalid token for user_id: {user_id}'
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
@router.post(
'',
response_model=None,
responses={
200: {'description': 'Settings stored successfully', 'model': dict},
500: {'description': 'Error storing settings', 'model': dict},
},
)
async def store_settings(
settings: Settings,
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
"""Store user settings.
Saves the user settings including LLM configuration, provider tokens,
and other user preferences.
Returns:
200: Settings stored successfully
500: Error storing settings
"""
# Check provider tokens are valid
try:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
if existing_settings:
settings = await store_llm_settings(settings, existing_settings)
# Keep existing analytics consent if not provided
if settings.user_consents_to_analytics is None:
settings.user_consents_to_analytics = (
existing_settings.user_consents_to_analytics
)
# Keep existing disabled_skills if not provided
if settings.disabled_skills is None:
settings.disabled_skills = existing_settings.disabled_skills
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
config.sandbox.remote_runtime_resource_factor = (
settings.remote_runtime_resource_factor
)
# Update git configuration with new settings
git_config_updated = False
if settings.git_user_name is not None:
config.git_user_name = settings.git_user_name
git_config_updated = True
if settings.git_user_email is not None:
config.git_user_email = settings.git_user_email
git_config_updated = True
# Note: Git configuration will be applied when new sessions are initialized
# Existing sessions will continue with their current git configuration
if git_config_updated:
logger.info(
f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}'
)
settings = convert_to_settings(settings)
await settings_store.store(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing settings: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)

View File

@@ -5,9 +5,9 @@ import yaml
from fastapi import APIRouter, Query
from pydantic import BaseModel
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.memory.memory import GLOBAL_MICROAGENTS_DIR, USER_MICROAGENTS_DIR
from openhands.server.dependencies import get_dependencies
router = APIRouter(prefix='/skills', tags=['Skills'], dependencies=get_dependencies())

View File

@@ -9,7 +9,7 @@ from openhands.app_server.config import depends_user_context
from openhands.app_server.sandbox.session_auth import validate_session_key
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.server.dependencies import get_dependencies
from openhands.app_server.utils.dependencies import get_dependencies
_logger = logging.getLogger(__name__)

View File

@@ -1,11 +1,3 @@
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
# - V1 application server (in this repo): openhands/app_server/
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
import os
from fastapi import Depends, HTTPException, status

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel
class EditResponse(BaseModel):
"""General response to an edit operation"""
message: str

View File

@@ -9,6 +9,12 @@ from openhands.app_server.pending_messages.pending_message_router import (
router as pending_message_router,
)
from openhands.app_server.sandbox import sandbox_router, sandbox_spec_router
from openhands.app_server.secrets.secrets_router import (
router as secrets_router,
)
from openhands.app_server.settings.settings_router import (
router as settings_router,
)
from openhands.app_server.user import skills_router, user_router
from openhands.app_server.web_client import web_client_router
@@ -19,6 +25,8 @@ router.include_router(app_conversation_router.router)
router.include_router(pending_message_router)
router.include_router(sandbox_router.router)
router.include_router(sandbox_spec_router.router)
router.include_router(settings_router)
router.include_router(secrets_router)
router.include_router(user_router.router)
router.include_router(skills_router.router)
router.include_router(webhook_router.router)

View File

@@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationInfo,
)
from openhands.app_server.config import depends_app_conversation_info_service
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.event_filter import EventFilter
@@ -27,7 +28,6 @@ from openhands.events.serialization.event import event_to_dict
from openhands.memory.memory import Memory
from openhands.microagent.types import InputMetadata
from openhands.runtime.base import Runtime
from openhands.server.dependencies import get_dependencies
from openhands.server.session.conversation import ServerConversation
from openhands.server.shared import conversation_manager, file_store
from openhands.server.user_auth import get_user_id
@@ -83,7 +83,7 @@ def _get_v0_conversation_config(
}
@app.get('/config')
@app.get('/config', deprecated=True)
async def get_remote_runtime_config(
conversation_id: str,
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
@@ -91,6 +91,10 @@ async def get_remote_runtime_config(
) -> JSONResponse:
"""Retrieve the runtime configuration.
.. deprecated::
This endpoint is deprecated. The config is now returned as part of
GET /api/v1/app-conversation/{conversation_id}. Use that endpoint instead.
For V0 conversations: returns runtime_id and session_id from the runtime.
For V1 conversations: returns sandbox_id as runtime_id and conversation_id as session_id.
"""

View File

@@ -9,12 +9,12 @@
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_filter import EventFilter
from openhands.events.serialization import event_to_dict
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
from openhands.server.dependencies import get_dependencies
from openhands.server.session.conversation import ServerConversation
from openhands.server.utils import get_conversation
from openhands.utils.async_utils import call_sync_from_async
@@ -24,7 +24,7 @@ app = APIRouter(
)
@app.post('/submit-feedback')
@app.post('/submit-feedback', deprecated=True)
async def submit_feedback(
request: Request, conversation: ServerConversation = Depends(get_conversation)
) -> JSONResponse:
@@ -32,6 +32,10 @@ async def submit_feedback(
This function stores the provided feedback data.
.. deprecated::
Submitting feedback in OSS doesn't really make sense. This endpoint is deprecated
and will be removed.
To submit feedback:
```sh
curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:"

View File

@@ -14,10 +14,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, JSONResponse
from starlette.background import BackgroundTask
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.base import Runtime
from openhands.server.dependencies import get_dependencies
from openhands.server.file_config import FILES_TO_IGNORE
from openhands.server.files import POSTUploadFilesModel
from openhands.server.session.conversation import ServerConversation

View File

@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
@@ -32,7 +33,6 @@ from openhands.microagent.types import (
MicroagentContentResponse,
MicroagentResponse,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import server_config
from openhands.server.user_auth import (
get_access_token,
@@ -308,6 +308,7 @@ def _extract_repo_name(repository_name: str) -> str:
@app.get(
'/repository/{repository_name:path}/microagents',
response_model=list[MicroagentResponse],
deprecated=True,
)
async def get_repository_microagents(
repository_name: str,
@@ -317,6 +318,10 @@ async def get_repository_microagents(
) -> list[MicroagentResponse] | JSONResponse:
"""Scan the microagents directory of a repository and return the list of microagents.
.. deprecated::
This endpoint is deprecated. The microagents UI has already been removed
and is not supported in V1.
The microagents directory location depends on the git provider and actual repository name:
- If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
@@ -371,6 +376,7 @@ async def get_repository_microagents(
@app.get(
'/repository/{repository_name:path}/microagents/content',
response_model=MicroagentContentResponse,
deprecated=True,
)
async def get_repository_microagent_content(
repository_name: str,
@@ -383,6 +389,10 @@ async def get_repository_microagent_content(
) -> MicroagentContentResponse | JSONResponse:
"""Fetch the content of a specific microagent file from a repository.
.. deprecated::
This endpoint is deprecated. The microagents UI has already been removed
and is not supported in V1.
Args:
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
file_path: Query parameter - Path to the microagent file within the repository

View File

@@ -47,6 +47,7 @@ from openhands.app_server.services.db_session_injector import set_db_session_kee
from openhands.app_server.services.httpx_client_injector import (
set_httpx_client_keep_open,
)
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.logger import openhands_logger as logger
@@ -77,7 +78,6 @@ from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
@@ -704,13 +704,18 @@ async def _delete_v0_conversation(conversation_id: str, user_id: str | None) ->
return True
@app.get('/conversations/{conversation_id}/remember-prompt')
@app.get('/conversations/{conversation_id}/remember-prompt', deprecated=True)
async def get_prompt(
event_id: int,
conversation_id: str = Depends(validate_conversation_id),
user_settings: SettingsStore = Depends(get_user_settings_store),
metadata: ConversationMetadata = Depends(get_conversation_metadata),
):
"""Get the remember prompt for the microagent UI.
.. deprecated::
This endpoint is deprecated. Microagent UI is deprecated in V1.
"""
# get event store for the conversation
event_store = EventStore(
sid=conversation_id, file_store=file_store, user_id=metadata.user_id
@@ -1467,7 +1472,7 @@ def _create_combined_page_id(
return base64.b64encode(json.dumps(next_page_data).encode()).decode()
@app.get('/microagent-management/conversations')
@app.get('/microagent-management/conversations', deprecated=True)
async def get_microagent_management_conversations(
selected_repository: str,
page_id: str | None = None,
@@ -1478,6 +1483,9 @@ async def get_microagent_management_conversations(
) -> ConversationInfoResultSet:
"""Get conversations for the microagent management page with pagination support.
.. deprecated::
This endpoint is deprecated. Microagent UI is deprecated in V1.
This endpoint returns conversations with conversation_trigger = 'microagent_management'
and only includes conversations with active PRs. Pagination is supported.

View File

@@ -11,9 +11,9 @@ from typing import Any
from fastapi import APIRouter, Depends, Request
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.controller.agent import Agent
from openhands.security.options import SecurityAnalyzers
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import config, server_config
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
@@ -36,10 +36,14 @@ async def get_litellm_models(
return models
@app.get('/agents', response_model=list[str])
@app.get('/agents', response_model=list[str], deprecated=True)
async def get_agents() -> list[str]:
"""Get all agents supported by LiteLLM.
.. deprecated::
This endpoint is deprecated. The agent definitions are now part of the
OpenAPI schema so this is no longer required.
To get the agents:
```sh
curl http://localhost:3000/api/agents
@@ -51,10 +55,14 @@ async def get_agents() -> list[str]:
return sorted(Agent.list_agents())
@app.get('/security-analyzers', response_model=list[str])
@app.get('/security-analyzers', response_model=list[str], deprecated=True)
async def get_security_analyzers() -> list[str]:
"""Get all supported security analyzers.
.. deprecated::
This endpoint is deprecated. The security analyzers are now part of the
OpenAPI schema so this is no longer required.
To get the security analyzers:
```sh
curl http://localhost:3000/api/security-analyzers

View File

@@ -7,13 +7,28 @@
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, CustomSecret
from openhands.integrations.service_types import ProviderType
from openhands.integrations.utils import validate_provider_token
from openhands.server.dependencies import get_dependencies
from openhands.app_server.secrets.secrets_router import (
create_custom_secret as v1_create_custom_secret,
)
from openhands.app_server.secrets.secrets_router import (
delete_custom_secret as v1_delete_custom_secret,
)
from openhands.app_server.secrets.secrets_router import (
load_custom_secrets_names as v1_load_custom_secrets_names,
)
from openhands.app_server.secrets.secrets_router import (
store_provider_tokens as v1_store_provider_tokens,
)
from openhands.app_server.secrets.secrets_router import (
unset_provider_tokens as v1_unset_provider_tokens,
)
from openhands.app_server.secrets.secrets_router import (
update_custom_secret as v1_update_custom_secret,
)
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.app_server.utils.models import EditResponse
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.server.settings import (
CustomSecretModel,
CustomSecretWithoutValueModel,
@@ -60,126 +75,20 @@ async def invalidate_legacy_secrets_store(
return None
def process_token_validation_result(
confirmed_token_type: ProviderType | None, token_type: ProviderType
) -> str:
if not confirmed_token_type or confirmed_token_type != token_type:
return (
f'Invalid token. Please make sure it is a valid {token_type.value} token.'
)
return ''
async def check_provider_tokens(
incoming_provider_tokens: POSTProviderModel,
existing_provider_tokens: PROVIDER_TOKEN_TYPE | None,
) -> str:
msg = ''
if incoming_provider_tokens.provider_tokens:
# Determine whether tokens are valid
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
if token_value.token:
confirmed_token_type = await validate_provider_token(
token_value.token, token_value.host
) # FE always sends latest host
msg = process_token_validation_result(confirmed_token_type, token_type)
existing_token = (
existing_provider_tokens.get(token_type, None)
if existing_provider_tokens
else None
)
if (
existing_token
and (existing_token.host != token_value.host)
and existing_token.token
):
confirmed_token_type = await validate_provider_token(
existing_token.token, token_value.host
) # Host has changed, check it against existing token
if not confirmed_token_type or confirmed_token_type != token_type:
msg = process_token_validation_result(
confirmed_token_type, token_type
)
return msg
@app.post('/add-git-providers')
@app.post('/add-git-providers', deprecated=True)
async def store_provider_tokens(
provider_info: POSTProviderModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
) -> JSONResponse:
provider_err_msg = await check_provider_tokens(provider_info, provider_tokens)
if provider_err_msg:
# We don't have direct access to user_id here, but we can log the provider info
logger.info(
f'Returning 401 Unauthorized - Provider token error: {provider_err_msg}'
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': provider_err_msg},
)
try:
user_secrets = await secrets_store.load()
if not user_secrets:
user_secrets = Secrets()
if provider_info.provider_tokens:
existing_providers = [provider for provider in user_secrets.provider_tokens]
# Merge incoming settings store with the existing one
for provider, token_value in list(provider_info.provider_tokens.items()):
if provider in existing_providers and not token_value.token:
existing_token = user_secrets.provider_tokens.get(provider)
if existing_token and existing_token.token:
provider_info.provider_tokens[provider] = existing_token
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
provider
].model_copy(update={'host': token_value.host})
updated_secrets = user_secrets.model_copy(
update={'provider_tokens': provider_info.provider_tokens}
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Git providers stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing git providers: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing git providers'},
)
) -> EditResponse:
return await v1_store_provider_tokens(provider_info, secrets_store, provider_tokens)
@app.post('/unset-provider-tokens', response_model=dict[str, str])
@app.post('/unset-provider-tokens', deprecated=True)
async def unset_provider_tokens(
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
user_secrets = await secrets_store.load()
if user_secrets:
updated_secrets = user_secrets.model_copy(update={'provider_tokens': {}})
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Unset Git provider tokens'},
)
except Exception as e:
logger.warning(f'Something went wrong unsetting tokens: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong unsetting tokens'},
)
) -> EditResponse:
return await v1_unset_provider_tokens(secrets_store)
# =================================================
@@ -187,170 +96,33 @@ async def unset_provider_tokens(
# =================================================
@app.get('/secrets', response_model=GETCustomSecrets)
@app.get('/secrets', response_model=GETCustomSecrets, deprecated=True)
async def load_custom_secrets_names(
user_secrets: Secrets | None = Depends(get_secrets),
) -> GETCustomSecrets | JSONResponse:
try:
if not user_secrets:
return GETCustomSecrets(custom_secrets=[])
custom_secrets: list[CustomSecretWithoutValueModel] = []
if user_secrets.custom_secrets:
for secret_name, secret_value in user_secrets.custom_secrets.items():
custom_secret = CustomSecretWithoutValueModel(
name=secret_name,
description=secret_value.description,
)
custom_secrets.append(custom_secret)
return GETCustomSecrets(custom_secrets=custom_secrets)
except Exception as e:
logger.warning(f'Failed to load secret names: {e}')
logger.info('Returning 401 Unauthorized - Failed to get secret names')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Failed to get secret names'},
)
) -> GETCustomSecrets:
return await v1_load_custom_secrets_names(user_secrets)
@app.post('/secrets', response_model=dict[str, str])
@app.post('/secrets', status_code=status.HTTP_201_CREATED, deprecated=True)
async def create_custom_secret(
incoming_secret: CustomSecretModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
custom_secrets = (
dict(existing_secrets.custom_secrets) if existing_secrets else {}
)
secret_name = incoming_secret.name
secret_value = incoming_secret.value
secret_description = incoming_secret.description
if secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
custom_secrets[secret_name] = CustomSecret(
secret=secret_value,
description=secret_description or '',
)
# Create a new Secrets that preserves provider tokens
updated_user_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens
if existing_secrets
else {}, # type: ignore[arg-type]
)
await secrets_store.store(updated_user_secrets)
return JSONResponse(
status_code=status.HTTP_201_CREATED,
content={'message': 'Secret created successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong creating secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong creating secret'},
)
) -> EditResponse:
return await v1_create_custom_secret(incoming_secret, secrets_store)
@app.put('/secrets/{secret_id}', response_model=dict[str, str])
@app.put('/secrets/{secret_id}', deprecated=True)
async def update_custom_secret(
secret_id: str,
incoming_secret: CustomSecretWithoutValueModel,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
if existing_secrets:
# Check if the secret to update exists
if secret_id not in existing_secrets.custom_secrets:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': f'Secret with ID {secret_id} not found'},
)
secret_name = incoming_secret.name
secret_description = incoming_secret.description
custom_secrets = dict(existing_secrets.custom_secrets)
existing_secret = custom_secrets.pop(secret_id)
if secret_name != secret_id and secret_name in custom_secrets:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'message': f'Secret {secret_name} already exists'},
)
custom_secrets[secret_name] = CustomSecret(
secret=existing_secret.secret,
description=secret_description or '',
)
updated_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Secret updated successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong updating secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong updating secret'},
)
) -> EditResponse:
return await v1_update_custom_secret(secret_id, incoming_secret, secrets_store)
@app.delete('/secrets/{secret_id}')
@app.delete('/secrets/{secret_id}', deprecated=True)
async def delete_custom_secret(
secret_id: str,
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> JSONResponse:
try:
existing_secrets = await secrets_store.load()
if existing_secrets:
# Get existing custom secrets
custom_secrets = dict(existing_secrets.custom_secrets)
# Check if the secret to delete exists
if secret_id not in custom_secrets:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': f'Secret with ID {secret_id} not found'},
)
# Remove the secret
custom_secrets.pop(secret_id)
# Create a new Secrets that preserves provider tokens and remaining secrets
updated_secrets = Secrets(
custom_secrets=custom_secrets, # type: ignore[arg-type]
provider_tokens=existing_secrets.provider_tokens,
)
await secrets_store.store(updated_secrets)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Secret deleted successfully'},
)
except Exception as e:
logger.warning(f'Something went wrong deleting secret: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong deleting secret'},
)
) -> EditResponse:
return await v1_delete_custom_secret(secret_id, secrets_store)

View File

@@ -15,7 +15,7 @@ from fastapi import (
status,
)
from openhands.server.dependencies import get_dependencies
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.server.session.conversation import ServerConversation
from openhands.server.utils import get_conversation

View File

@@ -8,20 +8,22 @@
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
import os
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from openhands.core.logger import openhands_logger as logger
from openhands.app_server.settings.settings_router import (
load_settings as v1_load_settings,
)
from openhands.app_server.settings.settings_router import (
store_settings as v1_store_settings,
)
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderType,
)
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
from openhands.server.settings import (
GETSettingsModel,
)
from openhands.server.shared import config
from openhands.server.user_auth import (
get_provider_tokens,
get_secrets_store,
@@ -31,7 +33,6 @@ from openhands.server.user_auth import (
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.llm import get_provider_api_base, is_openhands_model
LITE_LLM_API_URL = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
@@ -47,6 +48,7 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
404: {'description': 'Settings not found', 'model': dict},
401: {'description': 'Invalid token', 'model': dict},
},
deprecated=True,
)
async def load_settings(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
@@ -54,110 +56,11 @@ async def load_settings(
settings: Settings = Depends(get_user_settings),
secrets_store: SecretsStore = Depends(get_secrets_store),
) -> GETSettingsModel | JSONResponse:
try:
if not settings:
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={'error': 'Settings not found'},
)
# On initial load, user secrets may not be populated with values migrated from settings store
user_secrets = await invalidate_legacy_secrets_store(
settings, settings_store, secrets_store
)
# If invalidation is successful, then the returned user secrets holds the most recent values
git_providers = (
user_secrets.provider_tokens if user_secrets else provider_tokens
)
provider_tokens_set: dict[ProviderType, str | None] = {}
if git_providers:
for provider_type, provider_token in git_providers.items():
if provider_token.token or provider_token.user_id:
provider_tokens_set[provider_type] = provider_token.host
settings_with_token_data = GETSettingsModel(
**settings.model_dump(exclude={'secrets_store'}),
llm_api_key_set=settings.llm_api_key is not None
and bool(settings.llm_api_key),
search_api_key_set=settings.search_api_key is not None
and bool(settings.search_api_key),
provider_tokens_set=provider_tokens_set,
)
# If the base url matches the default for the provider, we don't send it
# So that the frontend can display basic mode
if is_openhands_model(settings.llm_model):
if settings.llm_base_url == LITE_LLM_API_URL:
settings_with_token_data.llm_base_url = None
elif settings.llm_model and settings.llm_base_url == get_provider_api_base(
settings.llm_model
):
settings_with_token_data.llm_base_url = None
settings_with_token_data.llm_api_key = None
settings_with_token_data.search_api_key = None
settings_with_token_data.sandbox_api_key = None
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
# Get user_id from settings if available
user_id = getattr(settings, 'user_id', 'unknown') if settings else 'unknown'
logger.info(
f'Returning 401 Unauthorized - Invalid token for user_id: {user_id}'
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
)
return await v1_load_settings(
provider_tokens, settings_store, settings, secrets_store
)
async def store_llm_settings(
settings: Settings, existing_settings: Settings
) -> Settings:
# Convert to Settings model and merge with existing settings
if existing_settings:
# Keep existing LLM settings if not provided
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
if settings.llm_model is None:
settings.llm_model = existing_settings.llm_model
if settings.llm_base_url is None:
# Not provided at all (e.g. MCP config save) - preserve existing or auto-detect
if existing_settings.llm_base_url:
settings.llm_base_url = existing_settings.llm_base_url
elif is_openhands_model(settings.llm_model):
# OpenHands models use the LiteLLM proxy
settings.llm_base_url = LITE_LLM_API_URL
elif settings.llm_model:
# For non-openhands models, try to get URL from litellm
try:
api_base = get_provider_api_base(settings.llm_model)
if api_base:
settings.llm_base_url = api_base
else:
logger.debug(
f'No api_base found in litellm for model: {settings.llm_model}'
)
except Exception as e:
logger.error(
f'Failed to get api_base from litellm for model {settings.llm_model}: {e}'
)
elif settings.llm_base_url == '':
# Explicitly cleared by the user (basic view save or advanced view clear)
settings.llm_base_url = None
# Keep search API key if missing or empty
if not settings.search_api_key:
settings.search_api_key = existing_settings.search_api_key
return settings
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
# a response object directly. We document the possible responses using the 'responses'
# parameter and maintain proper type annotations for mypy.
@app.post(
'/settings',
response_model=None,
@@ -165,79 +68,10 @@ async def store_llm_settings(
200: {'description': 'Settings stored successfully', 'model': dict},
500: {'description': 'Error storing settings', 'model': dict},
},
deprecated=True,
)
async def store_settings(
settings: Settings,
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
# Check provider tokens are valid
try:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
if existing_settings:
settings = await store_llm_settings(settings, existing_settings)
# Keep existing analytics consent if not provided
if settings.user_consents_to_analytics is None:
settings.user_consents_to_analytics = (
existing_settings.user_consents_to_analytics
)
# Keep existing disabled_skills if not provided
if settings.disabled_skills is None:
settings.disabled_skills = existing_settings.disabled_skills
# Update sandbox config with new settings
if settings.remote_runtime_resource_factor is not None:
config.sandbox.remote_runtime_resource_factor = (
settings.remote_runtime_resource_factor
)
# Update git configuration with new settings
git_config_updated = False
if settings.git_user_name is not None:
config.git_user_name = settings.git_user_name
git_config_updated = True
if settings.git_user_email is not None:
config.git_user_email = settings.git_user_email
git_config_updated = True
# Note: Git configuration will be applied when new sessions are initialized
# Existing sessions will continue with their current git configuration
if git_config_updated:
logger.info(
f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}'
)
settings = convert_to_settings(settings)
await settings_store.store(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},
)
except Exception as e:
logger.warning(f'Something went wrong storing settings: {e}')
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
filtered_settings_data = {
key: value
for key, value in settings_data.items()
if key in Settings.model_fields # Ensures only `Settings` fields are included
}
# Convert the API keys to `SecretStr` instances
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key
# Create a new Settings instance
settings = Settings(**filtered_settings_data)
return settings
return await v1_store_settings(settings, settings_store)

View File

@@ -9,12 +9,12 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from openhands.app_server.utils.dependencies import get_dependencies
from openhands.core.logger import openhands_logger as logger
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
from openhands.events.event_filter import EventFilter
from openhands.events.event_store import EventStore
from openhands.events.serialization import event_to_trajectory
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import file_store
from openhands.server.utils import get_conversation_metadata
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
@@ -24,12 +24,16 @@ app = APIRouter(
)
@app.get('/trajectory')
@app.get('/trajectory', deprecated=True)
async def get_trajectory(
metadata: ConversationMetadata = Depends(get_conversation_metadata),
) -> JSONResponse:
"""Get trajectory.
.. deprecated::
This endpoint is deprecated. Use the V1 endpoint
``GET /app-conversation/{conversation_id}/download`` instead.
This function retrieves the current trajectory and returns it.
Uses the local EventStore which reads events from the file store,
so it works with both standalone and nested conversation managers.

View File

@@ -0,0 +1,168 @@
# TODO(OpenHands/evaluation#418): Delete this file and import directly from
# openhands.sdk.utils.redact once openhands-sdk >1.16.1 is released.
# These functions are copied from the SDK's redact.py to unblock PRs while
# waiting for the next SDK release.
#
# Source of truth: openhands-sdk/openhands/sdk/utils/redact.py
# in repo: https://github.com/OpenHands/software-agent-sdk
import copy
import re
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from openhands.sdk.utils.redact import sanitize_dict
# ---------------------------------------------------------------------------
# URL param redaction
# ---------------------------------------------------------------------------
SENSITIVE_URL_PARAMS = frozenset(
{
'tavilyapikey',
'apikey',
'api_key',
'token',
'access_token',
'secret',
'key',
}
)
def _is_secret_key(key: str) -> bool:
key_upper = key.upper()
return any(
p in key_upper
for p in (
'AUTHORIZATION',
'COOKIE',
'CREDENTIAL',
'KEY',
'PASSWORD',
'SECRET',
'SESSION',
'TOKEN',
)
)
def redact_url_params(url: str) -> str:
"""Redact sensitive query parameter values from a URL string."""
try:
parsed = urlparse(url)
except Exception:
return url
if not parsed.query:
return url
params = parse_qs(parsed.query, keep_blank_values=True)
redacted_params: dict[str, list[str]] = {}
for param_name, values in params.items():
if param_name.lower() in SENSITIVE_URL_PARAMS or _is_secret_key(param_name):
redacted_params[param_name] = ['<redacted>'] * len(values)
else:
redacted_params[param_name] = values
redacted_query = urlencode(redacted_params, doseq=True)
return urlunparse(parsed._replace(query=redacted_query))
def _walk_redact_urls(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: _walk_redact_urls(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_walk_redact_urls(item) for item in obj]
if isinstance(obj, str) and '?' in obj:
return redact_url_params(obj)
return obj
# ---------------------------------------------------------------------------
# sanitize_config
# ---------------------------------------------------------------------------
def sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
"""Deep-copy a config dict, redact secret keys and URL query params."""
config = copy.deepcopy(config)
config = sanitize_dict(config)
config = _walk_redact_urls(config)
return config
# ---------------------------------------------------------------------------
# Text / string redaction
# ---------------------------------------------------------------------------
_API_KEY_LITERAL_RE = re.compile(
r'\b('
# OpenRouter / OpenAI / Anthropic
r'sk-(?:or-v1|proj|ant-(?:api|oat)\d{2})-[A-Za-z0-9_-]{20,}'
r'|gsk_[A-Za-z0-9]{20,}' # GROQ
r'|hf_[A-Za-z0-9]{20,}' # HuggingFace
r'|tgp_v1_[A-Za-z0-9_-]{20,}' # Together AI
r'|ghp_[A-Za-z0-9]{20,}' # GitHub PAT (classic)
r'|github_pat_[A-Za-z0-9_]{20,}' # GitHub PAT (fine-grained)
r'|sk-oh-[A-Za-z0-9]{20,}' # OpenHands session tokens
r'|ctx7sk-[A-Za-z0-9_-]{10,}' # Context7 MCP keys
r'|cla_[A-Za-z0-9_-]{20,}' # Claude.ai MCP tokens
r'|sntryu_[A-Za-z0-9]{10,}' # Sentry tokens
r'|lin_api_[A-Za-z0-9]{10,}' # Linear API tokens
r'|tvly-[A-Za-z0-9_-]{10,}' # Tavily keys
r'|ATATT3x[A-Za-z0-9_-]{10,}' # Jira/Atlassian tokens
r'|xoxb-[A-Za-z0-9_-]{20,}' # Slack bot tokens
r'|xoxp-[A-Za-z0-9_-]{20,}' # Slack user tokens
r'|Bearer\s+[A-Za-z0-9_.-]{20,}' # Bearer tokens
r')'
)
def redact_api_key_literals(text: str) -> str:
"""Replace bare API key literals from common providers with <redacted>."""
return _API_KEY_LITERAL_RE.sub('<redacted>', text)
def redact_text_secrets(text: str) -> str:
"""Redact secrets from a string representation of a config object."""
# api_key='...' patterns
text = re.sub(r"api_key='[^']*'", "api_key='<redacted>'", text)
text = re.sub(r'api_key="[^"]*"', 'api_key="<redacted>"', text)
# Dict entries with sensitive key names
text = re.sub(
r"('[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*':\s*')[^']*(')",
r'\g<1><redacted>\2',
text,
)
text = re.sub(
r'("[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*":\s*")[^"]*(")',
r'\g<1><redacted>\2',
text,
)
# URL query params
text = re.sub(
r'((?:tavilyApiKey|apiKey|api_key|token|access_token|secret|key)=)'
r"[^&\s'\")\]]+",
r'\g<1><redacted>',
text,
flags=re.IGNORECASE,
)
# Authorization header values
text = re.sub(
r"('Authorization':\s*')[^']*(')",
r'\g<1><redacted>\2',
text,
)
# X-Session-API-Key header values
text = re.sub(
r"('X-Session-API-Key':\s*')[^']*(')",
r'\g<1><redacted>\2',
text,
)
# Bare API key literals
text = redact_api_key_literals(text)
return text

2
poetry.lock generated
View File

@@ -15028,4 +15028,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "c05d3af6c9418c68c85e848deb19b9a8be2e27f7581cfa81cfc0fe04afc2203f"
content-hash = "6f88369a1b446dfbe38c9e0cf52e9bdacfb69aad51a9f56548768d160cdafd95"

View File

@@ -21,7 +21,7 @@ dynamic = [ "version" ]
# Main dependencies (mirrors [tool.poetry.dependencies] for UV compatibility)
dependencies = [
"aiohttp>=3.13.3",
"aiohttp>=3.13.5",
"anthropic[vertex]",
"anyio==4.9",
"asyncpg>=0.30",
@@ -89,7 +89,7 @@ dependencies = [
"qtconsole>=5.6.1",
"rapidfuzz>=3.9",
"redis>=5.2,<7",
"requests>=2.32.5",
"requests>=2.33",
"setuptools>=78.1.1",
"shellingham>=1.5.4",
"sqlalchemy[asyncio]>=2.0.40",
@@ -167,7 +167,7 @@ authlib = ">=1.6.9" # CVE-2026-27962 (fixed in 1.
orjson = ">=3.11.6" # Pinned to fix CVE-2025-67221
litellm = ">=1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
aiohttp = ">=3.13.3" # Pin to avoid CVE-2025-69223 (vulnerable versions < 3.13.3)
aiohttp = ">=3.13.5" # Pin to avoid CVE-2026-22815 (vulnerable versions < 3.13.4)
google-genai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
google-auth-httplib2 = "*" # For Google Sheets authentication
@@ -228,7 +228,7 @@ pypdf = "^6.9.2"
pillow = "^12.1.1"
starlette = "^0.49.1"
urllib3 = "^2.6.3"
requests = "^2.32.5"
requests = "^2.33.0"
setuptools = ">=78.1.1"
# TODO: These are integrations that should probably be optional

View File

@@ -12,7 +12,7 @@ from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from openhands.app_server.event.event_router import batch_get_events, router
from openhands.server.dependencies import check_session_api_key
from openhands.app_server.utils.dependencies import check_session_api_key
def _make_mock_event_service(search_return=None, batch_get_return=None):

View File

@@ -413,7 +413,7 @@ def _build_integration_test_app(
router as sandbox_router,
)
from openhands.app_server.user.user_router import router as user_router
from openhands.server.dependencies import check_session_api_key
from openhands.app_server.utils.dependencies import check_session_api_key
app = FastAPI()

View File

@@ -9,14 +9,14 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.app_server.secrets.secrets_router import (
router as secrets_router,
)
from openhands.integrations.provider import (
CustomSecret,
ProviderToken,
ProviderType,
)
from openhands.server.routes.secrets import (
app as secrets_app,
)
from openhands.storage import get_file_store
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.secrets.file_secrets_store import FileSecretsStore
@@ -26,12 +26,12 @@ from openhands.storage.secrets.file_secrets_store import FileSecretsStore
def test_client():
"""Create a test client for the settings API."""
app = FastAPI()
app.include_router(secrets_app)
app.include_router(secrets_router)
# Mock SESSION_API_KEY to None to disable authentication in tests
with patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False):
# Clear the SESSION_API_KEY to disable auth dependency
with patch('openhands.server.dependencies._SESSION_API_KEY', None):
with patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None):
yield TestClient(app)
@@ -70,7 +70,7 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
await file_secrets_store.store(user_secrets)
# Make the GET request
response = test_client.get('/api/secrets')
response = test_client.get('/secrets')
print(response)
assert response.status_code == 200
@@ -107,7 +107,7 @@ async def test_load_custom_secrets_names_empty(test_client, file_secrets_store):
await file_secrets_store.store(user_secrets)
# Make the GET request
response = test_client.get('/api/secrets')
response = test_client.get('/secrets')
assert response.status_code == 200
# Check the response
@@ -130,7 +130,7 @@ async def test_add_custom_secret(test_client, file_secrets_store):
# Make the POST request to add a custom secret
add_secret_data = {'name': 'API_KEY', 'value': 'api-key-value', 'description': None}
response = test_client.post('/api/secrets', json=add_secret_data)
response = test_client.post('/secrets', json=add_secret_data)
assert response.status_code == 201
# Verify that the settings were stored with the new secret
@@ -158,7 +158,7 @@ async def test_create_custom_secret_with_no_existing_secrets(
'value': 'new-api-key-value',
'description': 'Test API Key',
}
response = test_client.post('/api/secrets', json=add_secret_data)
response = test_client.post('/secrets', json=add_secret_data)
assert response.status_code == 201
# Verify that the settings were stored with the new secret
@@ -196,7 +196,7 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
'name': 'API_KEY',
'description': None,
}
response = test_client.put('/api/secrets/API_KEY', json=update_secret_data)
response = test_client.put('/secrets/API_KEY', json=update_secret_data)
assert response.status_code == 200
# Verify that the settings were stored with the updated secret
@@ -236,7 +236,7 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
'value': 'api-key-value',
'description': None,
}
response1 = test_client.post('/api/secrets', json=add_secret_data1)
response1 = test_client.post('/secrets', json=add_secret_data1)
assert response1.status_code == 201
# Make the POST request to add second custom secret
@@ -245,7 +245,7 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
'value': 'db-password-value',
'description': None,
}
response = test_client.post('/api/secrets', json=add_secret_data2)
response = test_client.post('/secrets', json=add_secret_data2)
assert response.status_code == 201
# Verify that the settings were stored with the new secrets
@@ -293,7 +293,7 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
await file_secrets_store.store(user_secrets)
# Make the DELETE request to delete a custom secret
response = test_client.delete('/api/secrets/API_KEY')
response = test_client.delete('/secrets/API_KEY')
assert response.status_code == 200
# Verify that the settings were stored without the deleted secret
@@ -331,7 +331,7 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
await file_secrets_store.store(user_secrets)
# Make the DELETE request to delete a nonexistent custom secret
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
response = test_client.delete('/secrets/NONEXISTENT_KEY')
assert response.status_code == 404
# Verify that the settings were stored without changes to existing secrets
@@ -360,7 +360,7 @@ async def test_add_git_providers_with_host(test_client, file_secrets_store):
# Mock check_provider_tokens to return empty string (no error)
with patch(
'openhands.server.routes.secrets.check_provider_tokens',
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
AsyncMock(return_value=''),
):
# Add a GitHub provider with a host
@@ -369,7 +369,7 @@ async def test_add_git_providers_with_host(test_client, file_secrets_store):
'github': {'token': 'new-github-token', 'host': 'github.enterprise.com'}
}
}
response = test_client.post('/api/add-git-providers', json=add_provider_data)
response = test_client.post('/secrets/git-providers', json=add_provider_data)
assert response.status_code == 200
# Verify that the settings were stored with the new provider token and host
@@ -399,7 +399,7 @@ async def test_add_git_providers_update_host_only(test_client, file_secrets_stor
# Mock check_provider_tokens to return empty string (no error)
with patch(
'openhands.server.routes.secrets.check_provider_tokens',
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
AsyncMock(return_value=''),
):
# Update only the host
@@ -411,7 +411,7 @@ async def test_add_git_providers_update_host_only(test_client, file_secrets_stor
}
}
}
response = test_client.post('/api/add-git-providers', json=update_host_data)
response = test_client.post('/secrets/git-providers', json=update_host_data)
assert response.status_code == 200
# Verify that the host was updated but the token remains the same
@@ -447,9 +447,9 @@ async def test_add_git_providers_invalid_token_with_host(
'github': {'token': 'invalid-token', 'host': 'github.enterprise.com'}
}
}
response = test_client.post('/api/add-git-providers', json=add_provider_data)
response = test_client.post('/secrets/git-providers', json=add_provider_data)
assert response.status_code == 401
assert 'Invalid token' in response.json()['error']
assert 'Invalid token' in response.json()['detail']
@pytest.mark.asyncio
@@ -461,7 +461,7 @@ async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_s
# Mock check_provider_tokens to return empty string (no error)
with patch(
'openhands.server.routes.secrets.check_provider_tokens',
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
AsyncMock(return_value=''),
):
# Add multiple providers with hosts
@@ -471,7 +471,7 @@ async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_s
'gitlab': {'token': 'gitlab-token', 'host': 'gitlab.enterprise.com'},
}
}
response = test_client.post('/api/add-git-providers', json=add_providers_data)
response = test_client.post('/secrets/git-providers', json=add_providers_data)
assert response.status_code == 200
# Verify that both providers were stored with their respective hosts

View File

@@ -65,7 +65,7 @@ def test_client():
# Create a test client
with (
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
patch('openhands.server.dependencies._SESSION_API_KEY', None),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
return_value=MockUserAuth(),
@@ -96,13 +96,13 @@ async def test_settings_api_endpoints(test_client):
}
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
response = test_client.post('/api/v1/settings', json=settings_data)
# We're not checking the exact response, just that it doesn't error
assert response.status_code == 200
# Test the GET settings endpoint
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
# Test updating with partial settings
@@ -112,11 +112,11 @@ async def test_settings_api_endpoints(test_client):
'llm_api_key': None, # Should preserve existing value
}
response = test_client.post('/api/settings', json=partial_settings)
response = test_client.post('/api/v1/settings', json=partial_settings)
assert response.status_code == 200
# Test the unset-provider-tokens endpoint
response = test_client.post('/api/unset-provider-tokens')
response = test_client.delete('/api/v1/secrets/git-providers')
assert response.status_code == 200
@@ -128,11 +128,11 @@ async def test_search_api_key_preservation(test_client):
'search_api_key': 'initial-secret-key',
'llm_model': 'gpt-4',
}
response = test_client.post('/api/settings', json=initial_settings)
response = test_client.post('/api/v1/settings', json=initial_settings)
assert response.status_code == 200
# Verify key is set
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
assert response.json()['search_api_key_set'] is True
@@ -142,11 +142,11 @@ async def test_search_api_key_preservation(test_client):
'search_api_key': '', # The frontend sends an empty string here
'llm_model': 'claude-3-opus',
}
response = test_client.post('/api/settings', json=update_settings)
response = test_client.post('/api/v1/settings', json=update_settings)
assert response.status_code == 200
# 3. Verify the key was NOT wiped out (The Critical Check)
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
# If the bug was present, this would be False
assert response.json()['search_api_key_set'] is True
@@ -163,11 +163,11 @@ async def test_disabled_skills_persistence(test_client):
'llm_api_key': 'test-key',
'disabled_skills': ['skill_a', 'skill_b'],
}
response = test_client.post('/api/settings', json=settings_data)
response = test_client.post('/api/v1/settings', json=settings_data)
assert response.status_code == 200
# 2. Retrieve and verify
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
data = response.json()
assert data['disabled_skills'] == ['skill_a', 'skill_b']
@@ -176,10 +176,10 @@ async def test_disabled_skills_persistence(test_client):
update_settings = {
'disabled_skills': ['skill_c'],
}
response = test_client.post('/api/settings', json=update_settings)
response = test_client.post('/api/v1/settings', json=update_settings)
assert response.status_code == 200
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
data = response.json()
assert data['disabled_skills'] == ['skill_c']
@@ -188,10 +188,10 @@ async def test_disabled_skills_persistence(test_client):
update_settings = {
'disabled_skills': [],
}
response = test_client.post('/api/settings', json=update_settings)
response = test_client.post('/api/v1/settings', json=update_settings)
assert response.status_code == 200
response = test_client.get('/api/settings')
response = test_client.get('/api/v1/settings')
assert response.status_code == 200
data = response.json()
assert data['disabled_skills'] == []

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
from openhands.app_server.secrets.secrets_router import check_provider_tokens
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import OwnerType, Repository
@@ -17,7 +18,6 @@ from openhands.resolver.interfaces.issue import Issue
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
from openhands.resolver.send_pull_request import PR_SIGNATURE, send_pull_request
from openhands.runtime.base import Runtime
from openhands.server.routes.secrets import check_provider_tokens
from openhands.server.settings import POSTProviderModel
from openhands.server.types import AppMode
@@ -412,9 +412,10 @@ async def test_check_provider_tokens_with_only_bitbucket():
# Call check_provider_tokens with the patched validate_provider_token
with patch(
'openhands.server.routes.secrets.validate_provider_token', mock_validate
'openhands.app_server.secrets.secrets_router.validate_provider_token',
mock_validate,
):
result = await check_provider_tokens(post_model, None)
await check_provider_tokens(post_model, None)
# Verify that validate_provider_token was called only once (for Bitbucket)
assert mock_validate.call_count == 1
@@ -423,9 +424,6 @@ async def test_check_provider_tokens_with_only_bitbucket():
args, kwargs = mock_validate.call_args
assert args[0].get_secret_value() == 'username:app_password'
# Verify that no error message was returned
assert result == ''
@pytest.mark.asyncio
async def test_bitbucket_sort_parameter_mapping():

View File

@@ -9,13 +9,13 @@ from fastapi.testclient import TestClient
from httpcore import Request
from pydantic import SecretStr
from openhands.app_server.utils.dependencies import check_session_api_key
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import (
AuthenticationError,
Repository,
)
from openhands.microagent.types import MicroagentContentResponse
from openhands.server.dependencies import check_session_api_key
from openhands.server.routes.git import app as git_app
from openhands.server.user_auth import (
get_access_token,

View File

@@ -6,16 +6,17 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.app_server.errors import AuthError
from openhands.app_server.secrets.secrets_router import (
check_provider_tokens,
)
from openhands.app_server.settings.settings_router import store_llm_settings
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.routes.secrets import (
app as secrets_router,
)
from openhands.server.routes.secrets import (
check_provider_tokens,
)
from openhands.server.routes.settings import store_llm_settings
from openhands.server.settings import POSTProviderModel
from openhands.storage import get_file_store
from openhands.storage.data_models.secrets import Secrets
@@ -39,10 +40,10 @@ def test_client():
with (
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
patch('openhands.server.dependencies._SESSION_API_KEY', None),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.server.routes.secrets.check_provider_tokens',
AsyncMock(return_value=''),
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
AsyncMock(return_value=None),
),
):
client = TestClient(test_app)
@@ -77,14 +78,10 @@ async def test_check_provider_tokens_valid():
# Mock the validate_provider_token function to return GITHUB for valid tokens
with patch(
'openhands.server.routes.secrets.validate_provider_token'
'openhands.app_server.secrets.secrets_router.validate_provider_token'
) as mock_validate:
mock_validate.return_value = ProviderType.GITHUB
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should return empty string for valid token
assert result == ''
await check_provider_tokens(providers, existing_provider_tokens)
mock_validate.assert_called_once()
@@ -99,14 +96,14 @@ async def test_check_provider_tokens_invalid():
# Mock the validate_provider_token function to return None for invalid tokens
with patch(
'openhands.server.routes.secrets.validate_provider_token'
'openhands.app_server.secrets.secrets_router.validate_provider_token'
) as mock_validate:
mock_validate.return_value = None
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should raise error for invalid token
with pytest.raises(AuthError):
await check_provider_tokens(providers, existing_provider_tokens)
# Should return error message for invalid token
assert 'Invalid token' in result
mock_validate.assert_called_once()
@@ -120,10 +117,7 @@ async def test_check_provider_tokens_wrong_type():
# Empty existing provider tokens
existing_provider_tokens = {}
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should return empty string for no providers
assert result == ''
await check_provider_tokens(providers, existing_provider_tokens)
@pytest.mark.asyncio
@@ -134,10 +128,7 @@ async def test_check_provider_tokens_no_tokens():
# Empty existing provider tokens
existing_provider_tokens = {}
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should return empty string when no tokens provided
assert result == ''
await check_provider_tokens(providers, existing_provider_tokens)
# Tests for store_llm_settings
@@ -339,7 +330,7 @@ async def test_store_llm_settings_litellm_error_logged():
)
# The function should not raise even if litellm fails
with patch('openhands.server.routes.settings.logger') as mock_logger:
with patch('openhands.app_server.settings.settings_router.logger') as mock_logger:
result = await store_llm_settings(settings, existing_settings)
# llm_base_url should remain None since litellm couldn't find the model

View File

@@ -65,7 +65,7 @@ class MockUserAuth(UserAuth):
def test_client():
with (
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
patch('openhands.server.dependencies._SESSION_API_KEY', None),
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
patch(
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
return_value=MockUserAuth(),

View File

@@ -3,11 +3,11 @@ from unittest.mock import patch
from pydantic import SecretStr
from openhands.app_server.settings.settings_router import convert_to_settings
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
from openhands.server.routes.settings import convert_to_settings
from openhands.storage.data_models.settings import Settings

82
uv.lock generated
View File

@@ -50,7 +50,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -61,42 +61,42 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
]
[[package]]
@@ -3788,7 +3788,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.3" },
{ name = "aiohttp", specifier = ">=3.13.5" },
{ name = "anthropic", extras = ["vertex"] },
{ name = "anyio", specifier = "==4.9" },
{ name = "asyncpg", specifier = ">=0.30" },
@@ -3859,7 +3859,7 @@ requires-dist = [
{ name = "qtconsole", specifier = ">=5.6.1" },
{ name = "rapidfuzz", specifier = ">=3.9" },
{ name = "redis", specifier = ">=5.2,<7" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "requests", specifier = ">=2.33.0" },
{ name = "runloop-api-client", marker = "extra == 'third-party-runtimes'", specifier = "==0.50" },
{ name = "setuptools", specifier = ">=78.1.1" },
{ name = "shellingham", specifier = ">=1.5.4" },
@@ -7910,7 +7910,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -7918,9 +7918,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]