Merge branch 'main' into fix-openhands-name

This commit is contained in:
Engel Nyst
2026-01-04 01:21:23 +01:00
committed by GitHub
38 changed files with 1189 additions and 10344 deletions

View File

@@ -1,156 +0,0 @@
# Workflow that validates the VSCode extension builds correctly
name: VSCode Extension CI
# * Always run on "main"
# * Run on PRs that have changes in the VSCode extension folder or this workflow
# * Run on tags that start with "ext-v"
on:
push:
branches:
- main
tags:
- 'ext-v*'
pull_request:
paths:
- 'openhands/integrations/vscode/**'
- 'build_vscode.py'
- '.github/workflows/vscode-extension-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
# Validate VSCode extension builds correctly
validate-vscode-extension:
name: Validate VSCode Extension Build
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22'
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install VSCode extension dependencies
working-directory: ./openhands/integrations/vscode
run: npm ci
- name: Build VSCode extension via build_vscode.py
run: python build_vscode.py
env:
# Ensure we don't skip the build
SKIP_VSCODE_BUILD: ""
- name: Validate .vsix file
run: |
# Verify the .vsix was created and is valid
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
echo "✅ VSCode extension built successfully"
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
# Basic validation that the .vsix is a valid zip file
echo "🔍 Validating .vsix structure..."
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
echo "✅ VSCode extension validation passed"
else
echo "❌ VSCode extension build failed - .vsix not found"
exit 1
fi
- name: Upload VSCode extension artifact
uses: actions/upload-artifact@v6
with:
name: vscode-extension
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
retention-days: 7
- name: Comment on PR with artifact link
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Get file size for display
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
const stats = fs.statSync(vsixPath);
const fileSizeKB = Math.round(stats.size / 1024);
const comment = `## 🔧 VSCode Extension Built Successfully!
The VSCode extension has been built and is ready for testing.
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
**🚀 To install**:
1. Download the artifact from the workflow run above
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
3. Select the downloaded \`.vsix\` file
**✅ Tested with**: Node.js 22
**🔍 Validation**: File structure and integrity verified
---
*Built from commit ${{ github.sha }}*`;
// Check if we already commented on this PR and delete it
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('VSCode Extension Built Successfully')
);
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
// Create a new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: validate-vscode-extension
if: startsWith(github.ref, 'refs/tags/ext-v')
steps:
- name: Download .vsix artifact
uses: actions/download-artifact@v6
with:
name: vscode-extension
path: ./
- name: Create Release
uses: ncipollo/release-action@v1.20.0
with:
artifacts: "*.vsix"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
allowUpdates: true

View File

@@ -13,7 +13,6 @@ STAGED_FILES=$(git diff --cached --name-only)
# Check if any files match specific patterns
has_frontend_changes=false
has_backend_changes=false
has_vscode_changes=false
# Check each file individually to avoid issues with grep
for file in $STAGED_FILES; do
@@ -21,17 +20,12 @@ for file in $STAGED_FILES; do
has_frontend_changes=true
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
has_backend_changes=true
# Check for VSCode extension changes (subset of backend changes)
if [[ $file == openhands/integrations/vscode/* ]]; then
has_vscode_changes=true
fi
fi
done
echo "Analyzing changes..."
echo "- Frontend changes: $has_frontend_changes"
echo "- Backend changes: $has_backend_changes"
echo "- VSCode extension changes: $has_vscode_changes"
# Run frontend linting if needed
if [ "$has_frontend_changes" = true ]; then
@@ -92,51 +86,6 @@ else
echo "Skipping backend checks (no backend changes detected)."
fi
# Run VSCode extension checks if needed
if [ "$has_vscode_changes" = true ]; then
# Check if we're in a CI environment
if [ -n "$CI" ]; then
echo "Skipping VSCode extension checks (CI environment detected)."
echo "WARNING: VSCode extension files have changed but checks are being skipped."
echo "Please run VSCode extension checks manually before submitting your PR."
else
echo "Running VSCode extension checks..."
if [ -d "openhands/integrations/vscode" ]; then
cd openhands/integrations/vscode || exit 1
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "VSCode extension linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension linting passed!"
fi
echo "Running npm typecheck..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "VSCode extension type checking failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension type checking passed!"
fi
echo "Running npm compile..."
npm run compile
if [ $? -ne 0 ]; then
echo "VSCode extension compilation failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension compilation passed!"
fi
cd ../../..
fi
fi
else
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
fi
# If no specific code changes detected, run basic checks
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then

View File

@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
to gather consensus from our design team first.
#### Improving the agent

View File

@@ -1,113 +0,0 @@
import os
import pathlib
import subprocess
# This script is intended to be run by Poetry during the build process.
# Define the expected name of the .vsix file based on the extension's package.json
# This should match the name and version in openhands-vscode/package.json
EXTENSION_NAME = 'openhands-vscode'
EXTENSION_VERSION = '0.0.1'
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
# Paths
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
def check_node_version():
"""Check if Node.js version is sufficient for building the extension."""
try:
result = subprocess.run(
['node', '--version'], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip()
# Extract major version number (e.g., "v12.22.9" -> 12)
major_version = int(version_str.lstrip('v').split('.')[0])
return major_version >= 18 # Align with frontend actual usage (18.20.1)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return False
def build_vscode_extension():
"""Builds the VS Code extension."""
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
# Check if VSCode extension build is disabled via environment variable
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
if vsix_path.exists():
print(f'--- Using existing VS Code extension: {vsix_path} ---')
else:
print('--- No pre-built VS Code extension found ---')
return
# Check Node.js version - if insufficient, use pre-built extension as fallback
if not check_node_version():
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
print('--- Using pre-built extension if available ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
else:
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
return
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
try:
# Ensure npm dependencies are installed
print('--- Running npm install for VS Code extension ---')
subprocess.run(
['npm', 'install'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Package the extension
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
subprocess.run(
['npm', 'run', 'package-vsix'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Verify the generated .vsix file exists
if not vsix_path.exists():
raise FileNotFoundError(
f'VS Code extension package not found after build: {vsix_path}'
)
print(f'--- VS Code extension built successfully: {vsix_path} ---')
except subprocess.CalledProcessError as e:
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
print('--- Continuing without building extension ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
def build(setup_kwargs):
"""This function is called by Poetry during the build process.
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
"""
print('--- Running custom Poetry build script (build_vscode.py) ---')
# Build the VS Code extension and place the .vsix file
build_vscode_extension()
# Poetry will handle including files based on pyproject.toml `include` patterns.
# Ensure openhands/integrations/vscode/*.vsix is included there.
print('--- Custom Poetry build script (build_vscode.py) finished ---')
if __name__ == '__main__':
print('Running build_vscode.py directly for testing VS Code extension packaging...')
build_vscode_extension()
print('Direct execution of build_vscode.py finished.')

View File

@@ -143,7 +143,7 @@ class GitHubDataCollector:
try:
installation_token = self._get_installation_access_token(installation_id)
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(repo_name)
issue = repo.get_issue(issue_number)
comments = []
@@ -237,7 +237,7 @@ class GitHubDataCollector:
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
commits = []
installation_token = self._get_installation_access_token(installation_id)
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(repo_name)
pr = repo.get_pull(pr_number)

View File

@@ -77,7 +77,7 @@ class GithubManager(Manager):
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
installation_token: GitHub installation access token for API access
"""
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
# Add reaction based on view type
if isinstance(github_view, GithubInlinePRComment):
@@ -199,7 +199,7 @@ class GithubManager(Manager):
outgoing_message = message.message
if isinstance(github_view, GithubInlinePRComment):
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
pr = repo.get_pull(github_view.issue_number)
pr.create_review_comment_reply(
@@ -211,7 +211,7 @@ class GithubManager(Manager):
or isinstance(github_view, GithubIssueComment)
or isinstance(github_view, GithubIssue)
):
with Github(installation_token) as github_client:
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
issue = repo.get_issue(number=github_view.issue_number)
issue.create_comment(outgoing_message)

View File

@@ -1,7 +1,7 @@
import asyncio
import time
from github import Github
from github import Auth, Github
from integrations.github.github_view import (
GithubInlinePRComment,
GithubIssueComment,
@@ -47,7 +47,7 @@ def fetch_github_issue_context(
context_parts.append(f'Title: {github_view.title}')
context_parts.append(f'Description:\n{github_view.description}')
with Github(user_token) as github_client:
with Github(auth=Auth.Token(user_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
issue = repo.get_issue(github_view.issue_number)
if issue.labels:

View File

@@ -735,7 +735,7 @@ class GithubFactory:
payload['installation']['id']
).token
with Github(access_token) as gh:
with Github(auth=Auth.Token(access_token)) as gh:
repo = gh.get_repo(selected_repo)
login = (
payload['organization']['login']
@@ -872,7 +872,7 @@ class GithubFactory:
access_token = integration.get_access_token(installation_id).token
head_ref = None
with Github(access_token) as gh:
with Github(auth=Auth.Token(access_token)) as gh:
repo = gh.get_repo(selected_repo)
pull_request = repo.get_pull(issue_number)
head_ref = pull_request.head.ref

View File

@@ -20,6 +20,7 @@ from openhands.events.action import (
AgentFinishAction,
MessageAction,
)
from openhands.events.event_filter import EventFilter
from openhands.events.event_store_abc import EventStoreABC
from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.integrations.service_types import Repository
@@ -203,18 +204,35 @@ def get_summary_for_agent_state(
def get_final_agent_observation(
event_store: EventStoreABC,
) -> list[AgentStateChangedObservation]:
return event_store.get_matching_events(
source=EventSource.ENVIRONMENT,
event_types=(AgentStateChangedObservation,),
limit=1,
reverse=True,
events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.ENVIRONMENT,
include_types=(AgentStateChangedObservation,),
),
limit=1,
reverse=True,
)
)
result = [e for e in events if isinstance(e, AgentStateChangedObservation)]
assert len(result) == len(events)
return result
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
return event_store.get_matching_events(
source=EventSource.USER, event_types=(MessageAction,), limit=1, reverse='true'
events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.USER,
include_types=(MessageAction,),
),
limit=1,
reverse=True,
)
)
result = [e for e in events if isinstance(e, MessageAction)]
assert len(result) == len(events)
return result
def extract_summary_from_event_store(
@@ -226,18 +244,22 @@ def extract_summary_from_event_store(
conversation_link = CONVERSATION_URL.format(conversation_id)
summary_instruction = get_summary_instruction()
instruction_event: list[MessageAction] = event_store.get_matching_events(
query=json.dumps(summary_instruction),
source=EventSource.USER,
event_types=(MessageAction,),
limit=1,
reverse=True,
instruction_events = list(
event_store.search_events(
filter=EventFilter(
query=json.dumps(summary_instruction),
source=EventSource.USER,
include_types=(MessageAction,),
),
limit=1,
reverse=True,
)
)
final_agent_observation = get_final_agent_observation(event_store)
# Find summary instruction event ID
if len(instruction_event) == 0:
if not instruction_events:
logger.warning(
'no_instruction_event_found', extra={'conversation_id': conversation_id}
)
@@ -245,19 +267,19 @@ def extract_summary_from_event_store(
final_agent_observation, conversation_link
) # Agent did not receive summary instruction
event_id: int = instruction_event[0].id
agent_messages: list[MessageAction | AgentFinishAction] = (
event_store.get_matching_events(
start_id=event_id,
source=EventSource.AGENT,
event_types=(MessageAction, AgentFinishAction),
reverse=True,
summary_events = list(
event_store.search_events(
filter=EventFilter(
source=EventSource.AGENT,
include_types=(MessageAction, AgentFinishAction),
),
limit=1,
reverse=True,
start_id=instruction_events[0].id,
)
)
if len(agent_messages) == 0:
if not summary_events:
logger.warning(
'no_agent_messages_found', extra={'conversation_id': conversation_id}
)
@@ -265,10 +287,11 @@ def extract_summary_from_event_store(
final_agent_observation, conversation_link
) # Agent failed to generate summary
summary_event: MessageAction | AgentFinishAction = agent_messages[0]
summary_event = summary_events[0]
if isinstance(summary_event, MessageAction):
return summary_event.content
assert isinstance(summary_event, AgentFinishAction)
return summary_event.final_thought

View File

@@ -31,7 +31,13 @@ class DomainBlocker:
return None
def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked"""
"""Check if email domain is blocked
Supports blocking:
- Exact domains: 'example.com' blocks 'user@example.com'
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
"""
if not self.is_active():
return False
@@ -44,13 +50,26 @@ class DomainBlocker:
logger.debug(f'Could not extract domain from email: {email}')
return False
is_blocked = domain in self.blocked_domains
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
else:
logger.debug(f'Email domain {domain} is not blocked')
# Check if domain matches any blocked pattern
for blocked_pattern in self.blocked_domains:
if blocked_pattern.startswith('.'):
# TLD pattern (e.g., '.us') - check if domain ends with it
if domain.endswith(blocked_pattern):
logger.warning(
f'Email domain {domain} is blocked by TLD pattern {blocked_pattern} for email: {email}'
)
return True
else:
# Full domain pattern (e.g., 'example.com')
# Block exact match or subdomains
if domain == blocked_pattern or domain.endswith(f'.{blocked_pattern}'):
logger.warning(
f'Email domain {domain} is blocked by domain pattern {blocked_pattern} for email: {email}'
)
return True
return is_blocked
logger.debug(f'Email domain {domain} is not blocked')
return False
domain_blocker = DomainBlocker()

View File

@@ -12,6 +12,8 @@ from typing import Any, cast
import httpx
import socketio
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.constants import PERMITTED_CORS_ORIGINS, WEB_HOST
from server.utils.conversation_callback_utils import (
process_event,
@@ -29,7 +31,11 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action import MessageAction
from openhands.events.event_store import EventStore
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
ProviderToken,
)
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.plugins.vscode import VSCodeRequirement
from openhands.runtime.runtime_status import RuntimeStatus
@@ -228,6 +234,102 @@ class SaasNestedConversationManager(ConversationManager):
status=status,
)
async def _refresh_provider_tokens_after_runtime_init(
self, settings: Settings, sid: str, user_id: str | None = None
) -> Settings:
"""Refresh provider tokens after runtime initialization.
During runtime initialization, tokens may be refreshed by Runtime.__init__().
This method retrieves the fresh tokens from the database and creates a new
settings object with updated tokens to avoid sending stale tokens to the
nested runtime.
The method handles two scenarios:
1. ProviderToken has user_id (IDP user ID, e.g., GitLab user ID)
→ Uses get_idp_token_from_idp_user_id()
2. ProviderToken has no user_id but Keycloak user_id is available
→ Uses load_offline_token() + get_idp_token_from_offline_token()
Args:
settings: The conversation settings that may contain provider tokens
sid: The session ID for logging purposes
user_id: The Keycloak user ID (optional, used as fallback when
ProviderToken.user_id is not available)
Returns:
Updated settings with fresh provider tokens, or original settings
if no update is needed
"""
if not isinstance(settings, ConversationInitData):
return settings
if not settings.git_provider_tokens:
return settings
token_manager = TokenManager()
updated_tokens = {}
tokens_refreshed = 0
tokens_failed = 0
for provider_type, provider_token in settings.git_provider_tokens.items():
fresh_token = None
try:
if provider_token.user_id:
# Case 1: We have IDP user ID (e.g., GitLab user ID '32546706')
# Get the token that was just refreshed during runtime initialization
fresh_token = await token_manager.get_idp_token_from_idp_user_id(
provider_token.user_id, provider_type
)
elif user_id:
# Case 2: We have Keycloak user ID but no IDP user ID
# This happens in web UI flow where ProviderToken.user_id is None
offline_token = await token_manager.load_offline_token(user_id)
if offline_token:
fresh_token = (
await token_manager.get_idp_token_from_offline_token(
offline_token, provider_type
)
)
if fresh_token:
updated_tokens[provider_type] = ProviderToken(
token=SecretStr(fresh_token),
user_id=provider_token.user_id,
host=provider_token.host,
)
tokens_refreshed += 1
else:
# Keep original token if we couldn't get a fresh one
updated_tokens[provider_type] = provider_token
except Exception as e:
# If refresh fails, use original token to prevent conversation startup failure
logger.warning(
f'Failed to refresh {provider_type.value} token: {e}',
extra={'session_id': sid, 'provider': provider_type.value},
exc_info=True,
)
updated_tokens[provider_type] = provider_token
tokens_failed += 1
# Create new ConversationInitData with updated tokens
# We cannot modify the frozen field directly, so we create a new object
updated_settings = settings.model_copy(
update={'git_provider_tokens': MappingProxyType(updated_tokens)}
)
logger.info(
'Updated provider tokens after runtime creation',
extra={
'session_id': sid,
'providers': [p.value for p in updated_tokens.keys()],
'refreshed': tokens_refreshed,
'failed': tokens_failed,
},
)
return updated_settings
async def _start_agent_loop(
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
):
@@ -249,6 +351,11 @@ class SaasNestedConversationManager(ConversationManager):
session_api_key = runtime.session.headers['X-Session-API-Key']
# Update provider tokens with fresh ones after runtime creation
settings = await self._refresh_provider_tokens_after_runtime_init(
settings, sid, user_id
)
await self._start_conversation(
sid,
user_id,
@@ -333,7 +440,12 @@ class SaasNestedConversationManager(ConversationManager):
async def _setup_provider_tokens(
self, client: httpx.AsyncClient, api_url: str, settings: Settings
):
"""Setup provider tokens for the nested conversation."""
"""Setup provider tokens for the nested conversation.
Note: Token validation happens in the nested runtime. If tokens are revoked,
the nested runtime will return 401. The caller should handle token refresh
and retry if needed.
"""
provider_handler = self._get_provider_handler(settings)
provider_tokens = provider_handler.provider_tokens
if provider_tokens:

View File

@@ -285,14 +285,21 @@ class SaasSettingsStore(SettingsStore):
'x-goog-api-key': LITE_LLM_API_KEY,
},
) as client:
# Get the previous max budget to prevent accidental loss
# In Litellm a get always succeeds, regardless of whether the user actually exists
# Get the previous max budget to prevent accidental loss.
#
# LiteLLM v1.80+ returns 404 for non-existent users (previously returned empty user_info)
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
)
response.raise_for_status()
response_json = response.json()
user_info = response_json.get('user_info') or {}
user_info: dict
if response.status_code == 404:
# New user - doesn't exist in LiteLLM yet (v1.80+ behavior)
user_info = {}
else:
# For any other status, use standard error handling
response.raise_for_status()
response_json = response.json()
user_info = response_json.get('user_info') or {}
logger.info(
f'creating_litellm_user: {self.user_id}; prev_max_budget: {user_info.get("max_budget")}; prev_metadata: {user_info.get("metadata")}'
)

View File

@@ -179,3 +179,315 @@ def test_is_domain_blocked_with_whitespace(domain_blocker):
# Assert
assert result is True
# ============================================================================
# TLD Blocking Tests (patterns starting with '.')
# ============================================================================
def test_is_domain_blocked_tld_pattern_blocks_matching_domain(domain_blocker):
"""Test that TLD pattern blocks domains ending with that TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@company.us')
# Assert
assert result is True
def test_is_domain_blocked_tld_pattern_blocks_subdomain_with_tld(domain_blocker):
"""Test that TLD pattern blocks subdomains with that TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@subdomain.company.us')
# Assert
assert result is True
def test_is_domain_blocked_tld_pattern_does_not_block_different_tld(domain_blocker):
"""Test that TLD pattern does not block domains with different TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@company.com')
# Assert
assert result is False
def test_is_domain_blocked_tld_pattern_does_not_block_substring_match(
domain_blocker,
):
"""Test that TLD pattern does not block domains that contain but don't end with the TLD."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@focus.com')
# Assert
assert result is False
def test_is_domain_blocked_tld_pattern_case_insensitive(domain_blocker):
"""Test that TLD pattern matching is case-insensitive."""
# Arrange
domain_blocker.blocked_domains = ['.us']
# Act
result = domain_blocker.is_domain_blocked('user@COMPANY.US')
# Assert
assert result is True
def test_is_domain_blocked_multiple_tld_patterns(domain_blocker):
"""Test blocking with multiple TLD patterns."""
# Arrange
domain_blocker.blocked_domains = ['.us', '.vn', '.com']
# Act
result_us = domain_blocker.is_domain_blocked('user@test.us')
result_vn = domain_blocker.is_domain_blocked('user@test.vn')
result_com = domain_blocker.is_domain_blocked('user@test.com')
result_org = domain_blocker.is_domain_blocked('user@test.org')
# Assert
assert result_us is True
assert result_vn is True
assert result_com is True
assert result_org is False
def test_is_domain_blocked_tld_pattern_with_multi_level_tld(domain_blocker):
"""Test that TLD pattern works with multi-level TLDs like .co.uk."""
# Arrange
domain_blocker.blocked_domains = ['.co.uk']
# Act
result_match = domain_blocker.is_domain_blocked('user@example.co.uk')
result_subdomain = domain_blocker.is_domain_blocked('user@api.example.co.uk')
result_no_match = domain_blocker.is_domain_blocked('user@example.uk')
# Assert
assert result_match is True
assert result_subdomain is True
assert result_no_match is False
# ============================================================================
# Subdomain Blocking Tests (domain patterns now block subdomains)
# ============================================================================
def test_is_domain_blocked_domain_pattern_blocks_exact_match(domain_blocker):
"""Test that domain pattern blocks exact domain match."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result is True
def test_is_domain_blocked_domain_pattern_blocks_subdomain(domain_blocker):
"""Test that domain pattern blocks subdomains of that domain."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked('user@subdomain.example.com')
# Assert
assert result is True
def test_is_domain_blocked_domain_pattern_blocks_multi_level_subdomain(
domain_blocker,
):
"""Test that domain pattern blocks multi-level subdomains."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked('user@api.v2.example.com')
# Assert
assert result is True
def test_is_domain_blocked_domain_pattern_does_not_block_similar_domain(
domain_blocker,
):
"""Test that domain pattern does not block domains that contain but don't match the pattern."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked('user@notexample.com')
# Assert
assert result is False
def test_is_domain_blocked_domain_pattern_does_not_block_different_tld(
domain_blocker,
):
"""Test that domain pattern does not block same domain with different TLD."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked('user@example.org')
# Assert
assert result is False
def test_is_domain_blocked_subdomain_pattern_blocks_exact_and_nested(domain_blocker):
"""Test that blocking a subdomain also blocks its nested subdomains."""
# Arrange
domain_blocker.blocked_domains = ['api.example.com']
# Act
result_exact = domain_blocker.is_domain_blocked('user@api.example.com')
result_nested = domain_blocker.is_domain_blocked('user@v1.api.example.com')
result_parent = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result_exact is True
assert result_nested is True
assert result_parent is False
# ============================================================================
# Mixed Pattern Tests (TLD + domain patterns together)
# ============================================================================
def test_is_domain_blocked_mixed_patterns_tld_and_domain(domain_blocker):
"""Test blocking with both TLD and domain patterns."""
# Arrange
domain_blocker.blocked_domains = ['.us', 'openhands.dev']
# Act
result_tld = domain_blocker.is_domain_blocked('user@company.us')
result_domain = domain_blocker.is_domain_blocked('user@openhands.dev')
result_subdomain = domain_blocker.is_domain_blocked('user@api.openhands.dev')
result_allowed = domain_blocker.is_domain_blocked('user@example.com')
# Assert
assert result_tld is True
assert result_domain is True
assert result_subdomain is True
assert result_allowed is False
def test_is_domain_blocked_overlapping_patterns(domain_blocker):
"""Test that overlapping patterns (TLD and specific domain) both work."""
# Arrange
domain_blocker.blocked_domains = ['.us', 'test.us']
# Act
result_specific = domain_blocker.is_domain_blocked('user@test.us')
result_other_us = domain_blocker.is_domain_blocked('user@other.us')
# Assert
assert result_specific is True
assert result_other_us is True
def test_is_domain_blocked_complex_multi_pattern_scenario(domain_blocker):
"""Test complex scenario with multiple TLD and domain patterns."""
# Arrange
domain_blocker.blocked_domains = [
'.us',
'.vn',
'test.com',
'openhands.dev',
]
# Act & Assert
# TLD patterns
assert domain_blocker.is_domain_blocked('user@anything.us') is True
assert domain_blocker.is_domain_blocked('user@company.vn') is True
# Domain patterns (exact)
assert domain_blocker.is_domain_blocked('user@test.com') is True
assert domain_blocker.is_domain_blocked('user@openhands.dev') is True
# Domain patterns (subdomains)
assert domain_blocker.is_domain_blocked('user@api.test.com') is True
assert domain_blocker.is_domain_blocked('user@staging.openhands.dev') is True
# Not blocked
assert domain_blocker.is_domain_blocked('user@allowed.com') is False
assert domain_blocker.is_domain_blocked('user@example.org') is False
# ============================================================================
# Edge Case Tests
# ============================================================================
def test_is_domain_blocked_domain_with_hyphens(domain_blocker):
"""Test that domain patterns work with hyphenated domains."""
# Arrange
domain_blocker.blocked_domains = ['my-company.com']
# Act
result_exact = domain_blocker.is_domain_blocked('user@my-company.com')
result_subdomain = domain_blocker.is_domain_blocked('user@api.my-company.com')
# Assert
assert result_exact is True
assert result_subdomain is True
def test_is_domain_blocked_domain_with_numbers(domain_blocker):
"""Test that domain patterns work with numeric domains."""
# Arrange
domain_blocker.blocked_domains = ['test123.com']
# Act
result_exact = domain_blocker.is_domain_blocked('user@test123.com')
result_subdomain = domain_blocker.is_domain_blocked('user@api.test123.com')
# Assert
assert result_exact is True
assert result_subdomain is True
def test_is_domain_blocked_short_tld(domain_blocker):
"""Test that short TLD patterns work correctly."""
# Arrange
domain_blocker.blocked_domains = ['.io']
# Act
result = domain_blocker.is_domain_blocked('user@company.io')
# Assert
assert result is True
def test_is_domain_blocked_very_long_subdomain_chain(domain_blocker):
"""Test that blocking works with very long subdomain chains."""
# Arrange
domain_blocker.blocked_domains = ['example.com']
# Act
result = domain_blocker.is_domain_blocked(
'user@level4.level3.level2.level1.example.com'
)
# Assert
assert result is True

View File

@@ -0,0 +1,437 @@
"""
TDD Tests for SaasNestedConversationManager token refresh functionality.
This module tests the token refresh logic that prevents stale tokens from being
sent to nested runtimes after Runtime.__init__() refreshes them.
Test Coverage:
- Token refresh with IDP user ID (GitLab webhook flow)
- Token refresh with Keycloak user ID (Web UI flow)
- Error handling and fallback behavior
- Settings immutability handling
"""
from types import MappingProxyType
from unittest.mock import AsyncMock, Mock, patch
import pytest
from pydantic import SecretStr
from enterprise.server.saas_nested_conversation_manager import (
SaasNestedConversationManager,
)
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.storage.data_models.settings import Settings
class TestRefreshProviderTokensAfterRuntimeInit:
"""Test suite for _refresh_provider_tokens_after_runtime_init method."""
@pytest.fixture
def conversation_manager(self):
"""Create a minimal SaasNestedConversationManager instance for testing."""
# Arrange: Create mock dependencies
mock_sio = Mock()
mock_config = Mock()
mock_config.max_concurrent_conversations = 5
mock_server_config = Mock()
mock_file_store = Mock()
# Create manager instance
manager = SaasNestedConversationManager(
sio=mock_sio,
config=mock_config,
server_config=mock_server_config,
file_store=mock_file_store,
event_retrieval=Mock(),
)
return manager
@pytest.fixture
def gitlab_provider_token_with_user_id(self):
"""Create a GitLab ProviderToken with IDP user ID (webhook flow)."""
return ProviderToken(
token=SecretStr('old_token_abc123'),
user_id='32546706', # GitLab user ID
host=None,
)
@pytest.fixture
def gitlab_provider_token_without_user_id(self):
"""Create a GitLab ProviderToken without IDP user ID (web UI flow)."""
return ProviderToken(
token=SecretStr('old_token_xyz789'),
user_id=None,
host=None,
)
@pytest.fixture
def conversation_init_data_with_user_id(self, gitlab_provider_token_with_user_id):
"""Create ConversationInitData with provider token containing user_id."""
return ConversationInitData(
git_provider_tokens=MappingProxyType(
{ProviderType.GITLAB: gitlab_provider_token_with_user_id}
)
)
@pytest.fixture
def conversation_init_data_without_user_id(
self, gitlab_provider_token_without_user_id
):
"""Create ConversationInitData with provider token without user_id."""
return ConversationInitData(
git_provider_tokens=MappingProxyType(
{ProviderType.GITLAB: gitlab_provider_token_without_user_id}
)
)
@pytest.mark.asyncio
async def test_returns_original_settings_when_not_conversation_init_data(
self, conversation_manager
):
"""
Test: Returns original settings when not ConversationInitData.
Arrange: Create a Settings object (not ConversationInitData)
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Returns the same settings object unchanged
"""
# Arrange
settings = Settings()
sid = 'test_session_123'
# Act
result = await conversation_manager._refresh_provider_tokens_after_runtime_init(
settings, sid
)
# Assert
assert result is settings
@pytest.mark.asyncio
async def test_returns_original_settings_when_no_provider_tokens(
self, conversation_manager
):
"""
Test: Returns original settings when no provider tokens present.
Arrange: Create ConversationInitData without git_provider_tokens
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Returns the same settings object unchanged
"""
# Arrange
settings = ConversationInitData(git_provider_tokens=None)
sid = 'test_session_456'
# Act
result = await conversation_manager._refresh_provider_tokens_after_runtime_init(
settings, sid
)
# Assert
assert result is settings
@pytest.mark.asyncio
async def test_refreshes_token_with_idp_user_id(
self, conversation_manager, conversation_init_data_with_user_id
):
"""
Test: Refreshes token using IDP user ID (GitLab webhook flow).
Arrange: ConversationInitData with GitLab token containing user_id
Act: Call _refresh_provider_tokens_after_runtime_init with mocked TokenManager
Assert: Token is refreshed using get_idp_token_from_idp_user_id
"""
# Arrange
sid = 'test_session_789'
fresh_token = 'fresh_token_def456'
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
return_value=fresh_token
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
conversation_init_data_with_user_id, sid
)
)
# Assert
mock_token_manager.get_idp_token_from_idp_user_id.assert_called_once_with(
'32546706', ProviderType.GITLAB
)
assert (
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== fresh_token
)
assert result.git_provider_tokens[ProviderType.GITLAB].user_id == '32546706'
@pytest.mark.asyncio
async def test_refreshes_token_with_keycloak_user_id(
self, conversation_manager, conversation_init_data_without_user_id
):
"""
Test: Refreshes token using Keycloak user ID (Web UI flow).
Arrange: ConversationInitData without IDP user_id, but with Keycloak user_id
Act: Call _refresh_provider_tokens_after_runtime_init with mocked TokenManager
Assert: Token is refreshed using load_offline_token + get_idp_token_from_offline_token
"""
# Arrange
sid = 'test_session_101'
keycloak_user_id = 'keycloak_user_abc'
offline_token = 'offline_token_xyz'
fresh_token = 'fresh_token_ghi789'
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.load_offline_token = AsyncMock(
return_value=offline_token
)
mock_token_manager.get_idp_token_from_offline_token = AsyncMock(
return_value=fresh_token
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
conversation_init_data_without_user_id, sid, keycloak_user_id
)
)
# Assert
mock_token_manager.load_offline_token.assert_called_once_with(
keycloak_user_id
)
mock_token_manager.get_idp_token_from_offline_token.assert_called_once_with(
offline_token, ProviderType.GITLAB
)
assert (
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== fresh_token
)
assert result.git_provider_tokens[ProviderType.GITLAB].user_id is None
@pytest.mark.asyncio
async def test_keeps_original_token_when_refresh_fails(
self, conversation_manager, conversation_init_data_with_user_id
):
"""
Test: Keeps original token when refresh fails (error handling).
Arrange: ConversationInitData with token, TokenManager raises exception
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Original token is preserved, no exception raised
"""
# Arrange
sid = 'test_session_error'
original_token = conversation_init_data_with_user_id.git_provider_tokens[
ProviderType.GITLAB
].token.get_secret_value()
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
side_effect=Exception('Token refresh failed')
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
conversation_init_data_with_user_id, sid
)
)
# Assert
assert (
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== original_token
)
@pytest.mark.asyncio
async def test_keeps_original_token_when_no_fresh_token_available(
self, conversation_manager, conversation_init_data_with_user_id
):
"""
Test: Keeps original token when no fresh token is available.
Arrange: ConversationInitData with token, TokenManager returns None
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Original token is preserved
"""
# Arrange
sid = 'test_session_no_fresh'
original_token = conversation_init_data_with_user_id.git_provider_tokens[
ProviderType.GITLAB
].token.get_secret_value()
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
return_value=None
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
conversation_init_data_with_user_id, sid
)
)
# Assert
assert (
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== original_token
)
@pytest.mark.asyncio
async def test_creates_new_settings_object_preserving_immutability(
self, conversation_manager, conversation_init_data_with_user_id
):
"""
Test: Creates new settings object (respects Pydantic frozen fields).
Arrange: ConversationInitData with frozen git_provider_tokens field
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Returns a new ConversationInitData object, not the same instance
"""
# Arrange
sid = 'test_session_immutable'
fresh_token = 'fresh_token_new'
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
return_value=fresh_token
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
conversation_init_data_with_user_id, sid
)
)
# Assert
assert result is not conversation_init_data_with_user_id
assert isinstance(result, ConversationInitData)
@pytest.mark.asyncio
async def test_handles_multiple_providers(self, conversation_manager):
"""
Test: Handles multiple provider tokens correctly.
Arrange: ConversationInitData with both GitLab and GitHub tokens
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Both tokens are refreshed independently
"""
# Arrange
sid = 'test_session_multi'
gitlab_token = ProviderToken(
token=SecretStr('old_gitlab_token'), user_id='gitlab_user_123', host=None
)
github_token = ProviderToken(
token=SecretStr('old_github_token'), user_id='github_user_456', host=None
)
settings = ConversationInitData(
git_provider_tokens=MappingProxyType(
{ProviderType.GITLAB: gitlab_token, ProviderType.GITHUB: github_token}
)
)
fresh_gitlab_token = 'fresh_gitlab_token'
fresh_github_token = 'fresh_github_token'
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
async def mock_get_token(user_id, provider_type):
if provider_type == ProviderType.GITLAB:
return fresh_gitlab_token
elif provider_type == ProviderType.GITHUB:
return fresh_github_token
return None
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
side_effect=mock_get_token
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
settings, sid
)
)
# Assert
assert (
result.git_provider_tokens[ProviderType.GITLAB].token.get_secret_value()
== fresh_gitlab_token
)
assert (
result.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
== fresh_github_token
)
assert mock_token_manager.get_idp_token_from_idp_user_id.call_count == 2
@pytest.mark.asyncio
async def test_preserves_token_host_field(self, conversation_manager):
"""
Test: Preserves the host field from original token.
Arrange: ProviderToken with custom host value
Act: Call _refresh_provider_tokens_after_runtime_init
Assert: Host field is preserved in the refreshed token
"""
# Arrange
sid = 'test_session_host'
custom_host = 'gitlab.example.com'
token_with_host = ProviderToken(
token=SecretStr('old_token'), user_id='user_789', host=custom_host
)
settings = ConversationInitData(
git_provider_tokens=MappingProxyType({ProviderType.GITLAB: token_with_host})
)
fresh_token = 'fresh_token_with_host'
with patch(
'enterprise.server.saas_nested_conversation_manager.TokenManager'
) as mock_token_manager_class:
mock_token_manager = AsyncMock()
mock_token_manager.get_idp_token_from_idp_user_id = AsyncMock(
return_value=fresh_token
)
mock_token_manager_class.return_value = mock_token_manager
# Act
result = (
await conversation_manager._refresh_provider_tokens_after_runtime_init(
settings, sid
)
)
# Assert
assert result.git_provider_tokens[ProviderType.GITLAB].host == custom_host

View File

@@ -1,5 +1,6 @@
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pydantic import SecretStr
from server.constants import (
@@ -335,6 +336,80 @@ async def test_update_settings_with_litellm_default_error(settings_store):
assert settings is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
'status_code,user_info_response,should_succeed',
[
# 200 OK with user info - existing user (v1.79.x and v1.80+ behavior)
(200, {'user_info': {'max_budget': 10, 'spend': 5}}, True),
# 200 OK with empty user info - new user (v1.79.x behavior)
(200, {'user_info': None}, True),
# 404 Not Found - new user (v1.80+ behavior)
(404, None, True),
# 500 Internal Server Error - should fail
(500, None, False),
],
)
async def test_update_settings_with_litellm_default_handles_user_info_responses(
settings_store, session_maker, status_code, user_info_response, should_succeed
):
"""Test that various LiteLLM user/info responses are handled correctly.
LiteLLM API behavior changed between versions:
- v1.79.x and earlier: GET /user/info always succeeds with empty user_info
- v1.80.x and later: GET /user/info returns 404 for non-existent users
"""
mock_get_response = MagicMock()
mock_get_response.status_code = status_code
if user_info_response is not None:
mock_get_response.json = MagicMock(return_value=user_info_response)
mock_get_response.raise_for_status = MagicMock()
else:
mock_get_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
'Error', request=MagicMock(), response=mock_get_response
)
if status_code >= 500
else None
)
# Mock successful responses for POST operations (delete and create)
mock_post_response = MagicMock()
mock_post_response.is_success = True
mock_post_response.json = MagicMock(return_value={'key': 'new_user_api_key'})
with (
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'test_key'),
patch('storage.saas_settings_store.LITE_LLM_API_URL', 'http://test.url'),
patch('storage.saas_settings_store.LITE_LLM_TEAM_ID', 'test_team'),
patch(
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
AsyncMock(return_value={'email': 'testuser@example.com'}),
),
patch('httpx.AsyncClient') as mock_client,
patch('storage.saas_settings_store.session_maker', session_maker),
):
# Set up the mock client
mock_client.return_value.__aenter__.return_value.get.return_value = (
mock_get_response
)
mock_client.return_value.__aenter__.return_value.post.return_value = (
mock_post_response
)
settings = Settings()
if should_succeed:
settings = await settings_store.update_settings_with_litellm_default(
settings
)
assert settings is not None
assert settings.llm_api_key is not None
assert settings.llm_api_key.get_secret_value() == 'new_user_api_key'
else:
with pytest.raises(httpx.HTTPStatusError):
await settings_store.update_settings_with_litellm_default(settings)
@pytest.mark.asyncio
async def test_update_settings_with_litellm_retry_on_duplicate_email(
settings_store, mock_litellm_api, session_maker

View File

@@ -30,7 +30,7 @@
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.312.0",
"posthog-js": "^1.313.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",
@@ -13382,9 +13382,9 @@
}
},
"node_modules/posthog-js": {
"version": "1.312.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.312.0.tgz",
"integrity": "sha512-rdXprhuRzhutU8powMJpIfC0uRtI3OyuYktmLhZRMsD4DQaO3fnudKNq4zxtNmqMPFCSTfmlBH8ByLNOppm2tg==",
"version": "1.313.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.313.0.tgz",
"integrity": "sha512-CL8RkC7m9BTZrix86w0fdnSCVqC/gxrfs6c4Wfkz/CldFD7f2912S2KqnWFmwRVDGIwm9IR82YhublQ88gdDKw==",
"dependencies": {
"@posthog/core": "1.9.0",
"core-js": "^3.38.1",

View File

@@ -29,7 +29,7 @@
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.312.0",
"posthog-js": "^1.313.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hot-toast": "^2.6.0",

View File

@@ -78,6 +78,7 @@ class DockerSandboxService(SandboxService):
health_check_path: str | None
httpx_client: httpx.AsyncClient
max_num_sandboxes: int
extra_hosts: dict[str, str] = field(default_factory=dict)
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
def _find_unused_port(self) -> int:
@@ -349,6 +350,9 @@ class DockerSandboxService(SandboxService):
# Use Docker's tini init process to ensure proper signal handling and reaping of
# zombie child processes.
init=True,
# Allow agent-server containers to resolve host.docker.internal
# and other custom hostnames for LAN deployments
extra_hosts=self.extra_hosts if self.extra_hosts else None,
)
sandbox_info = await self._container_to_sandbox_info(container)
@@ -469,6 +473,15 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
'determine whether the server is running'
),
)
extra_hosts: dict[str, str] = Field(
default_factory=lambda: {'host.docker.internal': 'host-gateway'},
description=(
'Extra hostname mappings to add to agent-server containers. '
'This allows containers to resolve hostnames like host.docker.internal '
'for LAN deployments and MCP connections. '
'Format: {"hostname": "ip_or_gateway"}'
),
)
async def inject(
self, state: InjectorState, request: Request | None = None
@@ -493,4 +506,5 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
health_check_path=self.health_check_path,
httpx_client=httpx_client,
max_num_sandboxes=self.max_num_sandboxes,
extra_hosts=self.extra_hosts,
)

View File

@@ -552,11 +552,11 @@ def get_uvicorn_json_log_config() -> dict:
},
# Actual JSON formatters used by handlers below
'json': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'()': 'pythonjsonlogger.json.JsonFormatter',
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(exc_info)s',
},
'json_access': {
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'()': 'pythonjsonlogger.json.JsonFormatter',
'fmt': '%(message)s %(levelname)s %(name)s %(asctime)s %(client_addr)s %(request_line)s %(status_code)s',
},
},

View File

@@ -1,4 +0,0 @@
out/
node_modules/
.vscode-test/
*.vsix

View File

@@ -1,68 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": 2020,
"sourceType": "module"
},
"extends": [
"airbnb-base",
"airbnb-typescript/base",
"prettier",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["prettier", "unused-imports"],
"rules": {
"unused-imports/no-unused-imports": "error",
"prettier/prettier": ["error"],
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
"import/extensions": [
"error",
"ignorePackages",
{
"": "never",
"ts": "never"
}
],
// Allow state modification in reduce and similar patterns
"no-param-reassign": [
"error",
{
"props": true,
"ignorePropertyModificationsFor": ["acc", "state"]
}
],
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
"no-restricted-syntax": "off",
"import/prefer-default-export": "off",
"no-underscore-dangle": "off",
"import/no-extraneous-dependencies": "off",
// VSCode extension specific - allow console for debugging
"no-console": "warn",
// Allow leading underscores for private variables in VSCode extensions
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "variable",
"format": ["camelCase", "PascalCase", "UPPER_CASE"],
"leadingUnderscore": "allow"
}
]
},
"env": {
"node": true,
"es6": true
},
"overrides": [
{
"files": ["src/test/**/*.ts"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"no-console": "off",
"@typescript-eslint/no-shadow": "off",
"consistent-return": "off"
}
}
]
}

View File

@@ -1,18 +0,0 @@
# Node modules
node_modules/
# Compiled TypeScript output
out/
# VS Code Extension packaging
*.vsix
# TypeScript build info
*.tsbuildinfo
# Test run output (if any specific folders are generated)
.vscode-test/
# OS-generated files
.DS_Store
Thumbs.db

View File

@@ -1,3 +0,0 @@
{
"trailingComma": "all"
}

View File

@@ -1,11 +0,0 @@
.vscodeignore
.gitignore
*.vsix
node_modules/
out/src/ # We only need out/extension.js and out/extension.js.map
src/
*.tsbuildinfo
tsconfig.json
PLAN.md
README.md
# Add other files/folders to ignore during packaging if needed

View File

@@ -1,97 +0,0 @@
# VSCode Extension Development
This document provides instructions for developing and contributing to the OpenHands VSCode extension.
## Setup
To get started with development, you need to install the dependencies.
```bash
npm install
```
## Building the Extension
The VSCode extension is automatically built during the main OpenHands `pip install` process. However, you can also build it manually.
- **Package the extension:** This creates a `.vsix` file that can be installed in VSCode.
```bash
npm run package-vsix
```
- **Compile TypeScript:** This compiles the source code without creating a package.
```bash
npm run compile
```
## Code Quality and Testing
We use ESLint, Prettier, and TypeScript for code quality.
- **Run linting with auto-fixes:**
```bash
npm run lint:fix
```
- **Run type checking:**
```bash
npm run typecheck
```
- **Run tests:**
```bash
npm run test
```
## Releasing a New Version
The extension has its own version number and is released independently of the main OpenHands application. The release process is automated via the `vscode-extension-build.yml` GitHub Actions workflow and is triggered by pushing a specially formatted Git tag.
### 1. Update the Version Number
Before creating a release, you must first bump the version number in the extension's `package.json` file.
1. Open `openhands/integrations/vscode/package.json`.
2. Find the `"version"` field and update it according to [Semantic Versioning](https://semver.org/) (e.g., from `"0.0.1"` to `"0.0.2"`).
### 2. Commit the Version Bump
Commit the change to `package.json` with a clear commit message.
```bash
git add openhands/integrations/vscode/package.json
git commit -m "chore(vscode): bump version to 0.0.2"
```
### 3. Create and Push the Tag
The release is triggered by a Git tag that **must** match the version in `package.json` and be prefixed with `ext-v`.
1. **Create an annotated tag.** The tag name must be `ext-v` followed by the version number you just set.
```bash
# Example for version 0.0.2
git tag -a ext-v0.0.2 -m "Release VSCode extension v0.0.2"
```
2. **Push the commit and the tag** to the `upstream` remote.
```bash
# Push the branch with the version bump commit
git push upstream <your-branch-name>
# Push the specific tag
git push upstream ext-v0.0.2
```
### 4. Finalize the Release on GitHub
Pushing the tag will automatically trigger the `VSCode Extension CI` workflow. This workflow will:
1. Build the `.vsix` file.
2. Create a new **draft release** on GitHub with the `.vsix` file attached as an asset.
To finalize the release:
1. Go to the "Releases" page of the OpenHands repository on GitHub.
2. Find the new draft release (e.g., `ext-v0.0.2`).
3. Click "Edit" to write the release notes, describing the new features and bug fixes.
4. Click the **"Publish release"** button.
The release is now public and available for users.

View File

@@ -1,25 +0,0 @@
The MIT License (MIT)
=====================
Copyright © 2025
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,48 +0,0 @@
# OpenHands VS Code Extension
The official OpenHands companion extension for Visual Studio Code.
This extension seamlessly integrates OpenHands into your VSCode workflow, allowing you to start coding sessions with your AI agent directly from your editor.
![OpenHands VSCode Extension Demo](https://raw.githubusercontent.com/OpenHands/OpenHands/main/assets/images/vscode-extension-demo.gif)
## Features
- **Start a New Conversation**: Launch OpenHands in a new terminal with a single command.
- **Use Your Current File**: Automatically send the content of your active file to OpenHands to start a task.
- **Use a Selection**: Send only the highlighted text from your editor to OpenHands for focused tasks.
- **Safe Terminal Management**: The extension intelligently reuses idle terminals or creates new ones, ensuring it never interrupts an active process.
- **Automatic Virtual Environment Detection**: Finds and uses your project's Python virtual environment (`.venv`, `venv`, etc.) automatically.
## How to Use
You can access the extension's commands in two ways:
1. **Command Palette**:
- Open the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`).
- Type `OpenHands` to see the available commands.
- Select the command you want to run.
2. **Editor Context Menu**:
- Right-click anywhere in your text editor.
- The OpenHands commands will appear in the context menu.
## Installation
For the best experience, the OpenHands CLI will attempt to install the extension for you automatically the first time you run it inside VSCode.
If you need to install it manually:
1. Download the latest `.vsix` file from the [GitHub Releases page](https://github.com/OpenHands/OpenHands/releases).
2. In VSCode, open the Command Palette (`Ctrl+Shift+P`).
3. Run the **"Extensions: Install from VSIX..."** command.
4. Select the `.vsix` file you downloaded.
## Requirements
- **OpenHands CLI**: You must have `openhands` installed and available in your system's PATH.
- **VS Code**: Version 1.98.2 or newer.
- **Shell**: For the best terminal reuse experience, a shell with [Shell Integration](https://code.visualstudio.com/docs/terminal/shell-integration) is recommended (e.g., modern versions of bash, zsh, PowerShell, or fish).
## Contributing
We welcome contributions! If you're interested in developing the extension, please see the `DEVELOPMENT.md` file in our source repository for instructions on how to get started.

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +0,0 @@
{
"name": "openhands-vscode",
"displayName": "OpenHands Integration",
"description": "Integrates OpenHands with VS Code for easy conversation starting and context passing.",
"version": "0.0.1",
"publisher": "openhands",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/openhands/OpenHands.git"
},
"engines": {
"vscode": "^1.98.2",
"node": ">=18.0.0"
},
"activationEvents": [
"onCommand:openhands.startConversation",
"onCommand:openhands.startConversationWithFileContext",
"onCommand:openhands.startConversationWithSelectionContext"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "openhands.startConversation",
"title": "Start New Conversation",
"category": "OpenHands"
},
{
"command": "openhands.startConversationWithFileContext",
"title": "Start with File Content",
"category": "OpenHands"
},
{
"command": "openhands.startConversationWithSelectionContext",
"title": "Start with Selected Text",
"category": "OpenHands"
}
],
"submenus": [
{
"id": "openhands.contextMenu",
"label": "OpenHands"
}
],
"menus": {
"editor/context": [
{
"submenu": "openhands.contextMenu",
"group": "navigation@1"
}
],
"openhands.contextMenu": [
{
"when": "editorHasSelection",
"command": "openhands.startConversationWithSelectionContext",
"group": "1@1"
},
{
"command": "openhands.startConversationWithFileContext",
"group": "1@2"
}
],
"commandPalette": [
{
"command": "openhands.startConversation",
"when": "true"
},
{
"command": "openhands.startConversationWithFileContext",
"when": "editorIsOpen"
},
{
"command": "openhands.startConversationWithSelectionContext",
"when": "editorHasSelection"
}
]
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"test": "npm run compile && node ./out/test/runTest.js",
"package-vsix": "npm run compile && npx vsce package --no-dependencies",
"lint": "npm run typecheck && eslint src --ext .ts && prettier --check src/**/*.ts",
"lint:fix": "eslint src --ext .ts --fix && prettier --write src/**/*.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/vscode": "^1.98.2",
"typescript": "^5.0.0",
"@types/mocha": "^10.0.6",
"mocha": "^10.4.0",
"@vscode/test-electron": "^2.3.9",
"@types/node": "^20.12.12",
"@types/glob": "^8.1.0",
"@vscode/vsce": "^3.5.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3"
}
}

View File

@@ -1,380 +0,0 @@
import * as vscode from "vscode";
import * as fs from "fs";
import * as path from "path";
// Create output channel for debug logging
const outputChannel = vscode.window.createOutputChannel("OpenHands Debug");
/**
* This implementation uses VSCode's Shell Integration API.
*
* VSCode API References:
* - Terminal Shell Integration: https://code.visualstudio.com/docs/terminal/shell-integration
* - VSCode Extension API: https://code.visualstudio.com/api/references/vscode-api
* - Terminal API Reference: https://code.visualstudio.com/api/references/vscode-api#Terminal
* - VSCode Source Examples: https://github.com/microsoft/vscode/blob/main/src/vscode-dts/vscode.d.ts
*
* Shell Integration Requirements:
* - Compatible shells: bash, zsh, PowerShell Core, or fish shell
* - Graceful fallback needed for Command Prompt and other shells
*/
// Track terminals that we know are idle (just finished our commands)
const idleTerminals = new Set<string>();
/**
* Marks a terminal as idle after our command completes
* @param terminalName The name of the terminal
*/
function markTerminalAsIdle(terminalName: string): void {
idleTerminals.add(terminalName);
}
/**
* Marks a terminal as busy when we start a command
* @param terminalName The name of the terminal
*/
function markTerminalAsBusy(terminalName: string): void {
idleTerminals.delete(terminalName);
}
/**
* Checks if we know a terminal is idle (safe to reuse)
* @param terminal The terminal to check
* @returns boolean true if we know it's idle, false otherwise
*/
function isKnownIdleTerminal(terminal: vscode.Terminal): boolean {
return idleTerminals.has(terminal.name);
}
/**
* Creates a new OpenHands terminal with timestamp
* @returns vscode.Terminal
*/
function createNewOpenHandsTerminal(): vscode.Terminal {
const timestamp = new Date().toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
const terminalName = `OpenHands ${timestamp}`;
return vscode.window.createTerminal(terminalName);
}
/**
* Finds an existing OpenHands terminal or creates a new one using safe detection
* @returns vscode.Terminal
*/
function findOrCreateOpenHandsTerminal(): vscode.Terminal {
const openHandsTerminals = vscode.window.terminals.filter((terminal) =>
terminal.name.startsWith("OpenHands"),
);
if (openHandsTerminals.length > 0) {
// Use the most recent terminal, but only if we know it's idle
const terminal = openHandsTerminals[openHandsTerminals.length - 1];
// Only reuse terminals that we know are idle (safe to reuse)
if (isKnownIdleTerminal(terminal)) {
return terminal;
}
// If we don't know the terminal is idle, create a new one to avoid interrupting running processes
return createNewOpenHandsTerminal();
}
// No existing terminals, create new one
return createNewOpenHandsTerminal();
}
/**
* Executes an OpenHands command using Shell Integration when available
* @param terminal The terminal to execute the command in
* @param command The command to execute
*/
function executeOpenHandsCommand(
terminal: vscode.Terminal,
command: string,
): void {
// Mark terminal as busy when we start a command
markTerminalAsBusy(terminal.name);
if (terminal.shellIntegration) {
// Use Shell Integration for better control
const execution = terminal.shellIntegration.executeCommand(command);
// Monitor execution completion
const disposable = vscode.window.onDidEndTerminalShellExecution((event) => {
if (event.execution === execution) {
if (event.exitCode === 0) {
outputChannel.appendLine(
"DEBUG: OpenHands command completed successfully",
);
// Mark terminal as idle when command completes successfully
markTerminalAsIdle(terminal.name);
} else if (event.exitCode !== undefined) {
outputChannel.appendLine(
`DEBUG: OpenHands command exited with code ${event.exitCode}`,
);
// Mark terminal as idle even if command failed (user can reuse it)
markTerminalAsIdle(terminal.name);
}
disposable.dispose(); // Clean up the event listener
}
});
} else {
// Fallback to traditional sendText
terminal.sendText(command, true);
// For traditional sendText, we can't track completion, so don't mark as idle
// This means terminals without Shell Integration won't be reused, which is safer
}
}
/**
* Detects and builds virtual environment activation command
* @returns string The activation command prefix (empty if no venv found)
*/
function detectVirtualEnvironment(): string {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
outputChannel.appendLine("DEBUG: No workspace folder found");
return "";
}
const venvPaths = [".venv", "venv", ".virtualenv"];
for (const venvPath of venvPaths) {
const venvFullPath = path.join(workspaceFolder.uri.fsPath, venvPath);
if (fs.existsSync(venvFullPath)) {
outputChannel.appendLine(`DEBUG: Found venv at ${venvFullPath}`);
if (process.platform === "win32") {
// For Windows, the activation command is different and typically doesn't use 'source'
// It's often a script that needs to be executed.
// This is a simplified version. A more robust solution might need to check for PowerShell, cmd, etc.
return `& "${path.join(venvFullPath, "Scripts", "Activate.ps1")}" && `;
}
// For POSIX-like shells
return `source "${path.join(venvFullPath, "bin", "activate")}" && `;
}
}
outputChannel.appendLine(
`DEBUG: No venv found in workspace ${workspaceFolder.uri.fsPath}`,
);
return "";
}
/**
* Creates a contextual task message for file content
* @param filePath The file path (or "Untitled" for unsaved files)
* @param content The file content
* @param languageId The programming language ID
* @returns string A descriptive task message
*/
function createFileContextMessage(
filePath: string,
content: string,
languageId?: string,
): string {
const fileName =
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
const langInfo = languageId ? ` (${languageId})` : "";
return `User opened ${fileName}${langInfo}. Here's the content:
\`\`\`${languageId || ""}
${content}
\`\`\`
Please ask the user what they want to do with this file.`;
}
/**
* Creates a contextual task message for selected text
* @param filePath The file path (or "Untitled" for unsaved files)
* @param content The selected content
* @param startLine 1-based start line number
* @param endLine 1-based end line number
* @param languageId The programming language ID
* @returns string A descriptive task message
*/
function createSelectionContextMessage(
filePath: string,
content: string,
startLine: number,
endLine: number,
languageId?: string,
): string {
const fileName =
filePath === "Untitled" ? "an untitled file" : `file ${filePath}`;
const langInfo = languageId ? ` (${languageId})` : "";
const lineInfo =
startLine === endLine
? `line ${startLine}`
: `lines ${startLine}-${endLine}`;
return `User selected ${lineInfo} in ${fileName}${langInfo}. Here's the selected content:
\`\`\`${languageId || ""}
${content}
\`\`\`
Please ask the user what they want to do with this selection.`;
}
/**
* Builds the OpenHands command with proper sanitization
* @param options Command options
* @param activationCommand Virtual environment activation prefix
* @returns string The complete command to execute
*/
function buildOpenHandsCommand(
options: { task?: string; filePath?: string },
activationCommand: string,
): string {
let commandToSend = `${activationCommand}openhands`;
if (options.filePath) {
// Ensure filePath is properly quoted if it contains spaces or special characters
const safeFilePath = options.filePath.includes(" ")
? `"${options.filePath}"`
: options.filePath;
commandToSend = `${activationCommand}openhands --file ${safeFilePath}`;
} else if (options.task) {
// Sanitize task string for command line (basic sanitization)
// Replace backticks and double quotes that might break the command
const sanitizedTask = options.task
.replace(/`/g, "\\`")
.replace(/"/g, '\\"');
commandToSend = `${activationCommand}openhands --task "${sanitizedTask}"`;
}
return commandToSend;
}
/**
* Main function to start OpenHands in terminal with safe terminal reuse
* @param options Command options
*/
function startOpenHandsInTerminal(options: {
task?: string;
filePath?: string;
}): void {
try {
// Find or create terminal using safe detection
const terminal = findOrCreateOpenHandsTerminal();
terminal.show(true); // true to preserve focus on the editor
// Detect virtual environment
const activationCommand = detectVirtualEnvironment();
// Build command
const commandToSend = buildOpenHandsCommand(options, activationCommand);
// Debug: show the actual command being sent
outputChannel.appendLine(`DEBUG: Sending command: ${commandToSend}`);
// Execute command using Shell Integration when available
executeOpenHandsCommand(terminal, commandToSend);
} catch (error) {
vscode.window.showErrorMessage(`Error starting OpenHands: ${error}`);
}
}
export function activate(context: vscode.ExtensionContext) {
// Clean up terminal tracking when terminals are closed
const terminalCloseDisposable = vscode.window.onDidCloseTerminal(
(terminal) => {
idleTerminals.delete(terminal.name);
},
);
context.subscriptions.push(terminalCloseDisposable);
// Command: Start New Conversation
const startConversationDisposable = vscode.commands.registerCommand(
"openhands.startConversation",
() => {
startOpenHandsInTerminal({});
},
);
context.subscriptions.push(startConversationDisposable);
// Command: Start Conversation with Active File Content
const startWithFileContextDisposable = vscode.commands.registerCommand(
"openhands.startConversationWithFileContext",
() => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
// No active editor, start conversation without task
startOpenHandsInTerminal({});
return;
}
if (editor.document.isUntitled) {
const fileContent = editor.document.getText();
if (!fileContent.trim()) {
// Empty untitled file, start conversation without task
startOpenHandsInTerminal({});
return;
}
// Create contextual message for untitled file
const contextualTask = createFileContextMessage(
"Untitled",
fileContent,
editor.document.languageId,
);
startOpenHandsInTerminal({ task: contextualTask });
} else {
const filePath = editor.document.uri.fsPath;
// For saved files, we can still use --file flag for better performance,
// but we could also create a contextual message if preferred
startOpenHandsInTerminal({ filePath });
}
},
);
context.subscriptions.push(startWithFileContextDisposable);
// Command: Start Conversation with Selected Text
const startWithSelectionContextDisposable = vscode.commands.registerCommand(
"openhands.startConversationWithSelectionContext",
() => {
outputChannel.appendLine(
"DEBUG: startConversationWithSelectionContext command triggered!",
);
const editor = vscode.window.activeTextEditor;
if (!editor) {
// No active editor, start conversation without task
startOpenHandsInTerminal({});
return;
}
if (editor.selection.isEmpty) {
// No text selected, start conversation without task
startOpenHandsInTerminal({});
return;
}
const selectedText = editor.document.getText(editor.selection);
const startLine = editor.selection.start.line + 1; // Convert to 1-based
const endLine = editor.selection.end.line + 1; // Convert to 1-based
const filePath = editor.document.isUntitled
? "Untitled"
: editor.document.uri.fsPath;
// Create contextual message with line numbers and file info
const contextualTask = createSelectionContextMessage(
filePath,
selectedText,
startLine,
endLine,
editor.document.languageId,
);
startOpenHandsInTerminal({ task: contextualTask });
},
);
context.subscriptions.push(startWithSelectionContextDisposable);
}
export function deactivate() {
// Clean up resources if needed, though for this simple extension,
// VS Code handles terminal disposal.
}

View File

@@ -1,22 +0,0 @@
import * as path from "path";
import { runTests } from "@vscode/test-electron";
async function main() {
try {
// The folder containing the Extension Manifest package.json
// Passed to `--extensionDevelopmentPath`
const extensionDevelopmentPath = path.resolve(__dirname, "../../../");
// The path to the extension test script
// Passed to --extensionTestsPath
const extensionTestsPath = path.resolve(__dirname, "./suite/index"); // Points to the compiled version of suite/index.ts
// Download VS Code, unzip it and run the integration test
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error("Failed to run tests");
process.exit(1);
}
}
main();

View File

@@ -1,848 +0,0 @@
import * as assert from "assert";
import * as vscode from "vscode";
suite("Extension Test Suite", () => {
let mockTerminal: vscode.Terminal;
let sendTextSpy: any; // Manual spy, using 'any' type
let showSpy: any; // Manual spy
let createTerminalStub: any; // Manual stub
let findTerminalStub: any; // Manual spy
let showErrorMessageSpy: any; // Manual spy
// It's better to use a proper mocking library like Sinon.JS for spies and stubs.
// For now, we'll use a simplified manual approach for spies.
const createManualSpy = () => {
const spy: any = (...args: any[]) => {
// eslint-disable-line @typescript-eslint/no-explicit-any
spy.called = true;
spy.callCount = (spy.callCount || 0) + 1;
spy.lastArgs = args;
spy.argsHistory = spy.argsHistory || [];
spy.argsHistory.push(args);
};
spy.called = false;
spy.callCount = 0;
spy.lastArgs = null;
spy.argsHistory = [];
spy.resetHistory = () => {
spy.called = false;
spy.callCount = 0;
spy.lastArgs = null;
spy.argsHistory = [];
};
return spy;
};
setup(() => {
// Reset spies and stubs before each test
sendTextSpy = createManualSpy();
showSpy = createManualSpy();
showErrorMessageSpy = createManualSpy();
mockTerminal = {
name: "OpenHands",
processId: Promise.resolve(123),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined, // Added to satisfy Terminal interface
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
}, // Added shell property
shellIntegration: undefined, // No Shell Integration in tests by default
};
// Store original functions
const _originalCreateTerminal = vscode.window.createTerminal;
const _originalTerminalsDescriptor = Object.getOwnPropertyDescriptor(
vscode.window,
"terminals",
);
const _originalShowErrorMessage = vscode.window.showErrorMessage;
// Stub vscode.window.createTerminal
createTerminalStub = createManualSpy();
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
createTerminalStub(...args); // Call the spy with whatever arguments it received
return mockTerminal; // Return the mock terminal
};
// Stub vscode.window.terminals
findTerminalStub = createManualSpy(); // To track if vscode.window.terminals getter is accessed
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
// Default to returning the mockTerminal, can be overridden in specific tests
return [mockTerminal];
},
configurable: true,
});
vscode.window.showErrorMessage = showErrorMessageSpy as any;
// Restore default mock behavior before each test
setup(() => {
// Reset spies
createTerminalStub.resetHistory();
sendTextSpy.resetHistory();
showSpy.resetHistory();
findTerminalStub.resetHistory();
showErrorMessageSpy.resetHistory();
// Restore default createTerminal mock
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
createTerminalStub(...args);
return mockTerminal; // Return the default mock terminal (no Shell Integration)
};
// Restore default terminals mock
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [mockTerminal]; // Default to returning the mockTerminal
},
configurable: true,
});
});
// Teardown logic to restore original functions
teardown(() => {
vscode.window.createTerminal = _originalCreateTerminal;
if (_originalTerminalsDescriptor) {
Object.defineProperty(
vscode.window,
"terminals",
_originalTerminalsDescriptor,
);
} else {
// If it wasn't originally defined, delete it to restore to that state
delete (vscode.window as any).terminals;
}
vscode.window.showErrorMessage = _originalShowErrorMessage;
});
});
test("Extension should be present and activate", async () => {
const extension = vscode.extensions.getExtension(
"openhands.openhands-vscode",
);
assert.ok(
extension,
"Extension should be found (check publisher.name in package.json)",
);
if (!extension.isActive) {
await extension.activate();
}
assert.ok(extension.isActive, "Extension should be active");
});
test("Commands should be registered", async () => {
const extension = vscode.extensions.getExtension(
"openhands.openhands-vscode",
);
if (extension && !extension.isActive) {
await extension.activate();
}
const commands = await vscode.commands.getCommands(true);
const expectedCommands = [
"openhands.startConversation",
"openhands.startConversationWithFileContext",
"openhands.startConversationWithSelectionContext",
];
for (const cmd of expectedCommands) {
assert.ok(
commands.includes(cmd),
`Command '${cmd}' should be registered`,
);
}
});
test("openhands.startConversation should send correct command to terminal", async () => {
findTerminalStub.resetHistory(); // Reset for this specific test path if needed
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [];
},
configurable: true,
}); // Simulate no existing terminal
await vscode.commands.executeCommand("openhands.startConversation");
assert.ok(
createTerminalStub.called,
"vscode.window.createTerminal should be called",
);
assert.ok(showSpy.called, "terminal.show should be called");
assert.deepStrictEqual(
sendTextSpy.lastArgs,
["openhands", true],
"Correct command sent to terminal",
);
});
test("openhands.startConversationWithFileContext (saved file) should send --file command", async () => {
const testFilePath = "/test/file.py";
// Mock activeTextEditor for a saved file
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => ({
document: {
isUntitled: false,
uri: vscode.Uri.file(testFilePath),
fsPath: testFilePath, // fsPath is often used
getText: () => "file content", // Not used for saved files but good to have
},
}),
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithFileContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
assert.deepStrictEqual(sendTextSpy.lastArgs, [
`openhands --file ${testFilePath.includes(" ") ? `"${testFilePath}"` : testFilePath}`,
true,
]);
// Restore activeTextEditor
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("openhands.startConversationWithFileContext (untitled file) should send contextual --task command", async () => {
const untitledFileContent = "untitled content";
const languageId = "javascript";
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => ({
document: {
isUntitled: true,
uri: vscode.Uri.parse("untitled:Untitled-1"),
getText: () => untitledFileContent,
languageId,
},
}),
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithFileContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
// Check that the command contains the contextual message
const expectedMessage = `User opened an untitled file (${languageId}). Here's the content:
\`\`\`${languageId}
${untitledFileContent}
\`\`\`
Please ask the user what they want to do with this file.`;
// Apply the same sanitization as the actual implementation
const sanitizedMessage = expectedMessage
.replace(/`/g, "\\`")
.replace(/"/g, '\\"');
assert.deepStrictEqual(sendTextSpy.lastArgs, [
`openhands --task "${sanitizedMessage}"`,
true,
]);
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("openhands.startConversationWithFileContext (no editor) should start conversation without context", async () => {
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => undefined,
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithFileContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("openhands.startConversationWithSelectionContext should send contextual --task with selection", async () => {
const selectedText = "selected text for openhands";
const filePath = "/test/file.py";
const languageId = "python";
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => ({
document: {
isUntitled: false,
uri: vscode.Uri.file(filePath),
fsPath: filePath,
languageId,
getText: (selection?: vscode.Selection) =>
selection ? selectedText : "full content",
},
selection: {
isEmpty: false,
active: new vscode.Position(0, 0),
anchor: new vscode.Position(0, 0),
start: new vscode.Position(0, 0), // Line 0 (0-based)
end: new vscode.Position(0, 10), // Line 0 (0-based)
} as vscode.Selection, // Mock non-empty selection on line 1
}),
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithSelectionContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
// Check that the command contains the contextual message with line numbers
const expectedMessage = `User selected line 1 in file ${filePath} (${languageId}). Here's the selected content:
\`\`\`${languageId}
${selectedText}
\`\`\`
Please ask the user what they want to do with this selection.`;
// Apply the same sanitization as the actual implementation
const sanitizedMessage = expectedMessage
.replace(/`/g, "\\`")
.replace(/"/g, '\\"');
assert.deepStrictEqual(sendTextSpy.lastArgs, [
`openhands --task "${sanitizedMessage}"`,
true,
]);
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("openhands.startConversationWithSelectionContext (no selection) should start conversation without context", async () => {
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => ({
document: {
isUntitled: false,
uri: vscode.Uri.file("/test/file.py"),
getText: () => "full content",
},
selection: { isEmpty: true } as vscode.Selection, // Mock empty selection
}),
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithSelectionContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
assert.deepStrictEqual(sendTextSpy.lastArgs, ["openhands", true]);
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("openhands.startConversationWithSelectionContext should handle multi-line selections", async () => {
const selectedText = "line 1\nline 2\nline 3";
const filePath = "/test/multiline.js";
const languageId = "javascript";
const originalActiveTextEditor = Object.getOwnPropertyDescriptor(
vscode.window,
"activeTextEditor",
);
Object.defineProperty(vscode.window, "activeTextEditor", {
get: () => ({
document: {
isUntitled: false,
uri: vscode.Uri.file(filePath),
fsPath: filePath,
languageId,
getText: (selection?: vscode.Selection) =>
selection ? selectedText : "full content",
},
selection: {
isEmpty: false,
active: new vscode.Position(4, 0),
anchor: new vscode.Position(4, 0),
start: new vscode.Position(4, 0), // Line 4 (0-based) = Line 5 (1-based)
end: new vscode.Position(6, 10), // Line 6 (0-based) = Line 7 (1-based)
} as vscode.Selection, // Mock multi-line selection from line 5 to 7
}),
configurable: true,
});
await vscode.commands.executeCommand(
"openhands.startConversationWithSelectionContext",
);
assert.ok(sendTextSpy.called, "terminal.sendText should be called");
// Check that the command contains the contextual message with line range
const expectedMessage = `User selected lines 5-7 in file ${filePath} (${languageId}). Here's the selected content:
\`\`\`${languageId}
${selectedText}
\`\`\`
Please ask the user what they want to do with this selection.`;
// Apply the same sanitization as the actual implementation
const sanitizedMessage = expectedMessage
.replace(/`/g, "\\`")
.replace(/"/g, '\\"');
assert.deepStrictEqual(sendTextSpy.lastArgs, [
`openhands --task "${sanitizedMessage}"`,
true,
]);
if (originalActiveTextEditor) {
Object.defineProperty(
vscode.window,
"activeTextEditor",
originalActiveTextEditor,
);
}
});
test("Terminal reuse should work when existing OpenHands terminal exists", async () => {
// Create a mock existing terminal
const existingTerminal = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: undefined, // No Shell Integration, should create new terminal
};
// Mock terminals array to return existing terminal
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [existingTerminal];
},
configurable: true,
});
await vscode.commands.executeCommand("openhands.startConversation");
// Should create new terminal since no Shell Integration
assert.ok(
createTerminalStub.called,
"Should create new terminal when no Shell Integration available",
);
});
test("Terminal reuse with Shell Integration should reuse existing terminal", async () => {
// Create mock Shell Integration
const mockExecution = {
read: () => ({
async *[Symbol.asyncIterator]() {
yield "OPENHANDS_PROBE_123456789";
},
}),
exitCode: Promise.resolve(0),
};
const mockShellIntegration = {
executeCommand: () => mockExecution,
};
// Create a mock existing terminal with Shell Integration
const existingTerminalWithShell = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: mockShellIntegration,
};
// Mock terminals array to return existing terminal with Shell Integration
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [existingTerminalWithShell];
},
configurable: true,
});
// Reset create terminal stub to track if new terminal is created
createTerminalStub.resetHistory();
await vscode.commands.executeCommand("openhands.startConversation");
// Should reuse existing terminal since Shell Integration is available
// Note: The probe might timeout in test environment, but it should still reuse the terminal
assert.ok(showSpy.called, "terminal.show should be called");
});
test("Shell Integration should use executeCommand for OpenHands commands", async () => {
const executeCommandSpy = createManualSpy();
// Mock execution for OpenHands command
const mockExecution = {
read: () => ({
async *[Symbol.asyncIterator]() {
yield "OpenHands started successfully";
},
}),
exitCode: Promise.resolve(0),
commandLine: {
value: "openhands",
isTrusted: true,
confidence: 2,
},
cwd: vscode.Uri.file("/test/directory"),
};
const mockShellIntegration = {
executeCommand: (command: string) => {
executeCommandSpy(command);
return mockExecution;
},
cwd: vscode.Uri.file("/test/directory"),
};
// Create a terminal with Shell Integration that will be created by createTerminal
const terminalWithShell = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: mockShellIntegration,
};
// Mock createTerminal to return a terminal with Shell Integration
createTerminalStub.resetHistory();
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
createTerminalStub(...args);
return terminalWithShell; // Return terminal with Shell Integration
};
// Mock empty terminals array so we create a new one
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return []; // No existing terminals
},
configurable: true,
});
await vscode.commands.executeCommand("openhands.startConversation");
// Should have called executeCommand for OpenHands command
assert.ok(
executeCommandSpy.called,
"Shell Integration executeCommand should be called for OpenHands command",
);
// Check that the command was an OpenHands command
const openhandsCall = executeCommandSpy.argsHistory.find(
(args: any[]) => args[0] && args[0].includes("openhands"),
);
assert.ok(
openhandsCall,
`Should execute OpenHands command. Actual calls: ${JSON.stringify(executeCommandSpy.argsHistory)}`,
);
// Should create new terminal since none exist
assert.ok(
createTerminalStub.called,
"Should create new terminal when none exist",
);
});
test("Idle terminal tracking should reuse known idle terminals", async () => {
const executeCommandSpy = createManualSpy();
// Mock execution for OpenHands command
const mockExecution = {
read: () => ({
async *[Symbol.asyncIterator]() {
yield "OpenHands started successfully";
},
}),
exitCode: Promise.resolve(0),
commandLine: {
value: "openhands",
isTrusted: true,
confidence: 2,
},
cwd: vscode.Uri.file("/test/directory"),
};
const mockShellIntegration = {
executeCommand: (command: string) => {
executeCommandSpy(command);
return mockExecution;
},
cwd: vscode.Uri.file("/test/directory"),
};
const terminalWithShell = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: mockShellIntegration,
};
// First, manually mark the terminal as idle (simulating a previous successful command)
// We need to access the extension's internal idle tracking
// For testing, we'll simulate this by running a command first, then another
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [terminalWithShell];
},
configurable: true,
});
createTerminalStub.resetHistory();
// First command to establish the terminal as idle
await vscode.commands.executeCommand("openhands.startConversation");
// Simulate command completion to mark terminal as idle
// This would normally happen via the onDidEndTerminalShellExecution event
createTerminalStub.resetHistory();
executeCommandSpy.resetHistory();
// Second command should reuse the terminal if it's marked as idle
await vscode.commands.executeCommand("openhands.startConversation");
// Should show terminal
assert.ok(showSpy.called, "Should show terminal");
});
test("Shell Integration should use executeCommand when available", async () => {
const executeCommandSpy = createManualSpy();
const mockExecution = {
read: () => ({
async *[Symbol.asyncIterator]() {
yield "OpenHands started successfully";
},
}),
exitCode: Promise.resolve(0),
commandLine: {
value: "openhands",
isTrusted: true,
confidence: 2,
},
cwd: vscode.Uri.file("/test/directory"),
};
const mockShellIntegration = {
executeCommand: (command: string) => {
executeCommandSpy(command);
return mockExecution;
},
cwd: vscode.Uri.file("/test/directory"),
};
const terminalWithShell = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: mockShellIntegration,
};
// Mock createTerminal to return a terminal with Shell Integration
createTerminalStub.resetHistory();
vscode.window.createTerminal = (...args: any[]): vscode.Terminal => {
createTerminalStub(...args);
return terminalWithShell; // Return terminal with Shell Integration
};
// Mock empty terminals array so we create a new one
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return []; // No existing terminals
},
configurable: true,
});
sendTextSpy.resetHistory();
executeCommandSpy.resetHistory();
await vscode.commands.executeCommand("openhands.startConversation");
// Should use Shell Integration executeCommand, not sendText
assert.ok(
executeCommandSpy.called,
"Should use Shell Integration executeCommand",
);
// The OpenHands command should be executed via Shell Integration
const openhandsCommand = executeCommandSpy.argsHistory.find(
(args: any[]) => args[0] && args[0].includes("openhands"),
);
assert.ok(
openhandsCommand,
"Should execute OpenHands command via Shell Integration",
);
});
test("Terminal creation should work when no existing terminals", async () => {
// Mock empty terminals array
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return []; // No existing terminals
},
configurable: true,
});
createTerminalStub.resetHistory();
await vscode.commands.executeCommand("openhands.startConversation");
// Should create new terminal when none exist
assert.ok(
createTerminalStub.called,
"Should create new terminal when none exist",
);
// Should show the new terminal
assert.ok(showSpy.called, "Should show the new terminal");
});
test("Shell Integration fallback should work when Shell Integration unavailable", async () => {
// Create terminal without Shell Integration
const terminalWithoutShell = {
name: "OpenHands 10:30:15",
processId: Promise.resolve(456),
sendText: sendTextSpy as any,
show: showSpy as any,
hide: () => {},
dispose: () => {},
creationOptions: {},
exitStatus: undefined,
state: {
isInteractedWith: false,
shell: undefined as string | undefined,
},
shellIntegration: undefined, // No Shell Integration
};
Object.defineProperty(vscode.window, "terminals", {
get: () => {
findTerminalStub();
return [terminalWithoutShell];
},
configurable: true,
});
createTerminalStub.resetHistory();
sendTextSpy.resetHistory();
await vscode.commands.executeCommand("openhands.startConversation");
// Should create new terminal when no Shell Integration available
assert.ok(
createTerminalStub.called,
"Should create new terminal when Shell Integration unavailable",
);
// Should use sendText fallback for the new terminal
assert.ok(sendTextSpy.called, "Should use sendText fallback");
assert.ok(
sendTextSpy.lastArgs[0].includes("openhands"),
"Should send OpenHands command",
);
});
});

View File

@@ -1,37 +0,0 @@
import * as path from "path";
import Mocha = require("mocha");
import { glob } from "glob"; // Updated for glob v9+ API
export async function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
// This should now work with the changed import
ui: "tdd", // Use TDD interface
color: true, // Colored output
timeout: 15000, // Increased timeout for extension tests
});
const testsRoot = path.resolve(__dirname, ".."); // Root of the /src/test folder (compiled to /out/test)
try {
// Use glob to find all test files (ending with .test.js in the compiled output)
const files = await glob("**/**.test.js", { cwd: testsRoot });
// Add files to the test suite
files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f)));
// Run the mocha test
return await new Promise<void>((resolve, reject) => {
mocha.run((failures: number) => {
if (failures > 0) {
reject(new Error(`${failures} tests failed.`));
} else {
resolve();
}
});
});
} catch (err) {
console.error(err);
throw err;
}
}

View File

@@ -1,23 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"outDir": "out",
"lib": [
"es2020"
],
"sourceMap": true,
"strict": true,
"rootDir": "src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"exclude": [
"node_modules",
".vscode-test"
],
"include": [
"src"
]
}

View File

@@ -22,7 +22,7 @@ import base64
from typing import Any
import docx
import PyPDF2
import pypdf
from pptx import Presentation
from pylatexenc.latex2text import LatexNodes2Text
@@ -42,7 +42,7 @@ def parse_pdf(file_path: str) -> None:
file_path: str: The path to the file to open.
"""
print(f'[Reading PDF file from {file_path}]')
content = PyPDF2.PdfReader(file_path)
content = pypdf.PdfReader(file_path)
text = ''
for page_idx in range(len(content.pages)):
text += (

2
poetry.lock generated
View File

@@ -16824,4 +16824,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "9360db8d9ee46922f780ac13e2954c0b62166efd9c3d1b3cf61a9228889152fa"
content-hash = "ea3a3dcacf87517954778e7b04f0a5865bf213442a7bdbc4f2dc467713dbf82f"

View File

@@ -19,10 +19,8 @@ packages = [
{ include = "poetry.lock", to = "openhands" },
]
include = [
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
"skills/**/*",
]
build = "build_vscode.py" # Build VSCode extension during Poetry build
[tool.poetry.dependencies]
python = "^3.12,<3.14"
@@ -79,7 +77,6 @@ shellingham = "^1.5.4"
# TODO: Should these go into the runtime group?
ipywidgets = "^8.1.5"
qtconsole = "^5.6.1"
PyPDF2 = "*"
python-pptx = "*"
pylatexenc = "*"
python-docx = "*"

View File

@@ -444,6 +444,138 @@ class TestDockerSandboxService:
):
await service.start_sandbox()
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
@patch('os.urandom')
async def test_start_sandbox_with_extra_hosts(
self,
mock_urandom,
mock_encodebytes,
mock_sandbox_spec_service,
mock_httpx_client,
mock_docker_client,
):
"""Test that extra_hosts are passed to container creation."""
# Setup
mock_urandom.side_effect = [b'container_id', b'session_key']
mock_encodebytes.side_effect = ['test_container_id', 'test_session_key']
mock_container = MagicMock()
mock_container.name = 'oh-test-test_container_id'
mock_container.status = 'running'
mock_container.image.tags = ['test-image:latest']
mock_container.attrs = {
'Created': '2024-01-15T10:30:00.000000000Z',
'Config': {
'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value']
},
'NetworkSettings': {'Ports': {}},
}
mock_docker_client.containers.run.return_value = mock_container
# Create service with extra_hosts
service_with_extra_hosts = DockerSandboxService(
sandbox_spec_service=mock_sandbox_spec_service,
container_name_prefix='oh-test-',
host_port=3000,
container_url_pattern='http://localhost:{port}',
mounts=[],
exposed_ports=[
ExposedPort(
name=AGENT_SERVER, description='Agent server', container_port=8000
),
],
health_check_path='/health',
httpx_client=mock_httpx_client,
max_num_sandboxes=3,
extra_hosts={
'host.docker.internal': 'host-gateway',
'custom.host': '192.168.1.100',
},
docker_client=mock_docker_client,
)
with (
patch.object(
service_with_extra_hosts, '_find_unused_port', return_value=12345
),
patch.object(
service_with_extra_hosts, 'pause_old_sandboxes', return_value=[]
),
):
# Execute
await service_with_extra_hosts.start_sandbox()
# Verify extra_hosts was passed to container creation
mock_docker_client.containers.run.assert_called_once()
call_args = mock_docker_client.containers.run.call_args
assert call_args[1]['extra_hosts'] == {
'host.docker.internal': 'host-gateway',
'custom.host': '192.168.1.100',
}
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
@patch('os.urandom')
async def test_start_sandbox_without_extra_hosts(
self,
mock_urandom,
mock_encodebytes,
mock_sandbox_spec_service,
mock_httpx_client,
mock_docker_client,
):
"""Test that extra_hosts is None when not configured."""
# Setup
mock_urandom.side_effect = [b'container_id', b'session_key']
mock_encodebytes.side_effect = ['test_container_id', 'test_session_key']
mock_container = MagicMock()
mock_container.name = 'oh-test-test_container_id'
mock_container.status = 'running'
mock_container.image.tags = ['test-image:latest']
mock_container.attrs = {
'Created': '2024-01-15T10:30:00.000000000Z',
'Config': {
'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value']
},
'NetworkSettings': {'Ports': {}},
}
mock_docker_client.containers.run.return_value = mock_container
# Create service without extra_hosts (empty dict)
service_without_extra_hosts = DockerSandboxService(
sandbox_spec_service=mock_sandbox_spec_service,
container_name_prefix='oh-test-',
host_port=3000,
container_url_pattern='http://localhost:{port}',
mounts=[],
exposed_ports=[
ExposedPort(
name=AGENT_SERVER, description='Agent server', container_port=8000
),
],
health_check_path='/health',
httpx_client=mock_httpx_client,
max_num_sandboxes=3,
extra_hosts={},
docker_client=mock_docker_client,
)
with (
patch.object(
service_without_extra_hosts, '_find_unused_port', return_value=12345
),
patch.object(
service_without_extra_hosts, 'pause_old_sandboxes', return_value=[]
),
):
# Execute
await service_without_extra_hosts.start_sandbox()
# Verify extra_hosts is None when empty dict is provided
mock_docker_client.containers.run.assert_called_once()
call_args = mock_docker_client.containers.run.call_args
assert call_args[1]['extra_hosts'] is None
async def test_resume_sandbox_from_paused(self, service):
"""Test resuming a paused sandbox."""
# Setup